diff --git a/CLAUDE.md b/CLAUDE.md index 957ccff..a3ad244 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,7 @@ ktsubuild ├── build # Build workflow (restore + build + test) │ Options: --workspace, --configuration, --verbose ├── release # Release workflow (pack + publish + GitHub release) -│ Options: --workspace, --configuration, --verbose, --dry-run +│ Options: --workspace, --configuration, --verbose, --dry-run, --version-bump ├── version │ ├── show # Display version info (version, tag, increment, reason) │ ├── bump # Calculate and print next version diff --git a/KtsuBuild.Tests/Commands/ReleaseCommandTests.cs b/KtsuBuild.Tests/Commands/ReleaseCommandTests.cs new file mode 100644 index 0000000..7d25401 --- /dev/null +++ b/KtsuBuild.Tests/Commands/ReleaseCommandTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace KtsuBuild.Tests.Commands; + +using System.CommandLine; +using KtsuBuild.Abstractions; +using KtsuBuild.Git; +using KtsuBuild.Tests.Helpers; +using KtsuBuild.Tests.Mocks; +using KtsuBuild.Tool.Commands; +using KtsuBuild.Utilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; + +/// +/// Drives the release command's handler against a workspace on disk and a substituted process +/// runner, so the wiring from its options into the pipeline stages is asserted rather than only the +/// stages themselves. +/// +/// +/// The pipeline reads its inputs from the process environment, which is per-process rather than +/// per-test, so these run one at a time and put every variable they touch back afterwards. +/// +[TestClass] +[DoNotParallelize] +public class ReleaseCommandTests +{ + private static readonly string[] EnvironmentVariables = + [ + "GITHUB_SERVER_URL", + "GITHUB_REF", + "GITHUB_SHA", + "GITHUB_REPOSITORY", + "GITHUB_TOKEN", + "GH_TOKEN", + "NUGET_API_KEY", + "KTSU_PACKAGE_KEY", + "EXPECTED_OWNER", + "GITHUB_OUTPUT", + ]; + + private readonly Dictionary _savedEnvironment = []; + + private IProcessRunner _processRunner = null!; + private RecordingBuildLogger _logger = null!; + private string _workspace = null!; + + /// + /// Gets or sets the context MSTest injects, whose cancellation token the handler runs under. + /// + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Setup() + { + foreach (string name in EnvironmentVariables) + { + _savedEnvironment[name] = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, null); + } + + Environment.SetEnvironmentVariable("GITHUB_REF", "refs/heads/main"); + Environment.SetEnvironmentVariable("GITHUB_SHA", "4444444444444444444444444444444444444444"); + Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", "ktsu-dev/TestRepo"); + + _workspace = TestHelpers.CreateTempDir("ReleaseCommand"); + _logger = new RecordingBuildLogger(); + _processRunner = Substitute.For(); + _processRunner + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => Respond(call.ArgAt(0), call.ArgAt(1))); + _processRunner + .RunAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(TestHelpers.SuccessResult()); + _processRunner + .RunWithCallbackAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any?>(), Arg.Any()) + .Returns(0); + } + + [TestCleanup] + public void Cleanup() + { + foreach (KeyValuePair saved in _savedEnvironment) + { + Environment.SetEnvironmentVariable(saved.Key, saved.Value); + } + + if (Directory.Exists(_workspace)) + { + Directory.Delete(_workspace, recursive: true); + } + } + + // A workflow that splits ci --no-release from a later release step forwards its dispatch's + // --version-bump to both. A forced bump over commits that all carry [skip ci] opens the gate in + // ci, which commits the forced version, so release has to open it too or that version is never + // published. + [TestMethod] + public async Task ForcedVersionBumpReleasesWhenEveryCommitCarriesSkipCi() + { + await RunAsync("patch").ConfigureAwait(false); + + await AssertTagCreatedAsync("v3.10.1").ConfigureAwait(false); + } + + [TestMethod] + public async Task AutoVersionBumpStillSkipsWhenEveryCommitCarriesSkipCi() + { + await RunAsync("auto").ConfigureAwait(false); + + await _processRunner.DidNotReceive().RunAsync( + "git", + ArgMatch.NotNull(a => a.StartsWith("tag -a", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any()).ConfigureAwait(false); + Assert.IsTrue(_logger.Infos.Any(m => m.StartsWith("Skipping release:", StringComparison.Ordinal)), string.Join(Environment.NewLine, _logger.Infos)); + } + + [TestMethod] + [DataRow("majr")] + [DataRow("pre")] + [DataRow("")] + public void VersionBumpRejectsAnUnknownValueAtParseTime(string versionBump) + { + ParseResult result = new ReleaseCommand().Parse(["--version-bump", versionBump]); + + Assert.HasCount(1, result.Errors); + StringAssert.Contains(result.Errors[0].Message, "--version-bump"); + } + + [TestMethod] + [DataRow("auto")] + [DataRow("patch")] + [DataRow("Minor")] + [DataRow("MAJOR")] + public void VersionBumpAcceptsTheKnownValuesIgnoringCase(string versionBump) + { + ParseResult result = new ReleaseCommand().Parse(["--version-bump", versionBump]); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual(versionBump, result.GetValue(GlobalOptions.VersionBump)); + } + + [TestMethod] + public void VersionBumpDefaultsToAuto() + { + ParseResult result = new ReleaseCommand().Parse([]); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual("auto", result.GetValue(GlobalOptions.VersionBump)); + } + + private async Task RunAsync(string versionBump) + { + Func> handler = ReleaseCommand.CreateHandler(_processRunner, _logger); + + await handler(new ReleaseCommand.ReleaseOptions( + Workspace: _workspace, + Configuration: "Release", + Verbose: false, + DryRun: false, + VersionBump: versionBump), TestContext.CancellationToken).ConfigureAwait(false); + } + + private async Task AssertTagCreatedAsync(string tag) => + await _processRunner.Received().RunAsync( + "git", + ArgMatch.NotNull(a => a.StartsWith($"tag -a \"{tag}\" ", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any()).ConfigureAwait(false); + + /// + /// Answers the commands the release workflow runs: one tag, v3.10.0, followed only by + /// commits that carry [skip ci], on an official repository whose HEAD is untagged. + /// Everything else succeeds with no output. + /// + private static ProcessResult Respond(string fileName, string arguments) + { + if (fileName == "gh") + { + return arguments.StartsWith("repo view", StringComparison.Ordinal) + ? TestHelpers.SuccessResult("{\"owner\":{\"login\":\"ktsu-dev\"},\"nameWithOwner\":\"ktsu-dev/TestRepo\",\"isFork\":false}") + : TestHelpers.SuccessResult(); + } + + if (fileName == "git") + { + if (arguments == GitService.ListTagsArguments) + { + return TestHelpers.SuccessResult("v3.10.0"); + } + + if (arguments == "rev-parse HEAD") + { + return TestHelpers.SuccessResult("1111111111111111111111111111111111111111"); + } + + if (arguments == "rev-list HEAD") + { + return TestHelpers.SuccessResult("1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222"); + } + + if (arguments.StartsWith("rev-list -n 1 ", StringComparison.Ordinal)) + { + return TestHelpers.SuccessResult("3333333333333333333333333333333333333333"); + } + + if (arguments.StartsWith("log --pretty=format:", StringComparison.Ordinal)) + { + return TestHelpers.SuccessResult("c0\u001f[bot][skip ci] Update Metadata\u001fdeveloper"); + } + } + + return TestHelpers.SuccessResult(); + } +} diff --git a/KtsuBuild.Tool/Commands/ReleaseCommand.cs b/KtsuBuild.Tool/Commands/ReleaseCommand.cs index 7d94201..b9a5ceb 100644 --- a/KtsuBuild.Tool/Commands/ReleaseCommand.cs +++ b/KtsuBuild.Tool/Commands/ReleaseCommand.cs @@ -15,6 +15,22 @@ namespace KtsuBuild.Tool.Commands; public class ReleaseCommand : Command #pragma warning restore CA1010 { + /// + /// The inputs to a release run. A record rather than positional parameters because adjacent + /// booleans in a delegate signature can be transposed without a compiler error. + /// + /// The workspace or repository path. + /// The build configuration, Debug or Release. + /// Whether to enable verbose logging. + /// Whether to report what would happen without making changes. + /// The forced version bump type, or auto to detect it. + public sealed record ReleaseOptions( + string Workspace, + string Configuration, + bool Verbose, + bool DryRun, + string VersionBump); + /// /// Initializes a new instance of the class. /// @@ -24,6 +40,7 @@ public ReleaseCommand() : base("release", "Release workflow: pack, publish, rele Options.Add(GlobalOptions.Configuration); Options.Add(GlobalOptions.Verbose); Options.Add(GlobalOptions.DryRun); + Options.Add(GlobalOptions.VersionBump); } /// @@ -32,15 +49,16 @@ public ReleaseCommand() : base("release", "Release workflow: pack, publish, rele /// The process runner. /// The build logger. /// The command handler action. - public static Func> CreateHandler( + public static Func> CreateHandler( IProcessRunner processRunner, IBuildLogger logger) { - return async (workspace, configuration, verbose, dryRun, cancellationToken) => + return async (options, cancellationToken) => { - logger.VerboseEnabled = verbose; + Ensure.NotNull(options); + logger.VerboseEnabled = options.Verbose; - if (dryRun) + if (options.DryRun) { logger.WriteWarning("DRY RUN MODE - No changes will be made"); } @@ -52,7 +70,7 @@ public static Func> Cre #pragma warning disable CA1031 // Top-level command handler must catch all exceptions try { - PipelineContext context = await pipeline.PrepareAsync(workspace, configuration, cancellationToken).ConfigureAwait(false); + PipelineContext context = await pipeline.PrepareAsync(options.Workspace, options.Configuration, cancellationToken).ConfigureAwait(false); BuildConfiguration buildConfig = context.Configuration; if (!buildConfig.ShouldRelease) @@ -62,7 +80,7 @@ public static Func> Cre return 0; } - if (dryRun) + if (options.DryRun) { logger.WriteInfo("Would pack, publish NuGet packages, and create GitHub release"); return 0; @@ -78,7 +96,11 @@ public static Func> Cre GitService gitService = new(processRunner, logger); buildConfig.ReleaseHash = await gitService.GetCurrentCommitHashAsync(buildConfig.WorkspacePath, cancellationToken).ConfigureAwait(false); - await pipeline.ResolveVersionAsync(context, "auto", cancellationToken).ConfigureAwait(false); + // The bump has to match the one ci --no-release was given in the same run. ci wrote that + // version to VERSION.md and committed it, and a forced bump also opens the version gate + // over commits that all carry [skip ci]. Resolving as auto here would close the gate + // again, leaving the forced version committed and never released. + await pipeline.ResolveVersionAsync(context, options.VersionBump, cancellationToken).ConfigureAwait(false); // Resolving deliberately leaves Configuration.Version alone, because in ci it is // assigned from the metadata result instead. A standalone release has no metadata @@ -88,9 +110,9 @@ public static Func> Cre pipeline.ApplyResolvedVersion(context); // The version gate is how [skip ci] and a run with no meaningful changes suppress - // a release. A standalone release honors it the same way ci does, so a run whose - // commits all carry the skip marker does not publish here either. Nothing is - // suppressed by a flag, because this command has no --no-release. + // a release. A standalone release honors it the same way ci does, so without a forced + // bump a run whose commits all carry the skip marker does not publish here either. + // Nothing is suppressed by a flag, because this command has no --no-release. await pipeline.ReleaseIfPermittedAsync(context, suppressedByFlag: false, cancellationToken).ConfigureAwait(false); logger.WriteSuccess("Release workflow completed successfully!"); diff --git a/KtsuBuild.Tool/Program.cs b/KtsuBuild.Tool/Program.cs index 97d33b1..e5d52f0 100644 --- a/KtsuBuild.Tool/Program.cs +++ b/KtsuBuild.Tool/Program.cs @@ -88,14 +88,16 @@ private static void AddBuildCommand(RootCommand rootCommand, IProcessRunner proc private static void AddReleaseCommand(RootCommand rootCommand, IProcessRunner processRunner, IBuildLogger logger) { ReleaseCommand command = new(); - Func> handler = ReleaseCommand.CreateHandler(processRunner, logger); + Func> handler = ReleaseCommand.CreateHandler(processRunner, logger); command.SetAction(async (parseResult, ct) => { - string workspace = parseResult.GetValue(GlobalOptions.Workspace)!; - string configuration = parseResult.GetValue(GlobalOptions.Configuration)!; - bool verbose = parseResult.GetValue(GlobalOptions.Verbose); - bool dryRun = parseResult.GetValue(GlobalOptions.DryRun); - return await handler(workspace, configuration, verbose, dryRun, ct).ConfigureAwait(false); + ReleaseCommand.ReleaseOptions options = new( + Workspace: parseResult.GetValue(GlobalOptions.Workspace)!, + Configuration: parseResult.GetValue(GlobalOptions.Configuration)!, + Verbose: parseResult.GetValue(GlobalOptions.Verbose), + DryRun: parseResult.GetValue(GlobalOptions.DryRun), + VersionBump: parseResult.GetValue(GlobalOptions.VersionBump)!); + return await handler(options, ct).ConfigureAwait(false); }); rootCommand.Subcommands.Add(command); } diff --git a/README.md b/README.md index d4b2dd4..942fae9 100644 --- a/README.md +++ b/README.md @@ -230,10 +230,15 @@ ktsubuild release [options] **Options:** - `--dry-run`: Preview actions without executing them +- `--version-bump `: Force version bump type (`auto`, `patch`, `minor`, `major`) `release` resolves the version the same way `ci` does, from the repository's tags and commit history, and publishes against the current commit. It also honors the version gate, so a run whose -commits all carry `[skip ci]` publishes nothing. +commits all carry `[skip ci]` publishes nothing unless the bump is forced. + +In a split pipeline, pass `release` the same `--version-bump` that `ci --no-release` received. `ci` +writes the forced version to VERSION.md and commits it, and a `release` left on `auto` would +resolve a different version, or close the gate the forced bump opened. Targeting the current commit matters in a split pipeline. When `ci --no-test --no-release` runs first, it commits the updated metadata, so the current commit is the one whose VERSION.md carries