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