Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
216 changes: 216 additions & 0 deletions KtsuBuild.Tests/Commands/ReleaseCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Drives the <c>release</c> 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<string, string?> _savedEnvironment = [];

private IProcessRunner _processRunner = null!;
private RecordingBuildLogger _logger = null!;
private string _workspace = null!;

/// <summary>
/// Gets or sets the context MSTest injects, whose cancellation token the handler runs under.
/// </summary>
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<IProcessRunner>();
_processRunner
.RunAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(call => Respond(call.ArgAt<string>(0), call.ArgAt<string>(1)));
_processRunner
.RunAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(TestHelpers.SuccessResult());
_processRunner
.RunWithCallbackAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>())
.Returns(0);
}

[TestCleanup]
public void Cleanup()
{
foreach (KeyValuePair<string, string?> 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<string>(a => a.StartsWith("tag -a", StringComparison.Ordinal)),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()).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<ReleaseCommand.ReleaseOptions, CancellationToken, Task<int>> 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<string>(a => a.StartsWith($"tag -a \"{tag}\" ", StringComparison.Ordinal)),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()).ConfigureAwait(false);

/// <summary>
/// Answers the commands the release workflow runs: one tag, <c>v3.10.0</c>, followed only by
/// commits that carry <c>[skip ci]</c>, on an official repository whose HEAD is untagged.
/// Everything else succeeds with no output.
/// </summary>
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();
}
}
42 changes: 32 additions & 10 deletions KtsuBuild.Tool/Commands/ReleaseCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ namespace KtsuBuild.Tool.Commands;
public class ReleaseCommand : Command
#pragma warning restore CA1010
{
/// <summary>
/// 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.
/// </summary>
/// <param name="Workspace">The workspace or repository path.</param>
/// <param name="Configuration">The build configuration, Debug or Release.</param>
/// <param name="Verbose">Whether to enable verbose logging.</param>
/// <param name="DryRun">Whether to report what would happen without making changes.</param>
/// <param name="VersionBump">The forced version bump type, or <c>auto</c> to detect it.</param>
public sealed record ReleaseOptions(
string Workspace,
string Configuration,
bool Verbose,
bool DryRun,
string VersionBump);

/// <summary>
/// Initializes a new instance of the <see cref="ReleaseCommand"/> class.
/// </summary>
Expand All @@ -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);
}

/// <summary>
Expand All @@ -32,15 +49,16 @@ public ReleaseCommand() : base("release", "Release workflow: pack, publish, rele
/// <param name="processRunner">The process runner.</param>
/// <param name="logger">The build logger.</param>
/// <returns>The command handler action.</returns>
public static Func<string, string, bool, bool, CancellationToken, Task<int>> CreateHandler(
public static Func<ReleaseOptions, CancellationToken, Task<int>> 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");
}
Expand All @@ -52,7 +70,7 @@ public static Func<string, string, bool, bool, CancellationToken, Task<int>> 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)
Expand All @@ -62,7 +80,7 @@ public static Func<string, string, bool, bool, CancellationToken, Task<int>> Cre
return 0;
}

if (dryRun)
if (options.DryRun)
{
logger.WriteInfo("Would pack, publish NuGet packages, and create GitHub release");
return 0;
Expand All @@ -78,7 +96,11 @@ public static Func<string, string, bool, bool, CancellationToken, Task<int>> 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
Expand All @@ -88,9 +110,9 @@ public static Func<string, string, bool, bool, CancellationToken, Task<int>> 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!");
Expand Down
14 changes: 8 additions & 6 deletions KtsuBuild.Tool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string, bool, bool, CancellationToken, Task<int>> handler = ReleaseCommand.CreateHandler(processRunner, logger);
Func<ReleaseCommand.ReleaseOptions, CancellationToken, Task<int>> 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);
}
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,15 @@ ktsubuild release [options]

**Options:**
- `--dry-run`: Preview actions without executing them
- `--version-bump <type>`: 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
Expand Down
Loading