2025-04-14 19:29:56 +02:00
using System.Diagnostics ;
2026-07-19 16:15:09 +02:00
using System.Globalization ;
2025-04-14 19:29:56 +02:00
using System.Text.RegularExpressions ;
2025-04-15 08:08:39 +02:00
using SharedTools ;
2025-04-14 19:29:56 +02:00
namespace Build.Commands ;
// ReSharper disable MemberCanBePrivate.Global
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
public sealed partial class UpdateMetadataCommands
{
[Command("release", Description = "Prepare & build the next release")]
2026-01-11 17:00:33 +01:00
public async Task Release (
[Option("action", ['a'] , Description = "The release action: patch, minor, or major" )] PrepareAction action = PrepareAction . NONE ,
2026-06-11 12:22:09 +02:00
[Option("version", ['v'] , Description = "Set a specific version directly, e.g., 26.1.2" )] string? version = null ,
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false )
2025-04-14 19:29:56 +02:00
{
if (! Environment . IsWorkingDirectoryValid ())
return ;
2026-01-11 17:00:33 +01:00
// Validate parameters: either action or version must be specified, but not both:
if ( action == PrepareAction . NONE && string . IsNullOrWhiteSpace ( version ))
{
Console . WriteLine ( "- Error: You must specify either --action (-a) or --version (-v)." );
return ;
}
if ( action != PrepareAction . NONE && ! string . IsNullOrWhiteSpace ( version ))
{
Console . WriteLine ( "- Error: You cannot specify both --action and --version. Please use only one." );
return ;
}
// If version is specified, use SET action:
if (! string . IsNullOrWhiteSpace ( version ))
action = PrepareAction . SET ;
2025-04-14 19:29:56 +02:00
// Prepare the metadata for the next release:
2026-01-11 17:00:33 +01:00
await this . PerformPrepare ( action , true , version );
2026-07-19 16:15:09 +02:00
await this . BuildPreparedRelease ( offline );
}
[Command("rebuild-release", Description = "Prepare & build a new build of the current release")]
public async Task RebuildRelease (
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false )
{
if (! Environment . IsWorkingDirectoryValid ())
return ;
Console . WriteLine ( "==============================" );
Console . WriteLine ( "- Prepare a new build of the current release ..." );
RebuildReleaseState releaseState ;
try
{
releaseState = await this . ValidateRebuildReleaseState ();
}
catch ( InvalidOperationException exception )
{
Console . WriteLine ( $"- Error: {exception.Message}" );
return ;
}
await this . ApplyRebuildReleaseState ( releaseState , DateTime . UtcNow );
await this . UpdateReleaseDependenciesAndLicence ();
Console . WriteLine ();
await this . BuildPreparedRelease ( offline );
}
private async Task BuildPreparedRelease ( bool offline )
{
2025-04-14 19:29:56 +02:00
// Build once to allow the Rust compiler to read the changed metadata
// and to update all .NET artifacts:
2026-06-11 12:22:09 +02:00
await this . Build ( offline );
2025-04-14 19:29:56 +02:00
// Now, we update the web assets (which may were updated by the first build):
new UpdateWebAssetsCommand (). UpdateWebAssets ();
// Collect the I18N keys from the source code. This step yields a I18N file
// that must be part of the final release:
await new CollectI18NKeysCommand (). CollectI18NKeys ();
// Build the final release, where Rust knows the updated metadata, the .NET
2026-09-13 14:17:25 +02:00
// artifacts are already in place, and .NET knows the updated web assets, etc.
// The gate already ran in the first build; running it a second time on the same
// sources would only add minutes:
await this . Build ( offline , skipVerify : true );
2025-04-14 19:29:56 +02:00
}
2025-04-20 13:33:03 +02:00
2026-08-13 10:43:13 +02:00
[Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")]
public async Task UpdateMetainfo (
[Option("version", ['v'] , Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata" )] string? version = null ,
[Option("date", ['d'] , Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata" )] string? date = null )
{
const int APP_VERSION_INDEX = 0 ;
const int BUILD_TIME_INDEX = 1 ;
if (! Environment . IsWorkingDirectoryValid ())
return ;
Console . WriteLine ( "==============================" );
try
{
var metadataLines = SplitLines ( await File . ReadAllTextAsync ( Environment . GetMetadataPath (), Encoding . UTF8 ));
var appVersion = string . IsNullOrWhiteSpace ( version ) ? metadataLines [ APP_VERSION_INDEX ]. Trim () : version . Trim ();
if (! ExactAppVersionRegex (). IsMatch ( appVersion ))
throw new InvalidOperationException ( $"The version '{appVersion}' is not a valid app version." );
DateTime releaseTime ;
if ( string . IsNullOrWhiteSpace ( date ))
releaseTime = ParseMetadataBuildTime ( metadataLines [ BUILD_TIME_INDEX ]);
else if (! DateTime . TryParseExact ( date . Trim (), "yyyy-MM-dd" , CultureInfo . InvariantCulture , DateTimeStyles . AssumeUniversal | DateTimeStyles . AdjustToUniversal , out releaseTime ))
throw new InvalidOperationException ( $"The release date '{date}' is not a valid date in the yyyy-MM-dd format." );
await WriteMetainfoRelease ( appVersion , releaseTime );
}
catch ( InvalidOperationException exception )
{
Console . WriteLine ( $"- Error: {exception.Message}" );
}
}
2025-04-20 13:33:03 +02:00
[Command("update-versions", Description = "The command will update the package versions in the metadata file")]
public async Task UpdateVersions ()
{
if (! Environment . IsWorkingDirectoryValid ())
return ;
Console . WriteLine ( "==============================" );
Console . WriteLine ( "- Update the main package versions ..." );
await this . UpdateDotnetVersion ();
await this . UpdateRustVersion ();
await this . UpdateMudBlazorVersion ();
await this . UpdateTauriVersion ();
2026-06-02 17:22:59 +02:00
await this . UpdateVectorStoreVersion ();
2025-04-20 13:33:03 +02:00
}
2025-04-14 19:29:56 +02:00
[Command("prepare", Description = "Prepare the metadata for the next release")]
2026-01-11 17:00:33 +01:00
public async Task Prepare (
[Option("action", ['a'] , Description = "The release action: patch, minor, or major" )] PrepareAction action = PrepareAction . NONE ,
[Option("version", ['v'] , Description = "Set a specific version directly, e.g., 26.1.2" )] string? version = null )
2025-04-14 19:29:56 +02:00
{
if (! Environment . IsWorkingDirectoryValid ())
return ;
2026-01-11 17:00:33 +01:00
// Validate parameters: either action or version must be specified, but not both:
if ( action == PrepareAction . NONE && string . IsNullOrWhiteSpace ( version ))
{
Console . WriteLine ( "- Error: You must specify either --action (-a) or --version (-v)." );
return ;
}
if ( action != PrepareAction . NONE && ! string . IsNullOrWhiteSpace ( version ))
{
Console . WriteLine ( "- Error: You cannot specify both --action and --version. Please use only one." );
return ;
}
// If version is specified, use SET action:
if (! string . IsNullOrWhiteSpace ( version ))
action = PrepareAction . SET ;
2025-04-14 19:29:56 +02:00
Console . WriteLine ( "==============================" );
2025-04-21 11:55:15 +02:00
Console . Write ( "- Are you trying to prepare a new release? (y/n) " );
var userAnswer = Console . ReadLine ();
if ( userAnswer ?. ToLowerInvariant () == "y" )
{
Console . WriteLine ( "- Please use the 'release' command instead" );
return ;
}
2026-01-11 17:00:33 +01:00
await this . PerformPrepare ( action , false , version );
2025-04-21 11:55:15 +02:00
}
2026-01-11 17:00:33 +01:00
private async Task PerformPrepare ( PrepareAction action , bool internalCall , string? version = null )
2025-04-21 11:55:15 +02:00
{
if ( internalCall )
Console . WriteLine ( "==============================" );
2026-01-11 17:00:33 +01:00
2025-04-14 19:29:56 +02:00
Console . WriteLine ( "- Prepare the metadata for the next release ..." );
2026-01-11 17:00:33 +01:00
var appVersion = await this . UpdateAppVersion ( action , version );
2025-04-21 11:55:15 +02:00
if (! string . IsNullOrWhiteSpace ( appVersion . VersionText ))
2025-04-14 19:29:56 +02:00
{
2026-08-13 10:43:13 +02:00
// The changelog is the source for the AppStream description. Check it before we write
// any further metadata, so that a missing changelog cannot leave a half-prepared release:
var changelogPath = GetChangelogPath ( appVersion . VersionText );
if (! File . Exists ( changelogPath ))
{
Console . WriteLine ( $"- Error: The changelog file '{Path.GetFileName(changelogPath)}' does not exist." );
return ;
}
2025-04-14 19:29:56 +02:00
var buildNumber = await this . IncreaseBuildNumber ();
var buildTime = await this . UpdateBuildTime ();
2025-04-21 11:55:15 +02:00
await this . UpdateChangelog ( buildNumber , appVersion . VersionText , buildTime );
await this . CreateNextChangelog ( buildNumber , appVersion );
2026-08-13 10:43:13 +02:00
await WriteMetainfoRelease ( appVersion . VersionText , ParseMetadataBuildTime ( buildTime ));
2025-04-14 19:29:56 +02:00
await this . UpdateProjectCommitHash ();
2026-07-19 16:15:09 +02:00
await this . UpdateReleaseDependenciesAndLicence ();
2025-04-14 19:29:56 +02:00
Console . WriteLine ();
}
}
2026-07-19 16:15:09 +02:00
private async Task UpdateReleaseDependenciesAndLicence ()
{
await this . UpdateDotnetVersion ();
await this . UpdateRustVersion ();
await this . UpdateMudBlazorVersion ();
await this . UpdateTauriVersion ();
await this . UpdateVectorStoreVersion ();
await this . UpdateLicenceYear ( Path . GetFullPath ( Path . Combine ( Environment . GetAIStudioDirectory (), ".." , ".." , "LICENSE.md" )));
await this . UpdateLicenceYear ( Path . GetFullPath ( Path . Combine ( Environment . GetAIStudioDirectory (), "Pages" , "Information.razor.cs" )));
}
2025-04-14 19:29:56 +02:00
[Command("build", Description = "Build MindWork AI Studio")]
2026-06-11 12:22:09 +02:00
public async Task Build (
2026-09-13 14:17:25 +02:00
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false ,
[Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false )
2025-04-14 19:29:56 +02:00
{
if (! Environment . IsWorkingDirectoryValid ())
return ;
2026-09-13 14:17:25 +02:00
//
// The gate runs before anything is built, and the build stops when it does not pass. That
// way the same command answers both questions a person has -- is it sound, and does it
// build -- and answers them in that order, because building something the tests reject
// takes minutes to produce an artifact nobody should use.
//
if (! skipVerify && await new VerifyCommand (). Verify () is not 0 )
throw new CommandExitedException ( 1 );
2025-04-14 19:29:56 +02:00
//
// Build the .NET project:
//
var pathApp = Environment . GetAIStudioDirectory ();
2025-05-02 23:09:50 +02:00
var rid = Environment . GetCurrentRid ();
Console . WriteLine ( "==============================" );
await this . UpdateArchitecture ( rid );
2026-06-02 17:22:59 +02:00
await this . UpdateTauriVersion ();
await this . UpdateVectorStoreVersion ();
2025-05-02 23:09:50 +02:00
var pdfiumVersion = await this . ReadPdfiumVersion ();
2026-06-11 12:22:09 +02:00
await Pdfium . InstallAsync ( rid , pdfiumVersion , Environment . IsOfflineBuildRequested ( offline ));
2025-05-02 23:09:50 +02:00
Console . Write ( $"- Start .NET build for {rid.ToUserFriendlyName()} ..." );
await this . ReadCommandOutput ( pathApp , "dotnet" , $"clean --configuration release --runtime {rid.AsMicrosoftRid()}" );
var dotnetBuildOutput = await this . ReadCommandOutput ( pathApp , "dotnet" , $"publish --configuration release --runtime {rid.AsMicrosoftRid()} --disable-build-servers --force" );
var dotnetBuildOutputLines = dotnetBuildOutput . Split ([ global :: System . Environment . NewLine ], StringSplitOptions . RemoveEmptyEntries );
var foundIssue = false ;
foreach ( var buildOutputLine in dotnetBuildOutputLines )
2025-04-14 19:29:56 +02:00
{
2025-05-02 23:09:50 +02:00
if ( buildOutputLine . Contains ( " error " ) || buildOutputLine . Contains ( "#warning" ))
2025-04-14 19:29:56 +02:00
{
2025-05-02 23:09:50 +02:00
if (! foundIssue )
2025-04-14 19:29:56 +02:00
{
2025-05-02 23:09:50 +02:00
foundIssue = true ;
Console . WriteLine ();
Console . WriteLine ( "- Build has issues:" );
2025-04-14 19:29:56 +02:00
}
2025-05-02 23:09:50 +02:00
Console . Write ( " - " );
Console . WriteLine ( buildOutputLine );
2025-04-14 19:29:56 +02:00
}
2025-05-02 23:09:50 +02:00
}
if ( foundIssue )
Console . WriteLine ();
else
{
Console . WriteLine ( " completed successfully." );
}
2025-04-14 19:29:56 +02:00
2025-05-02 23:09:50 +02:00
//
// Prepare the .NET artifact to be used by Tauri as sidecar:
//
var os = Environment . GetOS ();
var tauriSidecarArtifactName = rid switch
{
RID . WIN_X64 => "mindworkAIStudioServer-x86_64-pc-windows-msvc.exe" ,
RID . WIN_ARM64 => "mindworkAIStudioServer-aarch64-pc-windows-msvc.exe" ,
2025-04-14 19:29:56 +02:00
2025-05-02 23:09:50 +02:00
RID . LINUX_X64 => "mindworkAIStudioServer-x86_64-unknown-linux-gnu" ,
RID . LINUX_ARM64 => "mindworkAIStudioServer-aarch64-unknown-linux-gnu" ,
2025-04-14 19:29:56 +02:00
2025-05-02 23:09:50 +02:00
RID . OSX_ARM64 => "mindworkAIStudioServer-aarch64-apple-darwin" ,
RID . OSX_X64 => "mindworkAIStudioServer-x86_64-apple-darwin" ,
2025-04-14 19:29:56 +02:00
2025-05-02 23:09:50 +02:00
_ => string . Empty ,
};
2025-04-14 19:29:56 +02:00
2025-05-02 23:09:50 +02:00
if ( string . IsNullOrWhiteSpace ( tauriSidecarArtifactName ))
{
Console . WriteLine ( $"- Error: Unsupported rid '{rid.AsMicrosoftRid()}'." );
return ;
}
var dotnetArtifactPath = Path . Combine ( pathApp , "bin" , "dist" );
if (! Directory . Exists ( dotnetArtifactPath ))
Directory . CreateDirectory ( dotnetArtifactPath );
var dotnetArtifactFilename = os switch
{
"windows" => "mindworkAIStudio.exe" ,
_ => "mindworkAIStudio" ,
};
var dotnetPublishedPath = Path . Combine ( pathApp , "bin" , "release" , Environment . DOTNET_VERSION , rid . AsMicrosoftRid (), "publish" , dotnetArtifactFilename );
var finalDestination = Path . Combine ( dotnetArtifactPath , tauriSidecarArtifactName );
if ( File . Exists ( dotnetPublishedPath ))
Console . WriteLine ( "- Published .NET artifact found." );
else
{
Console . WriteLine ( $"- Error: Published .NET artifact not found: '{dotnetPublishedPath}'." );
return ;
2025-04-14 19:29:56 +02:00
}
2025-05-02 23:09:50 +02:00
Console . Write ( $"- Move the .NET artifact to the Tauri sidecar destination ..." );
try
{
File . Move ( dotnetPublishedPath , finalDestination , true );
Console . WriteLine ( " done." );
}
catch ( Exception e )
{
Console . WriteLine ( " failed." );
Console . WriteLine ( $" - Error: {e.Message}" );
}
2025-04-14 19:29:56 +02:00
//
// Build the Rust project / runtime:
//
Console . WriteLine ( "- Start building the Rust runtime ..." );
var pathRuntime = Environment . GetRustRuntimeDirectory ();
2026-05-06 18:45:50 +02:00
var rustBuildOutput = await this . ReadCommandOutput ( pathRuntime , "cargo" , "tauri build --no-bundle" , true );
2025-04-14 19:29:56 +02:00
var rustBuildOutputLines = rustBuildOutput . Split ([ global :: System . Environment . NewLine ], StringSplitOptions . RemoveEmptyEntries );
var foundRustIssue = false ;
foreach ( var buildOutputLine in rustBuildOutputLines )
{
if ( buildOutputLine . Contains ( "error" , StringComparison . OrdinalIgnoreCase ) || buildOutputLine . Contains ( "warning" ))
{
if (! foundRustIssue )
{
foundRustIssue = true ;
Console . WriteLine ();
Console . WriteLine ( "- Build has issues:" );
}
Console . Write ( " - " );
Console . WriteLine ( buildOutputLine );
}
}
if ( foundRustIssue )
Console . WriteLine ();
else
{
Console . WriteLine ();
Console . WriteLine ( "- Compilation completed successfully." );
Console . WriteLine ();
}
}
2025-04-21 11:55:15 +02:00
private async Task CreateNextChangelog ( int currentBuildNumber , AppVersion currentAppVersion )
{
Console . Write ( "- Create the next changelog ..." );
var pathChangelogs = Path . Combine ( Environment . GetAIStudioDirectory (), "wwwroot" , "changelog" );
var nextBuildNumber = currentBuildNumber + 1 ;
//
// Regarding the next build time: We assume that the next release will take place in one week from now.
// Thus, we check how many days this month has left. In the end, we want to predict the year and month
// for the next build. Day, hour, minute and second are all set to x.
//
var nextBuildMonth = ( DateTime . Today + TimeSpan . FromDays ( 7 )). Month ;
var nextBuildYear = ( DateTime . Today + TimeSpan . FromDays ( 7 )). Year ;
var nextBuildTimeString = $"{nextBuildYear}-{nextBuildMonth:00}-xx xx:xx UTC" ;
2026-01-11 17:00:33 +01:00
//
// We assume that most of the time, there will be patch releases:
//
// skipping the first 2 digits for major version
var nextBuildYearShort = nextBuildYear - 2000 ;
var nextMajor = nextBuildYearShort ;
var nextMinor = nextBuildMonth ;
var nextPatch = currentAppVersion . Major != nextBuildYearShort || currentAppVersion . Minor != nextBuildMonth ? 1 : currentAppVersion . Patch + 1 ;
var nextAppVersion = $"{nextMajor}.{nextMinor}.{nextPatch}" ;
var nextChangelogFilename = $"v{nextAppVersion}.md" ;
var nextChangelogFilePath = Path . Combine ( pathChangelogs , nextChangelogFilename );
2025-04-21 11:55:15 +02:00
var changelogHeader = $"""
# v{nextAppVersion}, build {nextBuildNumber} ({nextBuildTimeString})
""" ;
if (! File . Exists ( nextChangelogFilePath ))
{
await File . WriteAllTextAsync ( nextChangelogFilePath , changelogHeader , Environment . UTF8_NO_BOM );
Console . WriteLine ( $" done. Changelog '{nextChangelogFilename}' created." );
}
else
{
Console . WriteLine ( " failed." );
Console . WriteLine ( "- Error: The changelog file already exists." );
}
}
2025-04-14 19:29:56 +02:00
private async Task UpdateChangelog ( int buildNumber , string appVersion , string buildTime )
{
2025-04-21 11:55:15 +02:00
Console . Write ( "- Updating the in-app changelog list ..." );
2025-04-14 19:29:56 +02:00
var pathChangelogs = Path . Combine ( Environment . GetAIStudioDirectory (), "wwwroot" , "changelog" );
var expectedLogFilename = $"v{appVersion}.md" ;
var expectedLogFilePath = Path . Combine ( pathChangelogs , expectedLogFilename );
if (! File . Exists ( expectedLogFilePath ))
{
2025-04-21 11:55:15 +02:00
Console . WriteLine ( " failed." );
2025-04-14 19:29:56 +02:00
Console . WriteLine ( $"- Error: The changelog file '{expectedLogFilename}' does not exist." );
return ;
}
// Right now, the build time is formatted as "yyyy-MM-dd HH:mm:ss UTC", but must remove the seconds:
buildTime = buildTime [..^ 7 ] + " UTC" ;
const string CODE_START =
"""
LOGS =
[
""";
var changelogCodePath = Path.Join(Environment.GetAIStudioDirectory(), "Components", "Changelog.Logs.cs");
var changelogCode = await File.ReadAllTextAsync(changelogCodePath, Encoding.UTF8);
var updatedCode =
$"""
{CODE_START}
new ({buildNumber}, "v{appVersion}, build {buildNumber} ({buildTime})", "{expectedLogFilename}"),
""";
changelogCode = changelogCode.Replace(CODE_START, updatedCode);
await File.WriteAllTextAsync(changelogCodePath, changelogCode, Environment.UTF8_NO_BOM);
2025-04-21 11:55:15 +02:00
Console.WriteLine(" done.");
2025-04-14 19:29:56 +02:00
}
2026-07-19 16:15:09 +02:00
private async Task<RebuildReleaseState> ValidateRebuildReleaseState()
{
const int APP_VERSION_INDEX = 0;
const int BUILD_TIME_INDEX = 1;
const int BUILD_NUMBER_INDEX = 2;
var metadataPath = Environment.GetMetadataPath();
var metadataContent = await File.ReadAllTextAsync(metadataPath, Encoding.UTF8);
var metadataLines = SplitLines(metadataContent);
if (metadataLines.Length <= 8)
throw new InvalidOperationException("The metadata file does not contain all required release fields.");
var appVersion = metadataLines[APP_VERSION_INDEX] . Trim ();
if (! ExactAppVersionRegex (). IsMatch ( appVersion ))
throw new InvalidOperationException ( $"The metadata version '{appVersion}' is not a valid app version." );
2026-08-13 10:43:13 +02:00
var buildTime = ParseMetadataBuildTime ( metadataLines [ BUILD_TIME_INDEX ]);
2026-07-19 16:15:09 +02:00
if (! int . TryParse ( metadataLines [ BUILD_NUMBER_INDEX ]. Trim (), out var buildNumber ))
throw new InvalidOperationException ( $"The metadata build number '{metadataLines[BUILD_NUMBER_INDEX]}' is not a number." );
var changelogDirectory = Path . Combine ( Environment . GetAIStudioDirectory (), "wwwroot" , "changelog" );
var changelogFilename = $"v{appVersion}.md" ;
var changelogPath = Path . Combine ( changelogDirectory , changelogFilename );
if (! File . Exists ( changelogPath ))
throw new InvalidOperationException ( $"The current changelog file '{changelogFilename}' does not exist." );
var changelogContent = await File . ReadAllTextAsync ( changelogPath , Encoding . UTF8 );
var changelogHeader = FormatChangelogHeader ( appVersion , buildNumber , buildTime );
if ( GetFirstLine ( changelogContent ) != changelogHeader )
throw new InvalidOperationException ( $"The current changelog header does not match v{appVersion}, build {buildNumber}, and the metadata build time." );
var changelogCodePath = Path . Combine ( Environment . GetAIStudioDirectory (), "Components" , "Changelog.Logs.cs" );
var changelogCode = await File . ReadAllTextAsync ( changelogCodePath , Encoding . UTF8 );
var changelogLogEntry = FormatChangelogLogEntry ( appVersion , buildNumber , buildTime , changelogFilename );
if ( CountOccurrences ( changelogCode , changelogLogEntry ) != 1 )
throw new InvalidOperationException ( $"The in-app changelog list must contain exactly one matching entry for v{appVersion}, build {buildNumber}." );
var nextChangelogBuildNumber = buildNumber + 1 ;
var nextChangelogPattern = new Regex ( $"^# v(?<version>[0-9]+\\.[0-9]+\\.[0-9]+), build {nextChangelogBuildNumber} \\(20[0-9]{{2}}-[0-9]{{2}}-xx xx:xx UTC\\)$" );
var nextChangelogCandidates = new List <( string Path , string Content , string Header , string Version )>();
foreach ( var candidatePath in Directory . GetFiles ( changelogDirectory , "v*.md" ))
{
if ( candidatePath == changelogPath )
continue ;
var candidateContent = await File . ReadAllTextAsync ( candidatePath , Encoding . UTF8 );
var candidateHeader = GetFirstLine ( candidateContent );
var candidateMatch = nextChangelogPattern . Match ( candidateHeader );
if ( candidateMatch . Success )
nextChangelogCandidates . Add (( candidatePath , candidateContent , candidateHeader , candidateMatch . Groups [ "version" ]. Value ));
}
if ( nextChangelogCandidates . Count != 1 )
throw new InvalidOperationException ( $"Expected exactly one future changelog reserving build {nextChangelogBuildNumber}, but found {nextChangelogCandidates.Count}." );
var nextChangelog = nextChangelogCandidates [ 0 ];
2026-08-13 10:43:13 +02:00
// The release entry itself is written by ApplyRebuildReleaseState, which adds it when it is
// missing and moves it to the top otherwise. Here, we only ensure that there is a file to write to:
var metainfoPath = GetMetainfoPath ();
2026-07-19 16:15:09 +02:00
if (! File . Exists ( metainfoPath ))
throw new InvalidOperationException ( "The AppStream metainfo file does not exist." );
2026-08-13 10:43:13 +02:00
if (! ReleasesStartRegex (). IsMatch ( await File . ReadAllTextAsync ( metainfoPath , Encoding . UTF8 )))
throw new InvalidOperationException ( "The AppStream metainfo does not contain a <releases> element." );
2026-07-19 16:15:09 +02:00
var headCommitHash = ( await this . ReadCommandOutput ( Environment . GetAIStudioDirectory (), "git" , "rev-parse HEAD" )). Trim ();
if (! GitCommitHashRegex (). IsMatch ( headCommitHash ))
throw new InvalidOperationException ( "The current Git commit hash could not be determined." );
return new (
metadataPath ,
metadataContent ,
metadataLines ,
appVersion ,
buildNumber ,
changelogPath ,
changelogContent ,
changelogHeader ,
changelogCodePath ,
changelogCode ,
changelogLogEntry ,
nextChangelog . Path ,
nextChangelog . Content ,
nextChangelog . Header ,
nextChangelog . Version ,
headCommitHash [.. 11 ]);
}
private async Task ApplyRebuildReleaseState ( RebuildReleaseState releaseState , DateTime buildTime )
{
const int BUILD_TIME_INDEX = 1 ;
const int BUILD_NUMBER_INDEX = 2 ;
const int COMMIT_HASH_INDEX = 8 ;
buildTime = buildTime . ToUniversalTime ();
var buildNumber = releaseState . BuildNumber + 1 ;
var buildTimeString = buildTime . ToString ( "yyyy-MM-dd HH:mm:ss" , CultureInfo . InvariantCulture ) + " UTC" ;
Console . WriteLine ( $"- Updating build number from '{releaseState.BuildNumber}' to '{buildNumber}'." );
Console . WriteLine ( $"- Updating build time to '{buildTimeString}'." );
releaseState . MetadataLines [ BUILD_TIME_INDEX ] = buildTimeString ;
releaseState . MetadataLines [ BUILD_NUMBER_INDEX ] = buildNumber . ToString ( CultureInfo . InvariantCulture );
releaseState . MetadataLines [ COMMIT_HASH_INDEX ] = $"{releaseState.HeadCommitHash}, release" ;
var updatedMetadata = JoinLines ( releaseState . MetadataContent , releaseState . MetadataLines );
await File . WriteAllTextAsync ( releaseState . MetadataPath , updatedMetadata , Environment . UTF8_NO_BOM );
var updatedChangelogHeader = FormatChangelogHeader ( releaseState . AppVersion , buildNumber , buildTime );
var updatedChangelog = ReplaceExactlyOnce ( releaseState . ChangelogContent , releaseState . ChangelogHeader , updatedChangelogHeader );
await File . WriteAllTextAsync ( releaseState . ChangelogPath , updatedChangelog , Environment . UTF8_NO_BOM );
Console . WriteLine ( $"- Updated the header of '{Path.GetFileName(releaseState.ChangelogPath)}'." );
var changelogFilename = Path . GetFileName ( releaseState . ChangelogPath );
var updatedChangelogLogEntry = FormatChangelogLogEntry ( releaseState . AppVersion , buildNumber , buildTime , changelogFilename );
var updatedChangelogCode = ReplaceExactlyOnce ( releaseState . ChangelogCode , releaseState . ChangelogLogEntry , updatedChangelogLogEntry );
await File . WriteAllTextAsync ( releaseState . ChangelogCodePath , updatedChangelogCode , Environment . UTF8_NO_BOM );
Console . WriteLine ( "- Updated the existing in-app changelog entry." );
var updatedNextChangelogHeader = $"# v{releaseState.NextChangelogVersion}, build {buildNumber + 1} ({GetPlaceholderBuildTime(releaseState.NextChangelogHeader)})" ;
var updatedNextChangelog = ReplaceExactlyOnce ( releaseState . NextChangelogContent , releaseState . NextChangelogHeader , updatedNextChangelogHeader );
await File . WriteAllTextAsync ( releaseState . NextChangelogPath , updatedNextChangelog , Environment . UTF8_NO_BOM );
Console . WriteLine ( $"- Reserved build {buildNumber + 1} for '{Path.GetFileName(releaseState.NextChangelogPath)}'." );
2026-08-13 10:43:13 +02:00
await WriteMetainfoRelease ( releaseState . AppVersion , buildTime );
}
private static string GetMetainfoPath () => Path . Combine ( Environment . GetRustRuntimeDirectory (), "packaging" , "linux" , "org.mindworkai.AIStudio.metainfo.xml" );
private static string GetChangelogPath ( string appVersion ) => Path . Combine ( Environment . GetAIStudioDirectory (), "wwwroot" , "changelog" , $"v{appVersion}.md" );
/// <summary>
/// Writes the AppStream release entry for the given version, using the changelog of that version as its description.
/// </summary>
/// <remarks>
/// The entry always becomes the first release, and any earlier entry of the same version is replaced. This is what
/// the Flatpak pipeline validates through 'update-metainfo.py --check' before it syncs a release. The release date
/// is derived from the build time, because the pipeline reads it from the second line of the metadata file.
/// </remarks>
private static async Task WriteMetainfoRelease ( string appVersion , DateTime releaseTime )
{
const string RELEASE_INDENT = " " ;
var metainfoPath = GetMetainfoPath ();
if (! File . Exists ( metainfoPath ))
throw new InvalidOperationException ( "The AppStream metainfo file does not exist." );
var metainfo = await File . ReadAllTextAsync ( metainfoPath , Encoding . UTF8 );
if (! ReleasesStartRegex (). IsMatch ( metainfo ))
throw new InvalidOperationException ( "The AppStream metainfo does not contain a <releases> element." );
var changelogEntries = await ReadChangelogEntries ( appVersion );
// Drop any earlier entry of this version, so that the version stays unique and moves to the top.
// We remove from the back, so that the index of the remaining matches stays valid:
2026-09-13 14:17:25 +02:00
foreach ( var previousRelease in ReleaseBlockRegex (). Matches ( metainfo ). Where ( match => ReleaseTagHasVersion ( match . Value , appVersion )). Reverse ())
2026-08-13 10:43:13 +02:00
metainfo = metainfo . Remove ( previousRelease . Index , previousRelease . Length );
var lineEnding = metainfo . Contains ( "\r\n" , StringComparison . Ordinal ) ? "\r\n" : "\n" ;
var releaseDate = releaseTime . ToUniversalTime (). ToString ( "yyyy-MM-dd" , CultureInfo . InvariantCulture );
var releaseBlock = new StringBuilder ();
releaseBlock . Append ( $"{RELEASE_INDENT}<release type=\" stable \ " version=\"{appVersion}\" date=\"{releaseDate}\">{lineEnding}" );
releaseBlock . Append ( $"{RELEASE_INDENT} <description>{lineEnding}" );
releaseBlock . Append ( $"{RELEASE_INDENT} <ul>{lineEnding}" );
foreach ( var changelogEntry in changelogEntries )
releaseBlock . Append ( $"{RELEASE_INDENT} <li>{changelogEntry}</li>{lineEnding}" );
releaseBlock . Append ( $"{RELEASE_INDENT} </ul>{lineEnding}" );
releaseBlock . Append ( $"{RELEASE_INDENT} </description>{lineEnding}" );
releaseBlock . Append ( $"{RELEASE_INDENT}</release>{lineEnding}" );
var releasesStart = ReleasesStartRegex (). Match ( metainfo );
var insertionPoint = releasesStart . Index + releasesStart . Length ;
if ( metainfo . AsSpan ( insertionPoint ). StartsWith ( lineEnding ))
insertionPoint += lineEnding . Length ;
else
releaseBlock . Insert ( 0 , lineEnding );
metainfo = metainfo . Insert ( insertionPoint , releaseBlock . ToString ());
await File . WriteAllTextAsync ( metainfoPath , metainfo , Environment . UTF8_NO_BOM );
Console . WriteLine ( $"- Updated the AppStream metainfo for v{appVersion}, released on {releaseDate}, with {changelogEntries.Count} changelog entries." );
}
private static async Task < IReadOnlyList < string >> ReadChangelogEntries ( string appVersion )
{
var changelogPath = GetChangelogPath ( appVersion );
if (! File . Exists ( changelogPath ))
throw new InvalidOperationException ( $"The changelog file '{Path.GetFileName(changelogPath)}' does not exist." );
// The first line is the changelog header, every other non-empty line must be a changelog entry:
var changelogLines = SplitLines ( await File . ReadAllTextAsync ( changelogPath , Encoding . UTF8 ));
var changelogEntries = new List < string >();
foreach ( var changelogLine in changelogLines . Skip ( 1 ))
{
var changelogEntry = changelogLine . Trim ();
if ( changelogEntry . Length is 0 )
continue ;
if (! changelogEntry . StartsWith ( "- " , StringComparison . Ordinal ))
throw new InvalidOperationException ( $"The changelog '{Path.GetFileName(changelogPath)}' contains a line which is no changelog entry: '{changelogEntry}'." );
changelogEntries . Add ( ConvertChangelogEntryToAppStream ( changelogEntry [ 2. .]. Trim ()));
}
if ( changelogEntries . Count is 0 )
throw new InvalidOperationException ( $"The changelog '{Path.GetFileName(changelogPath)}' does not contain any entry." );
return changelogEntries ;
}
private static string ConvertChangelogEntryToAppStream ( string changelogEntry )
{
var escapedEntry = changelogEntry
. Replace ( "&" , "&" , StringComparison . Ordinal )
. Replace ( "<" , "<" , StringComparison . Ordinal )
. Replace ( ">" , ">" , StringComparison . Ordinal );
// Markdown code spans become AppStream code elements. Every second segment is inside a code span,
// which requires an even number of markers and therefore an odd number of segments:
var codeSpans = escapedEntry . Split ( '`' );
if ( codeSpans . Length % 2 is 0 )
throw new InvalidOperationException ( $"The changelog entry contains an unbalanced code marker: '{changelogEntry}'." );
var convertedEntry = new StringBuilder ();
for ( var index = 0 ; index < codeSpans . Length ; index ++)
convertedEntry . Append ( index % 2 is 0 ? codeSpans [ index ] : $"<code>{codeSpans[index]}</code>" );
return convertedEntry . ToString ();
}
private static DateTime ParseMetadataBuildTime ( string buildTime )
{
if (! DateTime . TryParseExact ( buildTime . Trim (), "yyyy-MM-dd HH:mm:ss 'UTC'" , CultureInfo . InvariantCulture , DateTimeStyles . AssumeUniversal | DateTimeStyles . AdjustToUniversal , out var parsedBuildTime ))
throw new InvalidOperationException ( $"The metadata build time '{buildTime}' is not a valid UTC build time." );
return parsedBuildTime ;
2026-07-19 16:15:09 +02:00
}
private static string FormatChangelogHeader ( string appVersion , int buildNumber , DateTime buildTime )
{
return $"# v{appVersion}, build {buildNumber} ({buildTime.ToUniversalTime():yyyy-MM-dd HH:mm} UTC)" ;
}
private static string FormatChangelogLogEntry ( string appVersion , int buildNumber , DateTime buildTime , string changelogFilename )
{
return $"new ({buildNumber}, \" v { appVersion }, build { buildNumber } ({ buildTime . ToUniversalTime (): yyyy - MM - dd HH : mm } UTC ) \ ", \"{changelogFilename}\")," ;
}
private static string GetFirstLine ( string content )
{
var lineEnd = content . IndexOf ( '\n' );
return ( lineEnd < 0 ? content : content [.. lineEnd ]). TrimEnd ( '\r' );
}
private static string GetPlaceholderBuildTime ( string changelogHeader )
{
var start = changelogHeader . LastIndexOf ( '(' ) + 1 ;
return changelogHeader [ start ..^ 1 ];
}
private static bool ReleaseTagHasVersion ( string releaseTag , string appVersion )
{
return Regex . IsMatch ( releaseTag , $"\\bversion=\" { Regex . Escape ( appVersion )} \ "" );
}
private static int CountOccurrences ( string content , string value )
{
var count = 0 ;
var index = 0 ;
while (( index = content . IndexOf ( value , index , StringComparison . Ordinal )) >= 0 )
{
count ++;
index += value . Length ;
}
return count ;
}
private static string ReplaceExactlyOnce ( string content , string oldValue , string newValue )
{
if ( CountOccurrences ( content , oldValue ) != 1 )
throw new InvalidOperationException ( "A previously validated release value is no longer unique." );
return content . Replace ( oldValue , newValue , StringComparison . Ordinal );
}
private static string [] SplitLines ( string content )
{
return content . Replace ( "\r\n" , "\n" , StringComparison . Ordinal ). Split ( '\n' );
}
private static string JoinLines ( string originalContent , string [] lines )
{
var lineEnding = originalContent . Contains ( "\r\n" , StringComparison . Ordinal ) ? "\r\n" : "\n" ;
return string . Join ( lineEnding , lines );
}
2025-05-02 23:09:50 +02:00
private async Task < string > ReadPdfiumVersion ()
{
const int PDFIUM_VERSION_INDEX = 10 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentPdfiumVersion = lines [ PDFIUM_VERSION_INDEX ]. Trim ();
var shortVersion = currentPdfiumVersion . Split ( '.' )[ 2 ];
return shortVersion ;
}
2025-04-14 19:29:56 +02:00
2025-04-15 08:08:39 +02:00
private async Task UpdateArchitecture ( RID rid )
{
const int ARCHITECTURE_INDEX = 9 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
2025-05-02 23:09:50 +02:00
Console . Write ( $"- Updating architecture to {rid.ToUserFriendlyName()} ..." );
2025-04-15 08:08:39 +02:00
lines [ ARCHITECTURE_INDEX ] = rid . AsMicrosoftRid ();
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
Console . WriteLine ( " done." );
}
2025-11-17 13:28:00 +01:00
[Command("update-project-hash", Description = "Update the project commit hash")]
public async Task UpdateProjectCommitHash ()
2025-04-14 19:29:56 +02:00
{
const int COMMIT_HASH_INDEX = 8 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentCommitHash = lines [ COMMIT_HASH_INDEX ]. Trim ();
var headCommitHash = await this . ReadCommandOutput ( Environment . GetAIStudioDirectory (), "git" , "rev-parse HEAD" );
var first10Chars = headCommitHash [.. 11 ];
var updatedCommitHash = $"{first10Chars}, release" ;
Console . WriteLine ( $"- Updating commit hash from '{currentCommitHash}' to '{updatedCommitHash}'." );
lines [ COMMIT_HASH_INDEX ] = updatedCommitHash ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
2026-01-11 17:00:33 +01:00
private async Task < AppVersion > UpdateAppVersion ( PrepareAction action , string? version = null )
2025-04-14 19:29:56 +02:00
{
const int APP_VERSION_INDEX = 0 ;
2026-01-11 17:00:33 +01:00
2025-04-14 19:29:56 +02:00
if ( action == PrepareAction . NONE )
{
Console . WriteLine ( "- No action specified. Skipping app version update." );
2025-04-21 11:55:15 +02:00
return new ( string . Empty , 0 , 0 , 0 );
2025-04-14 19:29:56 +02:00
}
2026-01-11 17:00:33 +01:00
2025-04-14 19:29:56 +02:00
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentAppVersionLine = lines [ APP_VERSION_INDEX ]. Trim ();
2026-01-11 17:00:33 +01:00
int newMajor , newMinor , newPatch ;
if ( action == PrepareAction . SET && ! string . IsNullOrWhiteSpace ( version ))
2025-04-14 19:29:56 +02:00
{
2026-01-11 17:00:33 +01:00
// Parse the provided version string:
var versionMatch = AppVersionRegex (). Match ( version );
if (! versionMatch . Success )
{
Console . WriteLine ( $"- Error: Invalid version format '{version}'. Expected format: major.minor.patch (e.g., 26.1.2)" );
return new ( string . Empty , 0 , 0 , 0 );
}
newMajor = int . Parse ( versionMatch . Groups [ "major" ]. Value );
newMinor = int . Parse ( versionMatch . Groups [ "minor" ]. Value );
newPatch = int . Parse ( versionMatch . Groups [ "patch" ]. Value );
2025-04-14 19:29:56 +02:00
}
2026-01-11 17:00:33 +01:00
else
{
// Parse current version and increment based on action:
var currentAppVersion = AppVersionRegex (). Match ( currentAppVersionLine );
newPatch = int . Parse ( currentAppVersion . Groups [ "patch" ]. Value );
newMinor = int . Parse ( currentAppVersion . Groups [ "minor" ]. Value );
newMajor = int . Parse ( currentAppVersion . Groups [ "major" ]. Value );
switch ( action )
{
case PrepareAction . BUILD :
newPatch ++;
break ;
case PrepareAction . MONTH :
newPatch = 1 ;
newMinor ++;
break ;
case PrepareAction . YEAR :
newPatch = 1 ;
newMinor = 1 ;
newMajor ++;
break ;
}
}
var updatedAppVersion = $"{newMajor}.{newMinor}.{newPatch}" ;
2025-04-14 19:29:56 +02:00
Console . WriteLine ( $"- Updating app version from '{currentAppVersionLine}' to '{updatedAppVersion}'." );
2026-01-11 17:00:33 +01:00
2025-04-14 19:29:56 +02:00
lines [ APP_VERSION_INDEX ] = updatedAppVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
2026-01-11 17:00:33 +01:00
return new ( updatedAppVersion , newMajor , newMinor , newPatch );
2025-04-14 19:29:56 +02:00
}
private async Task UpdateLicenceYear ( string licenceFilePath )
{
var currentYear = DateTime . UtcNow . Year . ToString ();
var lines = await File . ReadAllLinesAsync ( licenceFilePath , Encoding . UTF8 );
var found = false ;
var copyrightYear = string . Empty ;
var updatedLines = new List < string >( lines . Length );
foreach ( var line in lines )
{
var match = FindCopyrightRegex (). Match ( line );
if ( match . Success )
{
copyrightYear = match . Groups [ "year" ]. Value ;
if (! found && copyrightYear != currentYear )
Console . WriteLine ( $"- Updating the licence's year in '{Path.GetFileName(licenceFilePath)}' from '{copyrightYear}' to '{currentYear}'." );
updatedLines . Add ( ReplaceCopyrightYearRegex (). Replace ( line , currentYear ));
found = true ;
}
else
updatedLines . Add ( line );
}
await File . WriteAllLinesAsync ( licenceFilePath , updatedLines , Environment . UTF8_NO_BOM );
if (! found )
Console . WriteLine ( $"- Error: No copyright year found in '{Path.GetFileName(licenceFilePath)}'." );
else if ( copyrightYear == currentYear )
Console . WriteLine ( $"- The copyright year in '{Path.GetFileName(licenceFilePath)}' is already up to date." );
}
private async Task UpdateTauriVersion ()
{
const int TAURI_VERSION_INDEX = 7 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentTauriVersion = lines [ TAURI_VERSION_INDEX ]. Trim ();
var matches = await this . DetermineVersion ( "Tauri" , Environment . GetRustRuntimeDirectory (), TauriVersionRegex (), "cargo" , "tree --depth 1" );
if ( matches . Count == 0 )
return ;
var updatedTauriVersion = matches [ 0 ]. Groups [ "version" ]. Value ;
if ( currentTauriVersion == updatedTauriVersion )
{
Console . WriteLine ( "- The Tauri version is already up to date." );
return ;
}
Console . WriteLine ( $"- Updated Tauri version from {currentTauriVersion} to {updatedTauriVersion}." );
lines [ TAURI_VERSION_INDEX ] = updatedTauriVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
2026-06-02 17:22:59 +02:00
private async Task UpdateVectorStoreVersion ()
{
const int VECTOR_STORE_VERSION_INDEX = 11 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentVectorStoreVersion = lines [ VECTOR_STORE_VERSION_INDEX ]. Trim ();
var matches = await this . DetermineVersion ( "Qdrant Edge" , Environment . GetRustRuntimeDirectory (), QdrantEdgeVersionRegex (), "cargo" , "tree --depth 1" );
if ( matches . Count == 0 )
return ;
var updatedVectorStoreVersion = matches [ 0 ]. Groups [ "version" ]. Value ;
if ( currentVectorStoreVersion == updatedVectorStoreVersion )
{
Console . WriteLine ( "- The vector store version is already up to date." );
return ;
}
Console . WriteLine ( $"- Updated vector store version from {currentVectorStoreVersion} to {updatedVectorStoreVersion}." );
lines [ VECTOR_STORE_VERSION_INDEX ] = updatedVectorStoreVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
2025-04-14 19:29:56 +02:00
private async Task UpdateMudBlazorVersion ()
{
const int MUD_BLAZOR_VERSION_INDEX = 6 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentMudBlazorVersion = lines [ MUD_BLAZOR_VERSION_INDEX ]. Trim ();
var matches = await this . DetermineVersion ( "MudBlazor" , Environment . GetAIStudioDirectory (), MudBlazorVersionRegex (), "dotnet" , "list package" );
if ( matches . Count == 0 )
return ;
var updatedMudBlazorVersion = matches [ 0 ]. Groups [ "version" ]. Value ;
if ( currentMudBlazorVersion == updatedMudBlazorVersion )
{
Console . WriteLine ( "- The MudBlazor version is already up to date." );
return ;
}
Console . WriteLine ( $"- Updated MudBlazor version from {currentMudBlazorVersion} to {updatedMudBlazorVersion}." );
lines [ MUD_BLAZOR_VERSION_INDEX ] = updatedMudBlazorVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
private async Task UpdateRustVersion ()
{
const int RUST_VERSION_INDEX = 5 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentRustVersion = lines [ RUST_VERSION_INDEX ]. Trim ();
var matches = await this . DetermineVersion ( "Rust" , Environment . GetRustRuntimeDirectory (), RustVersionRegex (), "rustc" , "-Vv" );
if ( matches . Count == 0 )
return ;
var updatedRustVersion = matches [ 0 ]. Groups [ "version" ]. Value + " (commit " + matches [ 0 ]. Groups [ "commit" ]. Value + ")" ;
if ( currentRustVersion == updatedRustVersion )
{
Console . WriteLine ( "- Rust version is already up to date." );
return ;
}
Console . WriteLine ( $"- Updated Rust version from {currentRustVersion} to {updatedRustVersion}." );
lines [ RUST_VERSION_INDEX ] = updatedRustVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
private async Task UpdateDotnetVersion ()
{
const int DOTNET_VERSION_INDEX = 4 ;
const int DOTNET_SDK_VERSION_INDEX = 3 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var currentDotnetVersion = lines [ DOTNET_VERSION_INDEX ]. Trim ();
var currentDotnetSdkVersion = lines [ DOTNET_SDK_VERSION_INDEX ]. Trim ();
var matches = await this . DetermineVersion ( ".NET" , Environment . GetAIStudioDirectory (), DotnetVersionRegex (), "dotnet" , "--info" );
if ( matches . Count == 0 )
return ;
var updatedDotnetVersion = matches [ 0 ]. Groups [ "hostVersion" ]. Value + " (commit " + matches [ 0 ]. Groups [ "hostCommit" ]. Value + ")" ;
var updatedDotnetSdkVersion = matches [ 0 ]. Groups [ "sdkVersion" ]. Value + " (commit " + matches [ 0 ]. Groups [ "sdkCommit" ]. Value + ")" ;
if ( currentDotnetVersion == updatedDotnetVersion && currentDotnetSdkVersion == updatedDotnetSdkVersion )
{
Console . WriteLine ( "- .NET version is already up to date." );
return ;
}
Console . WriteLine ( $"- Updated .NET SDK version from {currentDotnetSdkVersion} to {updatedDotnetSdkVersion}." );
Console . WriteLine ( $"- Updated .NET version from {currentDotnetVersion} to {updatedDotnetVersion}." );
lines [ DOTNET_VERSION_INDEX ] = updatedDotnetVersion ;
lines [ DOTNET_SDK_VERSION_INDEX ] = updatedDotnetSdkVersion ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
}
private async Task < IList < Match >> DetermineVersion ( string name , string workingDirectory , Regex regex , string program , string command )
{
var processInfo = new ProcessStartInfo
{
WorkingDirectory = workingDirectory ,
FileName = program ,
Arguments = command ,
RedirectStandardOutput = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process ();
process . StartInfo = processInfo ;
process . Start ();
var output = await process . StandardOutput . ReadToEndAsync ();
await process . WaitForExitAsync ();
var matches = regex . Matches ( output );
if ( matches . Count == 0 )
{
Console . WriteLine ( $"- Error: Was not able to determine the {name} version." );
return [];
}
return matches ;
}
private async Task < string > ReadCommandOutput ( string workingDirectory , string program , string command , bool showLiveOutput = false )
{
var processInfo = new ProcessStartInfo
{
WorkingDirectory = workingDirectory ,
FileName = program ,
Arguments = command ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true ,
};
var sb = new StringBuilder ();
using var process = new Process ();
process . StartInfo = processInfo ;
process . Start ();
process . BeginOutputReadLine ();
process . BeginErrorReadLine ();
process . OutputDataReceived += ( _ , args ) =>
{
if (! string . IsNullOrWhiteSpace ( args . Data ))
{
if ( showLiveOutput )
Console . WriteLine ( args . Data );
sb . AppendLine ( args . Data );
}
};
process . ErrorDataReceived += ( _ , args ) =>
{
if (! string . IsNullOrWhiteSpace ( args . Data ))
{
if ( showLiveOutput )
Console . WriteLine ( args . Data );
sb . AppendLine ( args . Data );
}
};
await process . WaitForExitAsync ();
return sb . ToString ();
}
private async Task < int > IncreaseBuildNumber ()
{
const int BUILD_NUMBER_INDEX = 2 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var buildNumber = int . Parse ( lines [ BUILD_NUMBER_INDEX ]) + 1 ;
Console . WriteLine ( $"- Updating build number from '{lines[BUILD_NUMBER_INDEX]}' to '{buildNumber}'." );
lines [ BUILD_NUMBER_INDEX ] = buildNumber . ToString ();
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
return buildNumber ;
}
private async Task < string > UpdateBuildTime ()
{
const int BUILD_TIME_INDEX = 1 ;
var pathMetadata = Environment . GetMetadataPath ();
var lines = await File . ReadAllLinesAsync ( pathMetadata , Encoding . UTF8 );
var buildTime = DateTime . UtcNow . ToString ( "yyyy-MM-dd HH:mm:ss" ) + " UTC" ;
Console . WriteLine ( $"- Updating build time from '{lines[BUILD_TIME_INDEX]}' to '{buildTime}'." );
lines [ BUILD_TIME_INDEX ] = buildTime ;
await File . WriteAllLinesAsync ( pathMetadata , lines , Environment . UTF8_NO_BOM );
return buildTime ;
}
2026-07-19 16:15:09 +02:00
private sealed record RebuildReleaseState (
string MetadataPath ,
string MetadataContent ,
string [] MetadataLines ,
string AppVersion ,
int BuildNumber ,
string ChangelogPath ,
string ChangelogContent ,
string ChangelogHeader ,
string ChangelogCodePath ,
string ChangelogCode ,
string ChangelogLogEntry ,
string NextChangelogPath ,
string NextChangelogContent ,
string NextChangelogHeader ,
string NextChangelogVersion ,
string HeadCommitHash );
2025-04-14 19:29:56 +02:00
[GeneratedRegex("""(?ms).?(NET\s+SDK|SDK\s+\.NET)\s*:\s+Version:\s+(?<sdkVersion>[0-9.] +).+ Commit : \ s +(?< sdkCommit >[ a - zA - Z0 - 9 ]+).+ Host : \ s + Version : \ s +(?< hostVersion >[ 0 - 9. ]+).+ Commit : \ s +(?< hostCommit >[ a - zA - Z0 - 9 ]+) """)]
private static partial Regex DotnetVersionRegex ();
[GeneratedRegex("""rustc (?<version>[0-9.] +)(?:- nightly )? \ ((?< commit >[ a - zA - Z0 - 9 ]+) """)]
private static partial Regex RustVersionRegex ();
[GeneratedRegex("""MudBlazor\s+(?<version>[0-9.] +) """)]
private static partial Regex MudBlazorVersionRegex ();
2026-06-02 17:22:59 +02:00
[GeneratedRegex("""qdrant-edge\s+v(?<version>[0-9.] +) """)]
private static partial Regex QdrantEdgeVersionRegex ();
2025-04-14 19:29:56 +02:00
[GeneratedRegex("""tauri\s+v(?<version>[0-9.] +) """)]
private static partial Regex TauriVersionRegex ();
[GeneratedRegex("""^\s*Copyright\s+(?<year>[0-9] { 4 }) """)]
private static partial Regex FindCopyrightRegex ();
2026-07-19 16:15:09 +02:00
[GeneratedRegex("([0-9] { 4 }) ")]
2025-04-14 19:29:56 +02:00
private static partial Regex ReplaceCopyrightYearRegex ();
[GeneratedRegex("""(?<major>[0-9] +) \ .(?< minor >[ 0 - 9 ]+) \ .(?< patch >[ 0 - 9 ]+) """)]
private static partial Regex AppVersionRegex ();
2026-07-19 16:15:09 +02:00
[GeneratedRegex("""^[0-9] + \ .[ 0 - 9 ]+ \ .[ 0 - 9 ]+ $""")]
private static partial Regex ExactAppVersionRegex();
2026-08-13 10:43:13 +02:00
[GeneratedRegex(""" < releases \ b [^>]*> """)]
private static partial Regex ReleasesStartRegex ();
2026-07-19 16:15:09 +02:00
2026-08-13 10:43:13 +02:00
// Matches one entire release element, including its indentation and its trailing line break. The
// self-closing form comes first, so that it is never mistaken for the start of a longer element:
[GeneratedRegex("""(?ms)^[ \t] *< release \ b [^>]*/>[ \ t ]* \ r ? \ n ?|^[ \ t ]*< release \ b [^>]*>.*?</ release >[ \ t ]* \ r ? \ n ? """)]
private static partial Regex ReleaseBlockRegex ();
2026-07-19 16:15:09 +02:00
[GeneratedRegex("^[0-9a-fA-F] { 40 , 64 } $ ")]
private static partial Regex GitCommitHashRegex ();
2026-06-11 12:22:09 +02:00
}