From 54c3f0b3e083e48fb0508c86de778405a2ca9633 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 4 Nov 2026 14:57:37 +0100 Subject: [PATCH] Moved the build script to System.CommandLine (#1159) --- app/Build/Build Script.csproj | 5 +- .../Commands/AssistantPluginHashCommand.cs | 17 ++- app/Build/Commands/CheckRidsCommand.cs | 14 +- app/Build/Commands/CollectI18NKeysCommand.cs | 15 ++- app/Build/Commands/UpdateMetadataCommands.cs | 125 ++++++++++++++---- app/Build/Commands/UpdateWebAssetsCommand.cs | 15 ++- app/Build/Commands/VerifyCommand.cs | 14 +- app/Build/Commands/VerifyModelsCommand.cs | 18 ++- app/Build/GlobalUsings.cs | 2 +- app/Build/Program.cs | 56 ++++++-- app/Build/Tools/CommandExitedException.cs | 19 +++ documentation/Build.md | 2 +- 12 files changed, 232 insertions(+), 70 deletions(-) create mode 100644 app/Build/Tools/CommandExitedException.cs diff --git a/app/Build/Build Script.csproj b/app/Build/Build Script.csproj index 596f0b46a..c34869c27 100644 --- a/app/Build/Build Script.csproj +++ b/app/Build/Build Script.csproj @@ -11,10 +11,7 @@ - - - - + diff --git a/app/Build/Commands/AssistantPluginHashCommand.cs b/app/Build/Commands/AssistantPluginHashCommand.cs index 8d7552e40..c29677589 100644 --- a/app/Build/Commands/AssistantPluginHashCommand.cs +++ b/app/Build/Commands/AssistantPluginHashCommand.cs @@ -4,10 +4,19 @@ namespace Build.Commands; public sealed class AssistantPluginHashCommand { - [Command("assistant-plugin-hash", Description = "Compute the canonical assistant-plugin hash for a plugin directory")] - public void ComputeAssistantPluginHash( - [Argument(Description = "Path to the assistant plugin directory")] string pluginDir, - [Option("lua-snippet", Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]")] bool luaSnippet = false) + /// + /// Defines the command "assistant-plugin-hash". + /// + public static Command CreateCommand() + { + var pluginDir = new Argument("pluginDir") { Description = "Path to the assistant plugin directory" }; + var luaSnippet = new Option("--lua-snippet") { Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]" }; + var command = new Command("assistant-plugin-hash", "Compute the canonical assistant-plugin hash for a plugin directory") { pluginDir, luaSnippet }; + command.SetAction(parseResult => new AssistantPluginHashCommand().ComputeAssistantPluginHash(parseResult.GetRequiredValue(pluginDir), parseResult.GetValue(luaSnippet))); + return command; + } + + public void ComputeAssistantPluginHash(string pluginDir, bool luaSnippet = false) { if (!Environment.IsWorkingDirectoryValid()) return; diff --git a/app/Build/Commands/CheckRidsCommand.cs b/app/Build/Commands/CheckRidsCommand.cs index 74912a338..78ea5e2c3 100644 --- a/app/Build/Commands/CheckRidsCommand.cs +++ b/app/Build/Commands/CheckRidsCommand.cs @@ -1,11 +1,17 @@ -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedType.Global -// ReSharper disable UnusedMember.Global namespace Build.Commands; public sealed class CheckRidsCommand { - [Command("check-rids", Description = "Check the RIDs for the current OS")] + /// + /// Defines the command "check-rids". + /// + public static Command CreateCommand() + { + var command = new Command("check-rids", "Check the RIDs for the current OS"); + command.SetAction(_ => new CheckRidsCommand().GetRids()); + return command; + } + public void GetRids() { if(!Environment.IsWorkingDirectoryValid()) diff --git a/app/Build/Commands/CollectI18NKeysCommand.cs b/app/Build/Commands/CollectI18NKeysCommand.cs index 2b4dbd96d..c2d2155ff 100644 --- a/app/Build/Commands/CollectI18NKeysCommand.cs +++ b/app/Build/Commands/CollectI18NKeysCommand.cs @@ -2,10 +2,6 @@ using System.Text.RegularExpressions; using SharedTools; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedType.Global -// ReSharper disable UnusedMember.Global - namespace Build.Commands; public sealed partial class CollectI18NKeysCommand @@ -43,7 +39,16 @@ public sealed partial class CollectI18NKeysCommand END_TAG2 ]; - [Command("collect-i18n", Description = "Collect I18N keys")] + /// + /// Defines the command "collect-i18n". + /// + public static Command CreateCommand() + { + var command = new Command("collect-i18n", "Collect I18N keys"); + command.SetAction((_, _) => new CollectI18NKeysCommand().CollectI18NKeys()); + return command; + } + public async Task CollectI18NKeys() { if(!Environment.IsWorkingDirectoryValid()) diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 10ecf325b..29467ed98 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -9,17 +9,38 @@ using SharedTools; 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")] - public async Task Release( - [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, - [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) + /// + /// Creates the option "--action" of the commands which prepare a release. + /// + private static Option CreateActionOption() => new("--action", "-a") { Description = "The release action: build, month, or year, which raise the release, the month, or the year of the version, e.g., 26.11.1 becomes 26.11.2, 26.12.1, or 27.1.1", DefaultValueFactory = _ => PrepareAction.NONE }; + + /// + /// Creates the option "--version" of the commands which prepare a release. + /// + private static Option CreateVersionOption() => new("--version", "-v") { Description = "Set a specific version directly, e.g., 26.1.2" }; + + /// + /// Creates the option "--offline" of the commands which build the app. + /// + private static Option CreateOfflineOption() => new("--offline") { Description = "Skip downloads and use locally available build dependencies" }; + + /// + /// Defines the command "release". + /// + public static Command CreateReleaseCommand() + { + var action = CreateActionOption(); + var version = CreateVersionOption(); + var offline = CreateOfflineOption(); + var command = new Command("release", "Prepare & build the next release") { action, version, offline }; + command.SetAction((parseResult, _) => new UpdateMetadataCommands().Release(parseResult.GetValue(action), parseResult.GetValue(version), parseResult.GetValue(offline))); + return command; + } + + public async Task Release(PrepareAction action = PrepareAction.NONE, string? version = null, bool offline = false) { if(!Environment.IsWorkingDirectoryValid()) throw new CommandExitedException(1); @@ -57,9 +78,18 @@ public sealed partial class UpdateMetadataCommands Console.WriteLine(" \"Release Process\" in app/Build/AGENTS.md."); } - [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) + /// + /// Defines the command "rebuild-release". + /// + public static Command CreateRebuildReleaseCommand() + { + var offline = CreateOfflineOption(); + var command = new Command("rebuild-release", "Prepare & build a new build of the current release") { offline }; + command.SetAction((parseResult, _) => new UpdateMetadataCommands().RebuildRelease(parseResult.GetValue(offline))); + return command; + } + + public async Task RebuildRelease(bool offline = false) { if(!Environment.IsWorkingDirectoryValid()) throw new CommandExitedException(1); @@ -106,10 +136,19 @@ public sealed partial class UpdateMetadataCommands await this.Build(offline, skipVerify: true); } - [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) + /// + /// Defines the command "update-metainfo". + /// + public static Command CreateUpdateMetainfoCommand() + { + var version = new Option("--version", "-v") { Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata" }; + var date = new Option("--date", "-d") { Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata" }; + var command = new Command("update-metainfo", "Update the AppStream metainfo entry of one release from its changelog") { version, date }; + command.SetAction((parseResult, _) => new UpdateMetadataCommands().UpdateMetainfo(parseResult.GetValue(version), parseResult.GetValue(date))); + return command; + } + + public async Task UpdateMetainfo(string? version = null, string? date = null) { const int APP_VERSION_INDEX = 0; const int BUILD_TIME_INDEX = 1; @@ -140,7 +179,16 @@ public sealed partial class UpdateMetadataCommands } } - [Command("update-versions", Description = "The command will update the package versions in the metadata file")] + /// + /// Defines the command "update-versions". + /// + public static Command CreateUpdateVersionsCommand() + { + var command = new Command("update-versions", "The command will update the package versions in the metadata file"); + command.SetAction((_, _) => new UpdateMetadataCommands().UpdateVersions()); + return command; + } + public async Task UpdateVersions() { if(!Environment.IsWorkingDirectoryValid()) @@ -156,10 +204,19 @@ public sealed partial class UpdateMetadataCommands await this.UpdateVectorStoreVersion(); } - [Command("prepare", Description = "Prepare the metadata for the next release")] - 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) + /// + /// Defines the command "prepare". + /// + public static Command CreatePrepareCommand() + { + var action = CreateActionOption(); + var version = CreateVersionOption(); + var command = new Command("prepare", "Prepare the metadata for the next release") { action, version }; + command.SetAction((parseResult, _) => new UpdateMetadataCommands().Prepare(parseResult.GetValue(action), parseResult.GetValue(version))); + return command; + } + + public async Task Prepare(PrepareAction action = PrepareAction.NONE, string? version = null) { if(!Environment.IsWorkingDirectoryValid()) return; @@ -236,10 +293,19 @@ public sealed partial class UpdateMetadataCommands await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs"))); } - [Command("build", Description = "Build MindWork AI Studio")] - public async Task Build( - [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) + /// + /// Defines the command "build". + /// + public static Command CreateBuildCommand() + { + var offline = CreateOfflineOption(); + var skipVerify = new Option("--skip-verify") { Description = "Skip the quality gate which otherwise runs before anything is built" }; + var command = new Command("build", "Build MindWork AI Studio") { offline, skipVerify }; + command.SetAction((parseResult, _) => new UpdateMetadataCommands().Build(parseResult.GetValue(offline), parseResult.GetValue(skipVerify))); + return command; + } + + public async Task Build(bool offline = false, bool skipVerify = false) { if(!Environment.IsWorkingDirectoryValid()) throw new CommandExitedException(1); @@ -863,7 +929,16 @@ public sealed partial class UpdateMetadataCommands return shortVersion; } - [Command("update-project-hash", Description = "Update the project commit hash")] + /// + /// Defines the command "update-project-hash". + /// + public static Command CreateUpdateProjectHashCommand() + { + var command = new Command("update-project-hash", "Update the project commit hash"); + command.SetAction((_, _) => new UpdateMetadataCommands().UpdateProjectCommitHash()); + return command; + } + public async Task UpdateProjectCommitHash() { const int COMMIT_HASH_INDEX = 8; diff --git a/app/Build/Commands/UpdateWebAssetsCommand.cs b/app/Build/Commands/UpdateWebAssetsCommand.cs index 1d5bedf96..8e384fa3d 100644 --- a/app/Build/Commands/UpdateWebAssetsCommand.cs +++ b/app/Build/Commands/UpdateWebAssetsCommand.cs @@ -1,7 +1,3 @@ -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedType.Global -// ReSharper disable UnusedMember.Global - using SharedTools; namespace Build.Commands; @@ -14,7 +10,16 @@ public sealed class UpdateWebAssetsCommand /// private static readonly HashSet SKIPPED_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { ".br", ".gz", ".map" }; - [Command("update-web", Description = "Update web assets")] + /// + /// Defines the command "update-web". + /// + public static Command CreateCommand() + { + var command = new Command("update-web", "Update web assets"); + command.SetAction(_ => new UpdateWebAssetsCommand().UpdateWebAssets()); + return command; + } + public void UpdateWebAssets() { if(!Environment.IsWorkingDirectoryValid()) diff --git a/app/Build/Commands/VerifyCommand.cs b/app/Build/Commands/VerifyCommand.cs index ac0f0c025..50210193c 100644 --- a/app/Build/Commands/VerifyCommand.cs +++ b/app/Build/Commands/VerifyCommand.cs @@ -2,9 +2,6 @@ using Build.Tools; using SharedTools; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedType.Global -// ReSharper disable UnusedMember.Global namespace Build.Commands; /// @@ -22,7 +19,16 @@ public sealed class VerifyCommand /// private const string SIDECAR_PREFIX = "mindworkAIStudioServer-"; - [Command("verify", Description = "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources")] + /// + /// Defines the command "verify". + /// + public static Command CreateCommand() + { + var command = new Command("verify", "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources"); + command.SetAction((_, _) => new VerifyCommand().Verify()); + return command; + } + public async Task Verify() { if(!Environment.IsWorkingDirectoryValid()) diff --git a/app/Build/Commands/VerifyModelsCommand.cs b/app/Build/Commands/VerifyModelsCommand.cs index 30052d7d1..9b3a7142d 100644 --- a/app/Build/Commands/VerifyModelsCommand.cs +++ b/app/Build/Commands/VerifyModelsCommand.cs @@ -1,8 +1,5 @@ using System.Text.RegularExpressions; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedType.Global -// ReSharper disable UnusedMember.Global namespace Build.Commands; /// @@ -41,9 +38,18 @@ public sealed partial class VerifyModelsCommand /// private const string DAY_MARKER = "new DateOnly("; - [Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")] - public int VerifyModels( - [Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS) + /// + /// Defines the command "verify-models". + /// + public static Command CreateCommand() + { + var months = new Option("--months") { Description = "How long a page may go unread before it is reported", DefaultValueFactory = _ => DEFAULT_MONTHS }; + var command = new Command("verify-models", "Report how long ago the pages behind the model rules were read") { months }; + command.SetAction(parseResult => new VerifyModelsCommand().VerifyModels(parseResult.GetValue(months))); + return command; + } + + public int VerifyModels(int months = DEFAULT_MONTHS) { if(!Environment.IsWorkingDirectoryValid()) return 1; diff --git a/app/Build/GlobalUsings.cs b/app/Build/GlobalUsings.cs index e15bc8b9a..cf1272e61 100644 --- a/app/Build/GlobalUsings.cs +++ b/app/Build/GlobalUsings.cs @@ -2,6 +2,6 @@ global using System.Text; -global using Cocona; +global using System.CommandLine; global using Environment = Build.Tools.Environment; \ No newline at end of file diff --git a/app/Build/Program.cs b/app/Build/Program.cs index 999e30b2e..82fb48bed 100644 --- a/app/Build/Program.cs +++ b/app/Build/Program.cs @@ -1,12 +1,46 @@ -using Build.Commands; +using System.CommandLine.Help; -var builder = CoconaApp.CreateBuilder(); -var app = builder.Build(); -app.AddCommands(); -app.AddCommands(); -app.AddCommands(); -app.AddCommands(); -app.AddCommands(); -app.AddCommands(); -app.AddCommands(); -app.Run(); +using Build.Commands; +using Build.Tools; + +// +// A RootCommand would take the name of the executable, which is "build", and System.CommandLine refuses +// a root with the same name as one of its commands. Hence, the root is a command of its own, which +// brings only the help option of a RootCommand along: +// +var rootCommand = new Command("build-script", "Build, check, and release MindWork AI Studio. Run the commands from app/Build with 'dotnet run '.") +{ + new HelpOption(), + CheckRidsCommand.CreateCommand(), + UpdateMetadataCommands.CreateReleaseCommand(), + UpdateMetadataCommands.CreateRebuildReleaseCommand(), + UpdateMetadataCommands.CreateUpdateMetainfoCommand(), + UpdateMetadataCommands.CreateUpdateVersionsCommand(), + UpdateMetadataCommands.CreatePrepareCommand(), + UpdateMetadataCommands.CreateBuildCommand(), + UpdateMetadataCommands.CreateUpdateProjectHashCommand(), + UpdateWebAssetsCommand.CreateCommand(), + CollectI18NKeysCommand.CreateCommand(), + AssistantPluginHashCommand.CreateCommand(), + VerifyModelsCommand.CreateCommand(), + VerifyCommand.CreateCommand() +}; + +// +// A command which fails deep inside, e.g., the build within a release, ends the whole script through +// a CommandExitedException, once it has said why. System.CommandLine would print that exception with +// its stack trace, so we handle the exceptions ourselves and print only those nobody expected: +// +try +{ + return await rootCommand.Parse(args).InvokeAsync(new InvocationConfiguration { EnableDefaultExceptionHandler = false }); +} +catch (CommandExitedException exception) +{ + return exception.ExitCode; +} +catch (Exception exception) +{ + Console.Error.WriteLine($"Unhandled exception: {exception}"); + return 1; +} diff --git a/app/Build/Tools/CommandExitedException.cs b/app/Build/Tools/CommandExitedException.cs new file mode 100644 index 000000000..b5f31519c --- /dev/null +++ b/app/Build/Tools/CommandExitedException.cs @@ -0,0 +1,19 @@ +namespace Build.Tools; + +/// +/// Ends the build script with an exit code, after the command has said what went wrong. +/// +/// +/// A release runs several commands one after another, and a failure deep inside one of them has to end +/// the whole release, so that nothing is built on top of half-prepared metadata or broken artifacts. +/// Program.cs turns the exception into the exit code and prints nothing more: the command already +/// explained the failure, and a stack trace would only bury that explanation. +/// +/// The exit code of the build script, where anything but zero is a failure. +public sealed class CommandExitedException(int exitCode) : Exception +{ + /// + /// The exit code the build script ends with. + /// + public int ExitCode { get; } = exitCode; +} \ No newline at end of file diff --git a/documentation/Build.md b/documentation/Build.md index f282329b5..15d891890 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -81,7 +81,7 @@ In order to create a release: 1. To create a new release, you need to be a maintainer of the repository—see step 8. 2. Make sure there's a changelog file for the version you want to create in the `/app/MindWork AI Studio/wwwroot/changelog` directory. Name the file `vX.Y.Z.md` and include all release changes—your updates and any others included in this version. 3. After you have created the changelog file, you must commit the changes to the repository. -4. To prepare a new release, open a terminal, go to `/app/Build` and run `dotnet run release --action `, where `` is either `patch` (creating a patch version), `minor` (creating a minor version), or `major` (creating a major version). +4. To prepare a new release, open a terminal, go to `/app/Build` and run `dotnet run release --action `, where `` raises one part of the version, which reads year.month.release: `build` raises the release (26.11.1 becomes 26.11.2), `month` the month (26.12.1), and `year` the year (27.1.1). When a month passed without a release, set the version directly instead, e.g., `dotnet run release --version 27.2.1`. 5. Now wait until all process steps have been completed. Among other things, the version number will be incremented, the new changelog registered, and the version numbers of central dependencies updated, etc. 6. The actual release will be built by our GitHub Workflow. For this to work, you need to create a PR with your changes. 7. Your proposed changes will be reviewed and merged.