Moved the build script to System.CommandLine (#1159)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 14:57:37 +01:00
1 parent f0fa1411c8
commit 54c3f0b3e0
12 files changed
+232 -70

No files matched your search

+1 -4
View File
@@ -11,10 +11,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Cocona" Version="2.2.0" /> <PackageReference Include="System.CommandLine" Version="2.0.14" />
<!-- Pins Cocona's transitive Microsoft.Extensions.Hosting 6.0.0, which pulled in the vulnerable System.Text.Json 6.0.0 (GHSA-8g4q-xg66-9fp4) -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -4,10 +4,19 @@ namespace Build.Commands;
public sealed class AssistantPluginHashCommand public sealed class AssistantPluginHashCommand
{ {
[Command("assistant-plugin-hash", Description = "Compute the canonical assistant-plugin hash for a plugin directory")] /// <summary>
public void ComputeAssistantPluginHash( /// Defines the command "assistant-plugin-hash".
[Argument(Description = "Path to the assistant plugin directory")] string pluginDir, /// </summary>
[Option("lua-snippet", Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]")] bool luaSnippet = false) public static Command CreateCommand()
{
var pluginDir = new Argument<string>("pluginDir") { Description = "Path to the assistant plugin directory" };
var luaSnippet = new Option<bool>("--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()) if (!Environment.IsWorkingDirectoryValid())
return; return;
+10 -4
View File
@@ -1,11 +1,17 @@
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands; namespace Build.Commands;
public sealed class CheckRidsCommand public sealed class CheckRidsCommand
{ {
[Command("check-rids", Description = "Check the RIDs for the current OS")] /// <summary>
/// Defines the command "check-rids".
/// </summary>
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() public void GetRids()
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
+10 -5
View File
@@ -2,10 +2,6 @@ using System.Text.RegularExpressions;
using SharedTools; using SharedTools;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands; namespace Build.Commands;
public sealed partial class CollectI18NKeysCommand public sealed partial class CollectI18NKeysCommand
@@ -43,7 +39,16 @@ public sealed partial class CollectI18NKeysCommand
END_TAG2 END_TAG2
]; ];
[Command("collect-i18n", Description = "Collect I18N keys")] /// <summary>
/// Defines the command "collect-i18n".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("collect-i18n", "Collect I18N keys");
command.SetAction((_, _) => new CollectI18NKeysCommand().CollectI18NKeys());
return command;
}
public async Task CollectI18NKeys() public async Task CollectI18NKeys()
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
+100 -25
View File
@@ -9,17 +9,38 @@ using SharedTools;
namespace Build.Commands; namespace Build.Commands;
// ReSharper disable MemberCanBePrivate.Global // ReSharper disable MemberCanBePrivate.Global
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
public sealed partial class UpdateMetadataCommands public sealed partial class UpdateMetadataCommands
{ {
[Command("release", Description = "Prepare & build the next release")] /// <summary>
public async Task Release( /// Creates the option "--action" of the commands which prepare a release.
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE, /// </summary>
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null, private static Option<PrepareAction> 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 };
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
/// <summary>
/// Creates the option "--version" of the commands which prepare a release.
/// </summary>
private static Option<string> CreateVersionOption() => new("--version", "-v") { Description = "Set a specific version directly, e.g., 26.1.2" };
/// <summary>
/// Creates the option "--offline" of the commands which build the app.
/// </summary>
private static Option<bool> CreateOfflineOption() => new("--offline") { Description = "Skip downloads and use locally available build dependencies" };
/// <summary>
/// Defines the command "release".
/// </summary>
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()) if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1); throw new CommandExitedException(1);
@@ -57,9 +78,18 @@ public sealed partial class UpdateMetadataCommands
Console.WriteLine(" \"Release Process\" in app/Build/AGENTS.md."); Console.WriteLine(" \"Release Process\" in app/Build/AGENTS.md.");
} }
[Command("rebuild-release", Description = "Prepare & build a new build of the current release")] /// <summary>
public async Task RebuildRelease( /// Defines the command "rebuild-release".
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) /// </summary>
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()) if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1); throw new CommandExitedException(1);
@@ -106,10 +136,19 @@ public sealed partial class UpdateMetadataCommands
await this.Build(offline, skipVerify: true); await this.Build(offline, skipVerify: true);
} }
[Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")] /// <summary>
public async Task UpdateMetainfo( /// Defines the command "update-metainfo".
[Option("version", ['v'], Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata")] string? version = null, /// </summary>
[Option("date", ['d'], Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata")] string? date = null) public static Command CreateUpdateMetainfoCommand()
{
var version = new Option<string>("--version", "-v") { Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata" };
var date = new Option<string>("--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 APP_VERSION_INDEX = 0;
const int BUILD_TIME_INDEX = 1; 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")] /// <summary>
/// Defines the command "update-versions".
/// </summary>
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() public async Task UpdateVersions()
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
@@ -156,10 +204,19 @@ public sealed partial class UpdateMetadataCommands
await this.UpdateVectorStoreVersion(); await this.UpdateVectorStoreVersion();
} }
[Command("prepare", Description = "Prepare the metadata for the next release")] /// <summary>
public async Task Prepare( /// Defines the command "prepare".
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE, /// </summary>
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null) 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()) if(!Environment.IsWorkingDirectoryValid())
return; return;
@@ -236,10 +293,19 @@ public sealed partial class UpdateMetadataCommands
await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs"))); await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs")));
} }
[Command("build", Description = "Build MindWork AI Studio")] /// <summary>
public async Task Build( /// Defines the command "build".
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false, /// </summary>
[Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false) public static Command CreateBuildCommand()
{
var offline = CreateOfflineOption();
var skipVerify = new Option<bool>("--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()) if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1); throw new CommandExitedException(1);
@@ -863,7 +929,16 @@ public sealed partial class UpdateMetadataCommands
return shortVersion; return shortVersion;
} }
[Command("update-project-hash", Description = "Update the project commit hash")] /// <summary>
/// Defines the command "update-project-hash".
/// </summary>
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() public async Task UpdateProjectCommitHash()
{ {
const int COMMIT_HASH_INDEX = 8; const int COMMIT_HASH_INDEX = 8;
+10 -5
View File
@@ -1,7 +1,3 @@
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
using SharedTools; using SharedTools;
namespace Build.Commands; namespace Build.Commands;
@@ -14,7 +10,16 @@ public sealed class UpdateWebAssetsCommand
/// </summary> /// </summary>
private static readonly HashSet<string> SKIPPED_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { ".br", ".gz", ".map" }; private static readonly HashSet<string> SKIPPED_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { ".br", ".gz", ".map" };
[Command("update-web", Description = "Update web assets")] /// <summary>
/// Defines the command "update-web".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("update-web", "Update web assets");
command.SetAction(_ => new UpdateWebAssetsCommand().UpdateWebAssets());
return command;
}
public void UpdateWebAssets() public void UpdateWebAssets()
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
+10 -4
View File
@@ -2,9 +2,6 @@ using Build.Tools;
using SharedTools; using SharedTools;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands; namespace Build.Commands;
/// <summary> /// <summary>
@@ -22,7 +19,16 @@ public sealed class VerifyCommand
/// </summary> /// </summary>
private const string SIDECAR_PREFIX = "mindworkAIStudioServer-"; 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")] /// <summary>
/// Defines the command "verify".
/// </summary>
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<int> Verify() public async Task<int> Verify()
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
+12 -6
View File
@@ -1,8 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands; namespace Build.Commands;
/// <summary> /// <summary>
@@ -41,9 +38,18 @@ public sealed partial class VerifyModelsCommand
/// </remarks> /// </remarks>
private const string DAY_MARKER = "new DateOnly("; private const string DAY_MARKER = "new DateOnly(";
[Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")] /// <summary>
public int VerifyModels( /// Defines the command "verify-models".
[Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS) /// </summary>
public static Command CreateCommand()
{
var months = new Option<int>("--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()) if(!Environment.IsWorkingDirectoryValid())
return 1; return 1;
+1 -1
View File
@@ -2,6 +2,6 @@
global using System.Text; global using System.Text;
global using Cocona; global using System.CommandLine;
global using Environment = Build.Tools.Environment; global using Environment = Build.Tools.Environment;
+45 -11
View File
@@ -1,12 +1,46 @@
using Build.Commands; using System.CommandLine.Help;
var builder = CoconaApp.CreateBuilder(); using Build.Commands;
var app = builder.Build(); using Build.Tools;
app.AddCommands<CheckRidsCommand>();
app.AddCommands<UpdateMetadataCommands>(); //
app.AddCommands<UpdateWebAssetsCommand>(); // A RootCommand would take the name of the executable, which is "build", and System.CommandLine refuses
app.AddCommands<CollectI18NKeysCommand>(); // a root with the same name as one of its commands. Hence, the root is a command of its own, which
app.AddCommands<AssistantPluginHashCommand>(); // brings only the help option of a RootCommand along:
app.AddCommands<VerifyModelsCommand>(); //
app.AddCommands<VerifyCommand>(); var rootCommand = new Command("build-script", "Build, check, and release MindWork AI Studio. Run the commands from app/Build with 'dotnet run <command>'.")
app.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;
}
+19
View File
@@ -0,0 +1,19 @@
namespace Build.Tools;
/// <summary>
/// Ends the build script with an exit code, after the command has said what went wrong.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="exitCode">The exit code of the build script, where anything but zero is a failure.</param>
public sealed class CommandExitedException(int exitCode) : Exception
{
/// <summary>
/// The exit code the build script ends with.
/// </summary>
public int ExitCode { get; } = exitCode;
}
+1 -1
View File
@@ -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. 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. 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. 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 <ACTION>`, where `<ACTION>` 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 <ACTION>`, where `<ACTION>` 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. 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. 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. 7. Your proposed changes will be reviewed and merged.