From 43984a0ed17f1b6d578a11e064e5f2b1791354e2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:18:46 +0200 Subject: [PATCH 01/13] build: share project settings via Directory.Build.props Move the common test project settings (Exe output, test project flag, xUnit v3 reference) into tests/Directory.Build.props and the common tool project settings into tools/Directory.Build.props. Both import the root Directory.Build.props. Co-Authored-By: Claude Opus 5.5 --- tests/Directory.Build.props | 12 ++++++++++++ tests/GuidedCoding.Tests/GuidedCoding.Tests.csproj | 13 +------------ tools/Directory.Build.props | 7 +++++++ .../GuidedCoding.ClaudeGenerator.csproj | 8 +------- 4 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 tests/Directory.Build.props create mode 100644 tools/Directory.Build.props diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..fa07fb7 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,12 @@ + + + + + Exe + true + + + + + + diff --git a/tests/GuidedCoding.Tests/GuidedCoding.Tests.csproj b/tests/GuidedCoding.Tests/GuidedCoding.Tests.csproj index d3d54f3..6b512ec 100644 --- a/tests/GuidedCoding.Tests/GuidedCoding.Tests.csproj +++ b/tests/GuidedCoding.Tests/GuidedCoding.Tests.csproj @@ -1,12 +1 @@ - - - - Exe - true - - - - - - - + diff --git a/tools/Directory.Build.props b/tools/Directory.Build.props new file mode 100644 index 0000000..335fdd5 --- /dev/null +++ b/tools/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + Exe + + diff --git a/tools/GuidedCoding.ClaudeGenerator/GuidedCoding.ClaudeGenerator.csproj b/tools/GuidedCoding.ClaudeGenerator/GuidedCoding.ClaudeGenerator.csproj index 52e6553..6b512ec 100644 --- a/tools/GuidedCoding.ClaudeGenerator/GuidedCoding.ClaudeGenerator.csproj +++ b/tools/GuidedCoding.ClaudeGenerator/GuidedCoding.ClaudeGenerator.csproj @@ -1,7 +1 @@ - - - - Exe - - - + From 1500fd0bf158ec9f081c6d91d34c823246489a32 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:18:46 +0200 Subject: [PATCH 02/13] build: add release tool that derives versions from Conventional Commits GuidedCoding.Release proposes the next version from the commits since the last vMAJOR.MINOR.PATCH tag, updates the manifests and CHANGELOG.md, validates, commits, tags, pushes main and the tag atomically, and runs gh skill publish. The package validation tests no longer pin a specific version, and the changelog now collects changes under an Unreleased heading. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- GuidedCoding.slnx | 10 +- .../ChangelogTests.cs | 53 +++ .../ConventionalCommitsTests.cs | 52 +++ tests/GuidedCoding.Release.Tests/GitTests.cs | 76 ++++ .../GuidedCoding.Release.Tests.csproj | 7 + .../ManifestsTests.cs | 38 ++ .../RecordingCommandRunner.cs | 28 ++ .../ReleaseOptionsTests.cs | 42 ++ .../ReleaseRepository.cs | 167 ++++++++ .../ReleaseWorkflowTests.cs | 394 ++++++++++++++++++ .../SemanticVersionTests.cs | 85 ++++ .../PackageValidationTests.cs | 6 +- tools/GuidedCoding.Release/Changelog.cs | 39 ++ tools/GuidedCoding.Release/Command.cs | 18 + .../ConventionalCommits.cs | 36 ++ tools/GuidedCoding.Release/Git.cs | 62 +++ .../GuidedCoding.Release.csproj | 1 + tools/GuidedCoding.Release/Manifests.cs | 33 ++ tools/GuidedCoding.Release/Processes.cs | 71 ++++ tools/GuidedCoding.Release/Program.cs | 54 +++ tools/GuidedCoding.Release/ReleaseOptions.cs | 32 ++ tools/GuidedCoding.Release/ReleaseResult.cs | 8 + tools/GuidedCoding.Release/ReleaseType.cs | 10 + tools/GuidedCoding.Release/ReleaseWorkflow.cs | 224 ++++++++++ tools/GuidedCoding.Release/SemanticVersion.cs | 83 ++++ 26 files changed, 1626 insertions(+), 5 deletions(-) create mode 100644 tests/GuidedCoding.Release.Tests/ChangelogTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/ConventionalCommitsTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/GitTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/GuidedCoding.Release.Tests.csproj create mode 100644 tests/GuidedCoding.Release.Tests/ManifestsTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/RecordingCommandRunner.cs create mode 100644 tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/ReleaseRepository.cs create mode 100644 tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs create mode 100644 tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs create mode 100644 tools/GuidedCoding.Release/Changelog.cs create mode 100644 tools/GuidedCoding.Release/Command.cs create mode 100644 tools/GuidedCoding.Release/ConventionalCommits.cs create mode 100644 tools/GuidedCoding.Release/Git.cs create mode 100644 tools/GuidedCoding.Release/GuidedCoding.Release.csproj create mode 100644 tools/GuidedCoding.Release/Manifests.cs create mode 100644 tools/GuidedCoding.Release/Processes.cs create mode 100644 tools/GuidedCoding.Release/Program.cs create mode 100644 tools/GuidedCoding.Release/ReleaseOptions.cs create mode 100644 tools/GuidedCoding.Release/ReleaseResult.cs create mode 100644 tools/GuidedCoding.Release/ReleaseType.cs create mode 100644 tools/GuidedCoding.Release/ReleaseWorkflow.cs create mode 100644 tools/GuidedCoding.Release/SemanticVersion.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dab3d7..12b7ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Guided Coding are documented here. -## [2.0.0] - Unreleased +## [Unreleased] - Add UTC timestamps to plan and Plan Deviations filenames. - Treat `ai-plans/` as an append-only, event-sourced decision record. diff --git a/GuidedCoding.slnx b/GuidedCoding.slnx index 592b026..d2d332a 100644 --- a/GuidedCoding.slnx +++ b/GuidedCoding.slnx @@ -8,6 +8,12 @@ - - + + + + + + + + diff --git a/tests/GuidedCoding.Release.Tests/ChangelogTests.cs b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs new file mode 100644 index 0000000..4c85d82 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs @@ -0,0 +1,53 @@ +using System; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class ChangelogTests +{ + private static readonly SemanticVersion Version = new (1, 1, 0); + private static readonly DateOnly Date = new (2026, 9, 26); + + [Fact] + public void MovesTheUnreleasedEntriesIntoTheReleasedVersion() + { + const string changelog = + "# Changelog\n\n## [Unreleased]\n\n- Add a skill.\n- Fix a typo.\n\n## [1.0.0] - 2026-01-01\n\n- Start.\n"; + + var released = Changelog.Release(changelog, Version, Date); + + Assert.Equal( + "# Changelog\n\n## [Unreleased]\n\n## [1.1.0] - 2026-09-26\n\n- Add a skill.\n- Fix a typo.\n\n" + + "## [1.0.0] - 2026-01-01\n\n- Start.\n", + released + ); + } + + [Fact] + public void NormalizesLineEndings() + { + var released = Changelog.Release("## [Unreleased]\r\n\r\n- Add a skill.\r\n", Version, Date); + + Assert.Equal("## [Unreleased]\n\n## [1.1.0] - 2026-09-26\n\n- Add a skill.\n", released); + } + + [Fact] + public void RequiresAnUnreleasedSection() + { + var exception = Assert.Throws( + () => Changelog.Release("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n- Start.\n", Version, Date) + ); + + Assert.Equal("CHANGELOG.md has no '## [Unreleased]' section.", exception.Message); + } + + [Theory] + [InlineData("# Changelog\n\n## [Unreleased]\n")] + [InlineData("# Changelog\n\n## [Unreleased]\n\n \n## [1.0.0] - 2026-01-01\n\n- Start.\n")] + public void RequiresUnreleasedEntries(string changelog) + { + var exception = Assert.Throws(() => Changelog.Release(changelog, Version, Date)); + + Assert.StartsWith("CHANGELOG.md has no entries under '## [Unreleased]'.", exception.Message); + } +} diff --git a/tests/GuidedCoding.Release.Tests/ConventionalCommitsTests.cs b/tests/GuidedCoding.Release.Tests/ConventionalCommitsTests.cs new file mode 100644 index 0000000..0ed01b3 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ConventionalCommitsTests.cs @@ -0,0 +1,52 @@ +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class ConventionalCommitsTests +{ + [Theory] + [InlineData("feat!: rename a skill")] + [InlineData("fix(setup)!: drop an option")] + [InlineData("refactor!: restructure the plugin")] + [InlineData("docs: explain a skill\n\nBREAKING CHANGE: the skill was renamed")] + [InlineData("fix: correct a typo\n\nBREAKING-CHANGE: the output moved")] + [InlineData("feat: add a skill\r\n\r\nBREAKING CHANGE: the skill replaces another one")] + public void ClassifiesBreakingChangesAsMajor(string message) + { + Assert.Equal(ReleaseType.Major, ConventionalCommits.Classify(message)); + } + + [Theory] + [InlineData("feat: add a skill")] + [InlineData("feat(setup): support another agent")] + [InlineData("Feat: add a skill")] + [InlineData("feat: add a skill\n\nThe body mentions BREAKING CHANGE: only inline.")] + public void ClassifiesFeaturesAsMinor(string message) + { + Assert.Equal(ReleaseType.Minor, ConventionalCommits.Classify(message)); + } + + [Theory] + [InlineData("fix: correct a typo")] + [InlineData("fix(write-plan): clarify a step")] + [InlineData("perf: shorten a skill")] + [InlineData("fix: correct a typo\n\nbreaking change: footers must be uppercase")] + public void ClassifiesFixesAndPerformanceImprovementsAsPatch(string message) + { + Assert.Equal(ReleaseType.Patch, ConventionalCommits.Classify(message)); + } + + [Theory] + [InlineData("docs: explain a skill")] + [InlineData("chore(release): 1.0.0")] + [InlineData("test: cover a skill")] + [InlineData("Merge pull request #1 from feO2x/guided-learning")] + [InlineData("feat:missing space")] + [InlineData("feat add a skill")] + [InlineData("Add a skill\n\nBREAKING CHANGE: the subject is not conventional")] + [InlineData("")] + public void ClassifiesOtherCommitsAsNone(string message) + { + Assert.Equal(ReleaseType.None, ConventionalCommits.Classify(message)); + } +} diff --git a/tests/GuidedCoding.Release.Tests/GitTests.cs b/tests/GuidedCoding.Release.Tests/GitTests.cs new file mode 100644 index 0000000..795b7b2 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/GitTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class GitTests +{ + [Fact] + public void FindsTheHighestReleaseTagNumerically() + { + using var repository = ReleaseRepository.Create(); + foreach (var tag in new[] { "v1.9.0", "v1.10.0", "latest", "v2.0", "v3.0.0-beta.1", "4.0.0" }) + { + repository.Commit($"fix: prepare {tag}"); + repository.Tag(tag); + } + + var version = new Git(repository.WorkingDirectory).FindLatestReleaseVersion(); + + Assert.Equal(new SemanticVersion(1, 10, 0), version); + } + + [Fact] + public void IgnoresReleaseTagsOutsideTheCurrentBranch() + { + using var repository = ReleaseRepository.Create(); + repository.Git("switch", "--create", "experiment"); + repository.Commit("feat!: try something"); + repository.Tag("v2.0.0"); + repository.Git("switch", "main"); + + var version = new Git(repository.WorkingDirectory).FindLatestReleaseVersion(); + + Assert.Equal(new SemanticVersion(1, 0, 0), version); + } + + [Fact] + public void FindsNoReleaseWithoutReleaseTags() + { + using var repository = ReleaseRepository.Create(released: false); + + var version = new Git(repository.WorkingDirectory).FindLatestReleaseVersion(); + + Assert.Null(version); + } + + [Fact] + public void ListsCompleteCommitMessagesSinceTheLastReleaseWithoutMerges() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("feat: add a skill\n\nThe skill explains itself."); + repository.Git("switch", "--create", "feature"); + repository.Commit("fix: correct a typo"); + repository.Git("switch", "main"); + repository.Git("merge", "--no-ff", "-m", "Merge branch 'feature'", "feature"); + + var messages = new Git(repository.WorkingDirectory).CommitMessagesSince(new SemanticVersion(1, 0, 0)); + + Assert.Equal( + ["feat: add a skill\n\nThe skill explains itself.", "fix: correct a typo"], + messages.Order(StringComparer.Ordinal) + ); + } + + [Fact] + public void ListsAllCommitMessagesWithoutARelease() + { + using var repository = ReleaseRepository.Create(released: false); + repository.Commit("feat: add a skill"); + + var messages = new Git(repository.WorkingDirectory).CommitMessagesSince(null); + + Assert.Equal(["feat: add a skill", "chore: initial commit"], messages); + } +} diff --git a/tests/GuidedCoding.Release.Tests/GuidedCoding.Release.Tests.csproj b/tests/GuidedCoding.Release.Tests/GuidedCoding.Release.Tests.csproj new file mode 100644 index 0000000..93c47c5 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/GuidedCoding.Release.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/GuidedCoding.Release.Tests/ManifestsTests.cs b/tests/GuidedCoding.Release.Tests/ManifestsTests.cs new file mode 100644 index 0000000..8fbebc6 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ManifestsTests.cs @@ -0,0 +1,38 @@ +using System; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class ManifestsTests +{ + private static readonly SemanticVersion Version = new(2, 1, 0); + + [Fact] + public void ReplacesOnlyTheVersionValue() + { + const string manifest = + "{\n \"name\": \"guided-coding\",\n \"version\" : \"2.0.0\",\n \"description\": \"Since version 1.0.0\"\n}\n"; + + var updated = Manifests.SetVersion("plugin.json", manifest, Version); + + Assert.Equal( + "{\n \"name\": \"guided-coding\",\n \"version\" : \"2.1.0\",\n \"description\": \"Since version 1.0.0\"\n}\n", + updated + ); + } + + [Theory] + [InlineData("{ \"name\": \"guided-coding\" }", 0)] + [InlineData("{ \"version\": \"1.0.0\", \"plugins\": [{ \"version\": \"1.0.0\" }] }", 2)] + public void RequiresExactlyOneVersionProperty(string manifest, int count) + { + var exception = Assert.Throws( + () => Manifests.SetVersion("plugin.json", manifest, Version) + ); + + Assert.Equal( + $"plugin.json must contain exactly one \"version\" property, but contains {count}.", + exception.Message + ); + } +} diff --git a/tests/GuidedCoding.Release.Tests/RecordingCommandRunner.cs b/tests/GuidedCoding.Release.Tests/RecordingCommandRunner.cs new file mode 100644 index 0000000..47b029d --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/RecordingCommandRunner.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace GuidedCoding.Release.Tests; + +// Stands in for the dotnet and gh commands, which are too slow or too far-reaching to run in tests. +internal sealed class RecordingCommandRunner : ICommandRunner +{ + private readonly List _commands = []; + + public IReadOnlyList Commands => _commands; + + public string? FailingCommand { get; set; } + + public Action? OnRun { get; set; } + + public void Run(Command command) + { + var commandLine = command.ToString(); + _commands.Add(commandLine); + OnRun?.Invoke(commandLine); + + if (commandLine == FailingCommand) + { + throw new InvalidOperationException($"'{commandLine}' failed with exit code 1."); + } + } +} diff --git a/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs new file mode 100644 index 0000000..3104e3f --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs @@ -0,0 +1,42 @@ +using System; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class ReleaseOptionsTests +{ + [Fact] + public void ProposesTheVersionAndReleasesByDefault() + { + Assert.Equal(new ReleaseOptions(null, false), ReleaseOptions.Parse([])); + } + + [Theory] + [InlineData("--version", "2.1.0", "--dry-run")] + [InlineData("--dry-run", "--version", "2.1.0")] + public void ParsesTheRequestedVersionAndDryRun(params string[] args) + { + Assert.Equal(new ReleaseOptions(new SemanticVersion(2, 1, 0), true), ReleaseOptions.Parse(args)); + } + + [Theory] + [InlineData("--version")] + [InlineData("--dry-run", "--dry-run")] + [InlineData("--version", "1.0.0", "--version", "2.0.0")] + [InlineData("--tag", "v1.0.0")] + public void RejectsUnknownOrIncompleteArguments(params string[] args) + { + var exception = Assert.Throws(() => ReleaseOptions.Parse(args)); + + Assert.Equal( + "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run]", + exception.Message + ); + } + + [Fact] + public void RejectsInvalidVersions() + { + Assert.Throws(() => ReleaseOptions.Parse(["--version", "v2.1.0"])); + } +} diff --git a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs new file mode 100644 index 0000000..efd6be6 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace GuidedCoding.Release.Tests; + +// A throwaway git repository with the release-relevant files and a local bare repository as origin. +internal sealed class ReleaseRepository : IDisposable +{ + public const string InitialChangelog = "# Changelog\n\n## [Unreleased]\n\n- Add a skill.\n"; + + public static readonly IReadOnlyList ManifestPaths = + [ + "plugin.json", + "claude-plugin/.claude-plugin/plugin.json", + ".claude-plugin/marketplace.json" + ]; + + private readonly string _root; + + static ReleaseRepository() + { + // Keep the developer's git configuration (signing, hooks, identity) out of the tests. + var configPath = Path.Combine(Path.GetTempPath(), "guided-coding-release-tests.gitconfig"); + File.WriteAllText(configPath, "[user]\n\tname = Release Tests\n\temail = release-tests@example.com\n"); + Environment.SetEnvironmentVariable("GIT_CONFIG_GLOBAL", configPath); + Environment.SetEnvironmentVariable("GIT_CONFIG_NOSYSTEM", "1"); + } + + private ReleaseRepository(string root) + { + _root = root; + WorkingDirectory = Path.Combine(root, "work"); + OriginDirectory = Path.Combine(root, "origin.git"); + } + + public string WorkingDirectory { get; } + + public string Head => Git("rev-parse", "HEAD").Trim(); + + public bool IsClean => Git("status", "--porcelain").Length == 0; + + private string OriginDirectory { get; } + + // Creates a repository at version 1.0.0 whose initial commit is tagged v1.0.0 unless released is false. + public static ReleaseRepository Create(bool released = true) + { + var repository = new ReleaseRepository( + Path.Combine(Path.GetTempPath(), "guided-coding-release-tests", Guid.NewGuid().ToString("N")) + ); + Directory.CreateDirectory(repository.WorkingDirectory); + RunGit(repository._root, "init", "--bare", "--initial-branch=main", repository.OriginDirectory); + repository.Git("init", "--initial-branch=main"); + + foreach (var path in ManifestPaths) + { + repository.WriteFile(path, Manifest(path, "1.0.0")); + } + + repository.WriteFile("CHANGELOG.md", InitialChangelog); + repository.Commit("chore: initial commit"); + if (released) + { + repository.Tag("v1.0.0"); + } + + repository.Git("remote", "add", "origin", repository.OriginDirectory); + repository.Git("push", "--set-upstream", "origin", "main", "--tags"); + return repository; + } + + public static string Manifest(string path, string version) => + path switch + { + "plugin.json" => + $$""" + { + "name": "guided-coding", + "version": "{{version}}", + "description": "Skills for Guided Coding." + } + + """, + "claude-plugin/.claude-plugin/plugin.json" => + $$""" + { + "name": "guided-coding", + "version": "{{version}}", + "skills": "./claude-skills" + } + + """, + ".claude-plugin/marketplace.json" => + $$""" + { + "name": "guided-coding", + "plugins": [ + { + "name": "guided-coding", + "source": "./claude-plugin", + "version": "{{version}}" + } + ] + } + + """, + _ => throw new ArgumentOutOfRangeException(nameof(path), path, "Unknown manifest.") + }; + + public string Git(params string[] arguments) => RunGit(WorkingDirectory, arguments); + + public string Origin(params string[] arguments) => RunGit(OriginDirectory, arguments); + + public void Commit(string message) + { + Git("add", "--all"); + Git("commit", "--allow-empty", "-m", message); + } + + public void Tag(string tag) => Git("tag", "-a", tag, "-m", tag); + + public void PushCommitFromAnotherClone(string message) + { + var clone = Path.Combine(_root, $"clone-{Guid.NewGuid():N}"); + RunGit(_root, "clone", OriginDirectory, clone); + RunGit(clone, "commit", "--allow-empty", "-m", message); + RunGit(clone, "push", "origin", "main"); + } + + public void RejectPushes() + { + var hook = Path.Combine(OriginDirectory, "hooks", "pre-receive"); + File.WriteAllText(hook, "#!/bin/sh\necho 'Pushes are rejected.' >&2\nexit 1\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(hook, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + public string ReadFile(string relativePath) => File.ReadAllText(Path.Combine(WorkingDirectory, relativePath)); + + public void WriteFile(string relativePath, string content) + { + var path = Path.Combine(WorkingDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + public void Dispose() + { + if (!Directory.Exists(_root)) + { + return; + } + + // Git marks its object files as read-only, which prevents deleting them on Windows. + foreach (var file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(_root, recursive: true); + } + + private static string RunGit(string workingDirectory, params string[] arguments) => + Processes.Capture(workingDirectory, "git", arguments); +} diff --git a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs new file mode 100644 index 0000000..1986699 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs @@ -0,0 +1,394 @@ +using System; +using System.IO; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class ReleaseWorkflowTests +{ + private const string GeneratorCheck = + "dotnet run --project tools/GuidedCoding.ClaudeGenerator --configuration Release -- --check"; + private const string Tests = "dotnet test --configuration Release"; + private const string PublishDryRun = "gh skill publish --dry-run"; + private const string AddTopic = "gh repo edit --add-topic agent-skills"; + + private static readonly DateOnly Today = new(2026, 9, 26); + + private readonly RecordingCommandRunner _commands = new(); + private readonly StringWriter _output = new(); + + private string Output => _output.ToString().ReplaceLineEndings("\n"); + + [Fact] + public void ReleasesTheNextVersionDerivedFromConventionalCommits() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + repository.Commit("feat: add a skill"); + + var result = Release(repository); + + Assert.Equal(ReleaseResult.Released, result); + foreach (var path in ReleaseRepository.ManifestPaths) + { + Assert.Equal(ReleaseRepository.Manifest(path, "1.1.0"), repository.ReadFile(path)); + } + + Assert.Equal( + "# Changelog\n\n## [Unreleased]\n\n## [1.1.0] - 2026-09-26\n\n- Add a skill.\n", + repository.ReadFile("CHANGELOG.md") + ); + Assert.Equal("chore(release): 1.1.0", repository.Git("log", "-1", "--format=%s").Trim()); + Assert.True(repository.IsClean); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "main").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.1.0^{commit}").Trim()); + Assert.Equal( + [GeneratorCheck, Tests, PublishDryRun, AddTopic, "gh skill publish --tag v1.1.0"], + _commands.Commands + ); + Assert.EndsWith("Pushed main and v1.1.0 to origin.\nReleased v1.1.0.\n", Output); + } + + [Theory] + [InlineData("fix: correct a typo", "1.0.1 (patch)")] + [InlineData("perf: shorten a skill", "1.0.1 (patch)")] + [InlineData("feat: add a skill", "1.1.0 (minor)")] + [InlineData("feat!: rename a skill", "2.0.0 (major)")] + [InlineData("refactor: restructure a skill\n\nBREAKING CHANGE: the skill was renamed", "2.0.0 (major)")] + public void ProposesTheLargestBumpOfAllCommits(string message, string expectedVersion) + { + using var repository = ReleaseRepository.Create(); + repository.Commit("docs: explain the change"); + repository.Commit(message); + + Release(repository, "--dry-run"); + + Assert.Contains($"Next version: {expectedVersion}\n", Output); + } + + [Fact] + public void DryRunReportsTheProposalWithoutChangingAnything() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("feat: add a skill"); + repository.Commit("docs: explain the skill"); + var head = repository.Head; + + var result = Release(repository, "--dry-run"); + + Assert.Equal(ReleaseResult.DryRun, result); + Assert.Equal( + """ + Last release: v1.0.0 + Commits to release: + none docs: explain the skill + minor feat: add a skill + Next version: 1.1.0 (minor) + Dry run: nothing was changed. + + """, + Output + ); + AssertNothingChanged(repository, head); + } + + [Fact] + public void UsesTheRequestedVersionInsteadOfTheProposal() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + + var result = Release(repository, "--version 3.0.0"); + + Assert.Equal(ReleaseResult.Released, result); + Assert.Contains("Next version: 3.0.0 (requested)\n", Output); + Assert.Equal(ReleaseRepository.Manifest("plugin.json", "3.0.0"), repository.ReadFile("plugin.json")); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "v3.0.0^{commit}").Trim()); + } + + [Fact] + public void ReleasesTheFirstVersionWhenItIsRequested() + { + using var repository = ReleaseRepository.Create(released: false); + + var result = Release(repository, "--version 1.0.0"); + + Assert.Equal(ReleaseResult.Released, result); + Assert.StartsWith("Last release: none\n", Output); + foreach (var path in ReleaseRepository.ManifestPaths) + { + Assert.Equal(ReleaseRepository.Manifest(path, "1.0.0"), repository.ReadFile(path)); + } + + Assert.Contains("## [1.0.0] - 2026-09-26\n", repository.ReadFile("CHANGELOG.md")); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.0.0^{commit}").Trim()); + } + + [Fact] + public void RequiresAVersionForTheFirstRelease() + { + using var repository = ReleaseRepository.Create(released: false); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Contains("Pass --version", exception.Message); + AssertNothingChanged(repository, head); + } + + [Fact] + public void RefusesToReleaseWhenNoCommitRequiresIt() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("docs: explain a skill"); + repository.Commit("chore: tidy up"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith("No commit requires a release.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Fact] + public void RefusesToReleaseWithoutNewCommits() + { + using var repository = ReleaseRepository.Create(); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository, "--version 2.0.0")); + + Assert.Equal("There are no commits since v1.0.0.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Theory] + [InlineData("0.9.0")] + [InlineData("1.0.0")] + public void RefusesARequestedVersionThatIsNotNewerThanTheLastRelease(string version) + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository, $"--version {version}")); + + Assert.Equal($"Version {version} must be greater than the last release 1.0.0.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Fact] + public void RefusesToReuseAnExistingTag() + { + using var repository = ReleaseRepository.Create(); + repository.Git("switch", "--create", "experiment"); + repository.Commit("fix: try something"); + repository.Tag("v1.0.1"); + repository.Git("switch", "main"); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal("Tag v1.0.1 already exists.", exception.Message); + Assert.True(repository.IsClean); + Assert.Equal(head, repository.Head); + Assert.Empty(_commands.Commands); + } + + [Fact] + public void RefusesToReleaseFromAnotherBranch() + { + using var repository = ReleaseRepository.Create(); + repository.Git("switch", "--create", "feature"); + repository.Commit("feat: add a skill"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal("Releases are made from main, but the current branch is 'feature'.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Fact] + public void RefusesToReleaseWithUncommittedChanges() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("feat: add a skill"); + repository.WriteFile("notes.md", "Work in progress"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal("Commit or stash your changes before releasing.", exception.Message); + Assert.Equal(head, repository.Head); + Assert.Equal(ReleaseRepository.Manifest("plugin.json", "1.0.0"), repository.ReadFile("plugin.json")); + Assert.Empty(_commands.Commands); + } + + [Fact] + public void RefusesToReleaseWhenMainIsBehindOrigin() + { + using var repository = ReleaseRepository.Create(); + repository.PushCommitFromAnotherClone("fix: correct a typo elsewhere"); + repository.Commit("feat: add a skill"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal("main is 1 commit(s) behind origin/main. Pull before releasing.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Fact] + public void RefusesToReleaseWithoutChangelogEntries() + { + using var repository = ReleaseRepository.Create(); + repository.WriteFile("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n- Start.\n"); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith("CHANGELOG.md has no entries under '## [Unreleased]'.", exception.Message); + AssertNothingChanged(repository, head); + } + + [Theory] + [InlineData("n")] + [InlineData("")] + public void ChangesNothingWhenTheReleaseIsNotConfirmed(string answer) + { + using var repository = ReleaseRepository.Create(); + repository.Commit("feat: add a skill"); + var head = repository.Head; + + var result = Release(repository, answer: answer); + + Assert.Equal(ReleaseResult.Cancelled, result); + Assert.EndsWith("Release v1.1.0? [y/N] Release cancelled.\n", Output); + AssertNothingChanged(repository, head); + } + + [Fact] + public void ValidatesTheBumpedFilesBeforeCommitting() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + string? manifestDuringTests = null; + string? headDuringTests = null; + _commands.OnRun = command => + { + if (command == Tests) + { + manifestDuringTests = repository.ReadFile("plugin.json"); + headDuringTests = repository.Head; + } + }; + + Release(repository); + + Assert.Equal(ReleaseRepository.Manifest("plugin.json", "1.0.1"), manifestDuringTests); + Assert.Equal(head, headDuringTests); + } + + [Fact] + public void RevertsTheReleaseChangesWhenValidationFails() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + _commands.FailingCommand = Tests; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal( + $"The release changes were reverted. '{Tests}' failed with exit code 1.", + exception.Message + ); + Assert.True(repository.IsClean); + Assert.Equal(head, repository.Head); + Assert.Equal("v1.0.0", repository.Git("tag", "--list").Trim()); + Assert.Equal([GeneratorCheck, Tests], _commands.Commands); + } + + [Fact] + public void ExplainsHowToFinishWhenPushingFails() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + repository.RejectPushes(); + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith( + "The release commit and v1.0.1 were created locally, but pushing failed. " + + "Run 'git push --atomic origin main v1.0.1' and 'gh skill publish --tag v1.0.1' to finish the release.", + exception.Message + ); + Assert.Equal(repository.Head, repository.Git("rev-parse", "v1.0.1^{commit}").Trim()); + Assert.Equal("v1.0.0", repository.Origin("tag", "--list").Trim()); + Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands); + } + + [Fact] + public void PublishesAfterTheTagReachedOrigin() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + string? originTagsDuringPublish = null; + _commands.OnRun = command => + { + if (command == "gh skill publish --tag v1.0.1") + { + originTagsDuringPublish = repository.Origin("tag", "--list").ReplaceLineEndings("\n"); + } + }; + + Release(repository); + + Assert.Equal("v1.0.0\nv1.0.1\n", originTagsDuringPublish); + } + + [Fact] + public void ExplainsHowToFinishWhenPublishingFails() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + _commands.FailingCommand = "gh skill publish --tag v1.0.1"; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith( + "v1.0.1 was pushed, but publishing failed. Run 'gh skill publish --tag v1.0.1' to finish the release.", + exception.Message + ); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.0.1^{commit}").Trim()); + } + + private ReleaseResult Release(ReleaseRepository repository, string arguments = "", string answer = "y") + { + var workflow = new ReleaseWorkflow( + repository.WorkingDirectory, + _commands, + new StringReader(answer + "\n"), + _output, + Today + ); + + return workflow.Run(ReleaseOptions.Parse(arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries))); + } + + private void AssertNothingChanged(ReleaseRepository repository, string head) + { + Assert.True(repository.IsClean); + Assert.Equal(head, repository.Head); + Assert.Equal(ReleaseRepository.Manifest("plugin.json", "1.0.0"), repository.ReadFile("plugin.json")); + Assert.All( + repository.Git("tag", "--list").Split('\n', StringSplitOptions.RemoveEmptyEntries), + tag => Assert.Equal("v1.0.0", tag) + ); + Assert.Empty(_commands.Commands); + } +} diff --git a/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs b/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs new file mode 100644 index 0000000..2c77ea7 --- /dev/null +++ b/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs @@ -0,0 +1,85 @@ +using System; +using Xunit; + +namespace GuidedCoding.Release.Tests; + +public sealed class SemanticVersionTests +{ + [Theory] + [InlineData("0.0.0", 0, 0, 0)] + [InlineData("1.2.3", 1, 2, 3)] + [InlineData("10.20.30", 10, 20, 30)] + public void ParsesVersions(string value, int major, int minor, int patch) + { + Assert.Equal(new SemanticVersion(major, minor, patch), SemanticVersion.Parse(value)); + } + + [Theory] + [InlineData("")] + [InlineData("1")] + [InlineData("1.2")] + [InlineData("1.2.3.4")] + [InlineData("v1.2.3")] + [InlineData("01.2.3")] + [InlineData("1.2.03")] + [InlineData("1.2.3-beta.1")] + [InlineData("1.2.3+build")] + [InlineData("1.-2.3")] + [InlineData(" 1.2.3")] + [InlineData("1.2.99999999999")] + public void RejectsInvalidVersions(string value) + { + var exception = Assert.Throws(() => SemanticVersion.Parse(value)); + + Assert.Equal($"'{value}' is not a version in the form MAJOR.MINOR.PATCH.", exception.Message); + } + + [Theory] + [InlineData("Patch", "1.2.4")] + [InlineData("Minor", "1.3.0")] + [InlineData("Major", "2.0.0")] + public void BumpsVersions(string releaseType, string expected) + { + var version = new SemanticVersion(1, 2, 3).Bump(Enum.Parse(releaseType)); + + Assert.Equal(expected, version.ToString()); + } + + [Fact] + public void RefusesToBumpWithoutAReleaseType() + { + Assert.Throws(() => new SemanticVersion(1, 2, 3).Bump(ReleaseType.None)); + } + + [Theory] + [InlineData("1.10.0", "1.9.0")] + [InlineData("2.0.0", "1.99.99")] + [InlineData("1.2.10", "1.2.9")] + public void ComparesVersionsNumerically(string greater, string lesser) + { + var greaterVersion = SemanticVersion.Parse(greater); + var lesserVersion = SemanticVersion.Parse(lesser); + + Assert.True(greaterVersion > lesserVersion); + Assert.True(greaterVersion >= lesserVersion); + Assert.True(lesserVersion < greaterVersion); + Assert.True(lesserVersion <= greaterVersion); + Assert.True(greaterVersion >= SemanticVersion.Parse(greater)); + } + + [Fact] + public void UsesAVPrefixForTags() + { + Assert.Equal("v1.2.3", new SemanticVersion(1, 2, 3).ToTag()); + } + + [Theory] + [InlineData("v1.2.3", true)] + [InlineData("1.2.3", false)] + [InlineData("v1.2", false)] + [InlineData("latest", false)] + public void ParsesOnlyReleaseTags(string tag, bool isReleaseTag) + { + Assert.Equal(isReleaseTag, SemanticVersion.TryParseTag(tag, out _)); + } +} diff --git a/tests/GuidedCoding.Tests/PackageValidationTests.cs b/tests/GuidedCoding.Tests/PackageValidationTests.cs index 05e9f51..30f4105 100644 --- a/tests/GuidedCoding.Tests/PackageValidationTests.cs +++ b/tests/GuidedCoding.Tests/PackageValidationTests.cs @@ -11,7 +11,6 @@ public sealed class PackageValidationTests { private const string ExplicitInvocation = "Run only when explicitly requested by the user."; private const string PluginName = "guided-coding"; - private const string Version = "2.0.0"; private static readonly string[] ExpectedPortableSkillNames = [ @@ -106,7 +105,10 @@ public void ManifestsUseTheSamePackageMetadata() AssertPluginMetadataEqual(portable, claude); Assert.Equal(PluginName, portable.GetProperty("name").GetString()); - Assert.Equal(Version, portable.GetProperty("version").GetString()); + Assert.Matches( + @"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$", + portable.GetProperty("version").GetString() + ); Assert.Equal( ReadStringArray(portable, "keywords"), ReadStringArray(claude, "keywords") diff --git a/tools/GuidedCoding.Release/Changelog.cs b/tools/GuidedCoding.Release/Changelog.cs new file mode 100644 index 0000000..a65ca6d --- /dev/null +++ b/tools/GuidedCoding.Release/Changelog.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace GuidedCoding.Release; + +public static class Changelog +{ + public const string FileName = "CHANGELOG.md"; + public const string UnreleasedHeading = "## [Unreleased]"; + + // Turns the Unreleased section into the released version and opens a new, empty Unreleased section. + public static string Release(string content, SemanticVersion version, DateOnly date) + { + var lines = content.ReplaceLineEndings("\n").Split('\n'); + var headingIndex = Array.FindIndex(lines, line => line.TrimEnd() == UnreleasedHeading); + if (headingIndex < 0) + { + throw new InvalidOperationException($"{FileName} has no '{UnreleasedHeading}' section."); + } + + var hasEntries = lines + .Skip(headingIndex + 1) + .TakeWhile(line => !line.StartsWith("## ", StringComparison.Ordinal)) + .Any(line => !string.IsNullOrWhiteSpace(line)); + if (!hasEntries) + { + throw new InvalidOperationException( + $"{FileName} has no entries under '{UnreleasedHeading}'. Describe the release there first." + ); + } + + var releasedHeading = $"## [{version}] - {date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}"; + return string.Join( + '\n', + lines[..headingIndex].Concat([UnreleasedHeading, "", releasedHeading]).Concat(lines[(headingIndex + 1)..]) + ); + } +} diff --git a/tools/GuidedCoding.Release/Command.cs b/tools/GuidedCoding.Release/Command.cs new file mode 100644 index 0000000..0b269b5 --- /dev/null +++ b/tools/GuidedCoding.Release/Command.cs @@ -0,0 +1,18 @@ +using System.Linq; + +namespace GuidedCoding.Release; + +public sealed record Command(string FileName, params string[] Arguments) +{ + public override string ToString() => string.Join(' ', Arguments.Prepend(FileName)); +} + +public interface ICommandRunner +{ + void Run(Command command); +} + +public sealed class ProcessCommandRunner(string workingDirectory) : ICommandRunner +{ + public void Run(Command command) => Processes.Run(workingDirectory, command.FileName, command.Arguments); +} diff --git a/tools/GuidedCoding.Release/ConventionalCommits.cs b/tools/GuidedCoding.Release/ConventionalCommits.cs new file mode 100644 index 0000000..9df1415 --- /dev/null +++ b/tools/GuidedCoding.Release/ConventionalCommits.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Text.RegularExpressions; + +namespace GuidedCoding.Release; + +public static partial class ConventionalCommits +{ + public static ReleaseType Classify(string message) + { + var lines = message.ReplaceLineEndings("\n").Split('\n'); + var subject = SubjectPattern().Match(lines[0]); + if (!subject.Success) + { + return ReleaseType.None; + } + + if (subject.Groups["breaking"].Success || lines.Skip(1).Any(BreakingChangeFooterPattern().IsMatch)) + { + return ReleaseType.Major; + } + + return subject.Groups["type"].Value.ToLowerInvariant() switch + { + "feat" => ReleaseType.Minor, + "fix" or "perf" => ReleaseType.Patch, + _ => ReleaseType.None + }; + } + + // Types are case-insensitive, but BREAKING CHANGE must be uppercase (Conventional Commits 1.0.0). + [GeneratedRegex(@"^(?[A-Za-z]+)(\([^()]*\))?(?!)?: \S")] + private static partial Regex SubjectPattern(); + + [GeneratedRegex("^BREAKING[ -]CHANGE: ")] + private static partial Regex BreakingChangeFooterPattern(); +} diff --git a/tools/GuidedCoding.Release/Git.cs b/tools/GuidedCoding.Release/Git.cs new file mode 100644 index 0000000..fd71fb5 --- /dev/null +++ b/tools/GuidedCoding.Release/Git.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace GuidedCoding.Release; + +public sealed class Git(string repositoryRoot) +{ + public const string MainBranch = "main"; + public const string Remote = "origin"; + + public string CurrentBranch() => Run("branch", "--show-current").Trim(); + + public bool HasUncommittedChanges() => Run("status", "--porcelain").Length > 0; + + public void FetchOrigin() => Run("fetch", "--tags", Remote); + + public int CountCommitsBehindOrigin() => + int.Parse(Run("rev-list", "--count", $"HEAD..{Remote}/{MainBranch}").Trim(), CultureInfo.InvariantCulture); + + // Only tags reachable from HEAD count, so a tag on another branch cannot become the base of a release. + public SemanticVersion? FindLatestReleaseVersion() + { + SemanticVersion? latest = null; + foreach (var tag in Lines(Run("tag", "--list", "v*", "--merged", "HEAD"))) + { + if (SemanticVersion.TryParseTag(tag, out var version) && (latest is null || version > latest.Value)) + { + latest = version; + } + } + + return latest; + } + + public bool TagExists(string tag) => Lines(Run("tag", "--list", tag)).Length > 0; + + public IReadOnlyList CommitMessagesSince(SemanticVersion? release) + { + var range = release is { } version ? $"{version.ToTag()}..HEAD" : "HEAD"; + return Run("log", "--no-merges", "--format=%B%x1e", range) + .Split('\x1e', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + public void Restore(IEnumerable paths) => Run(["checkout", "HEAD", "--", .. paths]); + + public void Commit(IEnumerable paths, string message) + { + Run(["add", "--", .. paths]); + Run("commit", "-m", message); + } + + public void CreateTag(string tag) => Run("tag", "-a", tag, "-m", tag); + + // Pushes the branch and the tag together, or neither of them. + public void Push(string tag) => Run("push", "--atomic", Remote, MainBranch, $"refs/tags/{tag}"); + + private string Run(params string[] arguments) => Processes.Capture(repositoryRoot, "git", arguments); + + private static string[] Lines(string output) => + output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/tools/GuidedCoding.Release/GuidedCoding.Release.csproj b/tools/GuidedCoding.Release/GuidedCoding.Release.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/tools/GuidedCoding.Release/GuidedCoding.Release.csproj @@ -0,0 +1 @@ + diff --git a/tools/GuidedCoding.Release/Manifests.cs b/tools/GuidedCoding.Release/Manifests.cs new file mode 100644 index 0000000..d93d7bc --- /dev/null +++ b/tools/GuidedCoding.Release/Manifests.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace GuidedCoding.Release; + +public static partial class Manifests +{ + public static readonly IReadOnlyList Paths = + [ + "plugin.json", + "claude-plugin/.claude-plugin/plugin.json", + ".claude-plugin/marketplace.json" + ]; + + // Replaces only the version value so that the rest of the file keeps its formatting. + public static string SetVersion(string path, string json, SemanticVersion version) + { + var matches = VersionPropertyPattern().Matches(json); + if (matches.Count != 1) + { + throw new InvalidOperationException( + $"{path} must contain exactly one \"version\" property, but contains {matches.Count}." + ); + } + + var value = matches[0].Groups["value"]; + return string.Concat(json.AsSpan(0, value.Index), version.ToString(), json.AsSpan(value.Index + value.Length)); + } + + [GeneratedRegex("\"version\"\\s*:\\s*\"(?[^\"]*)\"")] + private static partial Regex VersionPropertyPattern(); +} diff --git a/tools/GuidedCoding.Release/Processes.cs b/tools/GuidedCoding.Release/Processes.cs new file mode 100644 index 0000000..f09845e --- /dev/null +++ b/tools/GuidedCoding.Release/Processes.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace GuidedCoding.Release; + +public static class Processes +{ + public static string Capture(string workingDirectory, string fileName, IEnumerable arguments) + { + var startInfo = CreateStartInfo(workingDirectory, fileName, arguments); + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + + using var process = Start(startInfo); + var error = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + EnsureSuccess(process, startInfo, error.GetAwaiter().GetResult()); + return output; + } + + // Shares the console with the child process so that its output streams to the user. + public static void Run(string workingDirectory, string fileName, IEnumerable arguments) + { + var startInfo = CreateStartInfo(workingDirectory, fileName, arguments); + + using var process = Start(startInfo); + process.WaitForExit(); + EnsureSuccess(process, startInfo, error: ""); + } + + private static ProcessStartInfo CreateStartInfo( + string workingDirectory, + string fileName, + IEnumerable arguments + ) + { + var startInfo = new ProcessStartInfo(fileName) + { + WorkingDirectory = workingDirectory, + UseShellExecute = false + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + return startInfo; + } + + private static Process Start(ProcessStartInfo startInfo) => + Process.Start(startInfo) ?? + throw new InvalidOperationException($"Could not start '{startInfo.FileName}'."); + + private static void EnsureSuccess(Process process, ProcessStartInfo startInfo, string error) + { + if (process.ExitCode == 0) + { + return; + } + + var command = string.Join(' ', startInfo.ArgumentList.Prepend(startInfo.FileName)); + var details = error.Trim(); + throw new InvalidOperationException( + $"'{command}' failed with exit code {process.ExitCode}." + (details.Length > 0 ? $"\n{details}" : "") + ); + } +} diff --git a/tools/GuidedCoding.Release/Program.cs b/tools/GuidedCoding.Release/Program.cs new file mode 100644 index 0000000..d6798a3 --- /dev/null +++ b/tools/GuidedCoding.Release/Program.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +namespace GuidedCoding.Release; + +public static class Program +{ + public static int Main(string[] args) + { + try + { + var options = ReleaseOptions.Parse(args); + var repositoryRoot = FindRepositoryRoot(); + var workflow = new ReleaseWorkflow( + repositoryRoot, + new ProcessCommandRunner(repositoryRoot), + Console.In, + Console.Out, + DateOnly.FromDateTime(DateTime.UtcNow) + ); + + return workflow.Run(options) == ReleaseResult.Cancelled ? 1 : 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + } + + private static string FindRepositoryRoot() + { + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + for ( + var directory = new DirectoryInfo(start); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "plugin.json")) && + File.Exists(Path.Combine(directory.FullName, Changelog.FileName)) && + Directory.Exists(Path.Combine(directory.FullName, "skills")) + ) + { + return directory.FullName; + } + } + } + + throw new InvalidOperationException("Could not locate the Guided Coding repository root."); + } +} diff --git a/tools/GuidedCoding.Release/ReleaseOptions.cs b/tools/GuidedCoding.Release/ReleaseOptions.cs new file mode 100644 index 0000000..08f0b48 --- /dev/null +++ b/tools/GuidedCoding.Release/ReleaseOptions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace GuidedCoding.Release; + +public sealed record ReleaseOptions(SemanticVersion? Version, bool DryRun) +{ + private const string Usage = "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run]"; + + public static ReleaseOptions Parse(IReadOnlyList args) + { + SemanticVersion? version = null; + var dryRun = false; + + for (var index = 0; index < args.Count; index++) + { + switch (args[index]) + { + case "--dry-run" when !dryRun: + dryRun = true; + break; + case "--version" when version is null && index + 1 < args.Count: + version = SemanticVersion.Parse(args[++index]); + break; + default: + throw new InvalidOperationException(Usage); + } + } + + return new(version, dryRun); + } +} diff --git a/tools/GuidedCoding.Release/ReleaseResult.cs b/tools/GuidedCoding.Release/ReleaseResult.cs new file mode 100644 index 0000000..9b012e6 --- /dev/null +++ b/tools/GuidedCoding.Release/ReleaseResult.cs @@ -0,0 +1,8 @@ +namespace GuidedCoding.Release; + +public enum ReleaseResult +{ + Released, + DryRun, + Cancelled +} diff --git a/tools/GuidedCoding.Release/ReleaseType.cs b/tools/GuidedCoding.Release/ReleaseType.cs new file mode 100644 index 0000000..4c44843 --- /dev/null +++ b/tools/GuidedCoding.Release/ReleaseType.cs @@ -0,0 +1,10 @@ +namespace GuidedCoding.Release; + +// Ordered from the smallest to the largest version bump. +public enum ReleaseType +{ + None, + Patch, + Minor, + Major +} diff --git a/tools/GuidedCoding.Release/ReleaseWorkflow.cs b/tools/GuidedCoding.Release/ReleaseWorkflow.cs new file mode 100644 index 0000000..cf138b7 --- /dev/null +++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace GuidedCoding.Release; + +public sealed class ReleaseWorkflow( + string repositoryRoot, + ICommandRunner commands, + TextReader input, + TextWriter output, + DateOnly today +) +{ + private static readonly Command[] Validations = + [ + new( + "dotnet", + "run", + "--project", + "tools/GuidedCoding.ClaudeGenerator", + "--configuration", + "Release", + "--", + "--check" + ), + // The Release configuration keeps the build away from the Debug binaries of this running tool. + new("dotnet", "test", "--configuration", "Release"), + new("gh", "skill", "publish", "--dry-run") + ]; + + private readonly Git _git = new(repositoryRoot); + + public ReleaseResult Run(ReleaseOptions options) + { + EnsureReleasableCheckout(); + + var version = DetermineVersion(options.Version, _git.FindLatestReleaseVersion()); + var tag = version.ToTag(); + if (_git.TagExists(tag)) + { + throw new InvalidOperationException($"Tag {tag} already exists."); + } + + var changes = PrepareChanges(version); + if (options.DryRun) + { + output.WriteLine("Dry run: nothing was changed."); + return ReleaseResult.DryRun; + } + + output.Write($"Release {tag}? [y/N] "); + if (input.ReadLine()?.Trim().ToLowerInvariant() is not ("y" or "yes")) + { + output.WriteLine("Release cancelled."); + return ReleaseResult.Cancelled; + } + + ApplyAndValidate(changes); + _git.Commit(changes.Select(change => change.Path), $"chore(release): {version}"); + _git.CreateTag(tag); + Push(tag); + Publish(tag); + + output.WriteLine($"Released {tag}."); + return ReleaseResult.Released; + } + + private void EnsureReleasableCheckout() + { + var branch = _git.CurrentBranch(); + if (branch != Git.MainBranch) + { + throw new InvalidOperationException( + $"Releases are made from {Git.MainBranch}, but the current branch is '{branch}'." + ); + } + + if (_git.HasUncommittedChanges()) + { + throw new InvalidOperationException("Commit or stash your changes before releasing."); + } + + _git.FetchOrigin(); + var behind = _git.CountCommitsBehindOrigin(); + if (behind > 0) + { + throw new InvalidOperationException( + $"{Git.MainBranch} is {behind} commit(s) behind {Git.Remote}/{Git.MainBranch}. Pull before releasing." + ); + } + } + + private SemanticVersion DetermineVersion(SemanticVersion? requested, SemanticVersion? lastRelease) + { + var lastTag = lastRelease?.ToTag(); + var commits = _git.CommitMessagesSince(lastRelease); + if (commits.Count == 0) + { + throw new InvalidOperationException($"There are no commits since {lastTag}."); + } + + output.WriteLine($"Last release: {lastTag ?? "none"}"); + output.WriteLine("Commits to release:"); + var releaseType = ReleaseType.None; + foreach (var message in commits) + { + var commitReleaseType = ConventionalCommits.Classify(message); + releaseType = commitReleaseType > releaseType ? commitReleaseType : releaseType; + output.WriteLine($" {Label(commitReleaseType),-5} {message.Split('\n')[0]}"); + } + + if (requested is { } version) + { + if (lastRelease is { } last && version <= last) + { + throw new InvalidOperationException( + $"Version {version} must be greater than the last release {last}." + ); + } + + output.WriteLine($"Next version: {version} (requested)"); + return version; + } + + if (lastRelease is null) + { + throw new InvalidOperationException( + "There is no release tag yet. Pass --version to choose the first version." + ); + } + + if (releaseType == ReleaseType.None) + { + throw new InvalidOperationException( + "No commit requires a release. Only feat, fix, perf, and breaking changes trigger one." + ); + } + + var next = lastRelease.Value.Bump(releaseType); + output.WriteLine($"Next version: {next} ({Label(releaseType)})"); + return next; + } + + // Computes every file change up front so that problems surface before anything is written. + private List PrepareChanges(SemanticVersion version) + { + var changes = Manifests + .Paths + .Select(path => new FileChange(path, Manifests.SetVersion(path, ReadFile(path), version))) + .ToList(); + changes.Add( + new FileChange(Changelog.FileName, Changelog.Release(ReadFile(Changelog.FileName), version, today)) + ); + return changes; + } + + private void ApplyAndValidate(IReadOnlyList changes) + { + try + { + foreach (var change in changes) + { + File.WriteAllText(Path.Combine(repositoryRoot, change.Path), change.Content); + } + + foreach (var command in Validations) + { + commands.Run(command); + } + } + catch (Exception exception) + { + _git.Restore(changes.Select(change => change.Path)); + throw new InvalidOperationException( + $"The release changes were reverted. {exception.Message}", + exception + ); + } + } + + private void Push(string tag) + { + try + { + _git.Push(tag); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"The release commit and {tag} were created locally, but pushing failed. " + + $"Run 'git push --atomic {Git.Remote} {Git.MainBranch} {tag}' and " + + $"'gh skill publish --tag {tag}' to finish the release. {exception.Message}", + exception + ); + } + + output.WriteLine($"Pushed {Git.MainBranch} and {tag} to {Git.Remote}."); + } + + private void Publish(string tag) + { + try + { + commands.Run(new("gh", "repo", "edit", "--add-topic", "agent-skills")); + commands.Run(new("gh", "skill", "publish", "--tag", tag)); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"{tag} was pushed, but publishing failed. Run 'gh skill publish --tag {tag}' to finish the release. " + + exception.Message, + exception + ); + } + } + + private string ReadFile(string relativePath) => File.ReadAllText(Path.Combine(repositoryRoot, relativePath)); + + private static string Label(ReleaseType releaseType) => releaseType.ToString().ToLowerInvariant(); + + private sealed record FileChange(string Path, string Content); +} diff --git a/tools/GuidedCoding.Release/SemanticVersion.cs b/tools/GuidedCoding.Release/SemanticVersion.cs new file mode 100644 index 0000000..d3a6094 --- /dev/null +++ b/tools/GuidedCoding.Release/SemanticVersion.cs @@ -0,0 +1,83 @@ +using System; +using System.Globalization; + +namespace GuidedCoding.Release; + +// Releases use plain MAJOR.MINOR.PATCH versions without pre-release or build metadata. +public readonly record struct SemanticVersion(int Major, int Minor, int Patch) : IComparable +{ + private const string TagPrefix = "v"; + + public int CompareTo(SemanticVersion other) + { + var major = Major.CompareTo(other.Major); + if (major != 0) + { + return major; + } + + var minor = Minor.CompareTo(other.Minor); + return minor != 0 ? minor : Patch.CompareTo(other.Patch); + } + + public static SemanticVersion Parse(string value) => + TryParse(value, out var version) ? + version : + throw new FormatException($"'{value}' is not a version in the form MAJOR.MINOR.PATCH."); + + public static bool TryParse(string value, out SemanticVersion version) + { + var parts = value.Split('.'); + if ( + parts.Length == 3 && + TryParseNumber(parts[0], out var major) && + TryParseNumber(parts[1], out var minor) && + TryParseNumber(parts[2], out var patch) + ) + { + version = new (major, minor, patch); + return true; + } + + version = default; + return false; + } + + public static bool TryParseTag(string tag, out SemanticVersion version) + { + if (tag.StartsWith(TagPrefix, StringComparison.Ordinal)) + { + return TryParse(tag[TagPrefix.Length..], out version); + } + + version = default; + return false; + } + + public SemanticVersion Bump(ReleaseType releaseType) => + releaseType switch + { + ReleaseType.Major => new (Major + 1, 0, 0), + ReleaseType.Minor => new (Major, Minor + 1, 0), + ReleaseType.Patch => new (Major, Minor, Patch + 1), + _ => throw new ArgumentOutOfRangeException(nameof(releaseType), releaseType, "Nothing to bump.") + }; + + public string ToTag() => TagPrefix + this; + + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}"); + + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; + + // SemVer forbids leading zeros in numeric identifiers. + private static bool TryParseNumber(string value, out int number) => + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out number) && + (value.Length == 1 || value[0] != '0'); +} From 1150d51280eebcfc697d9867e533c51c6de0a26a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:18:47 +0200 Subject: [PATCH 03/13] docs: document the release process and commit conventions Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 +++++++++++--- README.md | 20 ++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89b7d89..57d2d7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,16 +15,24 @@ This repository contains skills for Guided Coding. The root package supports the ## Manifests and versions -Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and release tags. +- Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `vMAJOR.MINOR.PATCH` release tags. +- Do not bump versions manually. Between releases, the manifests hold the last released version. Release with `dotnet run --project tools/GuidedCoding.Release`, which derives the next version from the commits since the last release tag, updates the manifests and `CHANGELOG.md`, validates, commits, tags, pushes, and runs `gh skill publish`. +- Describe user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. -Use Conventional Commits messages. +## Commit messages + +Use Conventional Commits messages. The commit type decides the next version: + +- `feat` releases a minor version, `fix` and `perf` release a patch version. +- `!` after the type or a `BREAKING CHANGE:` footer releases a major version. Removing or renaming a skill is a breaking change. +- Other types, such as `docs`, `test`, `refactor`, and `chore`, do not trigger a release. Changes to shipped skill content are therefore `feat` or `fix`, never `docs` or `chore`. ## Feedback loops - Regenerate the Claude adapter and run `dotnet test` after changing skills or manifests. - Run `dotnet run --project tools/GuidedCoding.ClaudeGenerator -- --check` to detect drift. - Run `claude plugin validate . --strict` when Claude Code is installed. -- Run `gh skill publish --dry-run` before publishing a release. +- Run `gh skill publish --dry-run` before publishing a release. The release tool runs it for you. ## This is your space diff --git a/README.md b/README.md index 935427c..e3f7b92 100644 --- a/README.md +++ b/README.md @@ -124,5 +124,21 @@ claude plugin validate . --strict gh skill publish --dry-run ``` -Keep `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, -`.claude-plugin/marketplace.json`, and the release tag on the same Semantic Version. +### Releasing + +Releases are made from an up-to-date `main` branch. Describe the changes under `## [Unreleased]` +in `CHANGELOG.md` first, then run: + +```sh +dotnet run --project tools/GuidedCoding.Release -- --dry-run # show the proposed version +dotnet run --project tools/GuidedCoding.Release # release the proposed version +dotnet run --project tools/GuidedCoding.Release -- --version 3.0.0 # release a specific version +``` + +The tool derives the next version from the Conventional Commits since the last `vMAJOR.MINOR.PATCH` +tag: breaking changes release a major version, `feat` a minor version, and `fix` or `perf` a patch +version. After you confirm, it updates `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, +`.claude-plugin/marketplace.json`, and `CHANGELOG.md`, runs the validations, commits, tags, pushes +`main` together with the tag, and publishes the Agent Skills with `gh skill publish`. Pushing the new +version to `main` releases the Claude Code plugin. The first release needs `--version` because there +is no release tag to start from. From 5fe0ff77472cc3d5e5d98a986263abb6db886352 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:37:41 +0200 Subject: [PATCH 04/13] build: support non-interactive releases in the release tool Add a --yes flag that skips the confirmation prompt so the tool can run without a terminal. Stop setting the agent-skills repository topic during a release: GitHub's workflow token lacks the admin permission it needs, and the topic is a one-time setup that is already in place. Co-Authored-By: Claude Opus 5.5 --- .../ReleaseOptionsTests.cs | 13 +++++++------ .../ReleaseWorkflowTests.cs | 16 ++++++++++++++-- tools/GuidedCoding.Release/ReleaseOptions.cs | 10 +++++++--- tools/GuidedCoding.Release/ReleaseWorkflow.cs | 10 +++++++--- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs index 3104e3f..9bcb572 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs @@ -8,20 +8,21 @@ public sealed class ReleaseOptionsTests [Fact] public void ProposesTheVersionAndReleasesByDefault() { - Assert.Equal(new ReleaseOptions(null, false), ReleaseOptions.Parse([])); + Assert.Equal(new ReleaseOptions(null, false, false), ReleaseOptions.Parse([])); } [Theory] - [InlineData("--version", "2.1.0", "--dry-run")] - [InlineData("--dry-run", "--version", "2.1.0")] - public void ParsesTheRequestedVersionAndDryRun(params string[] args) + [InlineData("--version", "2.1.0", "--dry-run", "--yes")] + [InlineData("--yes", "--dry-run", "--version", "2.1.0")] + public void ParsesAllOptionsInAnyOrder(params string[] args) { - Assert.Equal(new ReleaseOptions(new SemanticVersion(2, 1, 0), true), ReleaseOptions.Parse(args)); + Assert.Equal(new ReleaseOptions(new SemanticVersion(2, 1, 0), true, true), ReleaseOptions.Parse(args)); } [Theory] [InlineData("--version")] [InlineData("--dry-run", "--dry-run")] + [InlineData("--yes", "--yes")] [InlineData("--version", "1.0.0", "--version", "2.0.0")] [InlineData("--tag", "v1.0.0")] public void RejectsUnknownOrIncompleteArguments(params string[] args) @@ -29,7 +30,7 @@ public void RejectsUnknownOrIncompleteArguments(params string[] args) var exception = Assert.Throws(() => ReleaseOptions.Parse(args)); Assert.Equal( - "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run]", + "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run] [--yes]", exception.Message ); } diff --git a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs index 1986699..1f849ea 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs @@ -10,7 +10,6 @@ public sealed class ReleaseWorkflowTests "dotnet run --project tools/GuidedCoding.ClaudeGenerator --configuration Release -- --check"; private const string Tests = "dotnet test --configuration Release"; private const string PublishDryRun = "gh skill publish --dry-run"; - private const string AddTopic = "gh repo edit --add-topic agent-skills"; private static readonly DateOnly Today = new(2026, 9, 26); @@ -43,7 +42,7 @@ public void ReleasesTheNextVersionDerivedFromConventionalCommits() Assert.Equal(repository.Head, repository.Origin("rev-parse", "main").Trim()); Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.1.0^{commit}").Trim()); Assert.Equal( - [GeneratorCheck, Tests, PublishDryRun, AddTopic, "gh skill publish --tag v1.1.0"], + [GeneratorCheck, Tests, PublishDryRun, "gh skill publish --tag v1.1.0"], _commands.Commands ); Assert.EndsWith("Pushed main and v1.1.0 to origin.\nReleased v1.1.0.\n", Output); @@ -270,6 +269,19 @@ public void ChangesNothingWhenTheReleaseIsNotConfirmed(string answer) AssertNothingChanged(repository, head); } + [Fact] + public void ReleasesWithoutAskingWhenConfirmedUpFront() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("feat: add a skill"); + + var result = Release(repository, "--yes", answer: ""); + + Assert.Equal(ReleaseResult.Released, result); + Assert.DoesNotContain("[y/N]", Output); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.1.0^{commit}").Trim()); + } + [Fact] public void ValidatesTheBumpedFilesBeforeCommitting() { diff --git a/tools/GuidedCoding.Release/ReleaseOptions.cs b/tools/GuidedCoding.Release/ReleaseOptions.cs index 08f0b48..955962c 100644 --- a/tools/GuidedCoding.Release/ReleaseOptions.cs +++ b/tools/GuidedCoding.Release/ReleaseOptions.cs @@ -3,14 +3,15 @@ namespace GuidedCoding.Release; -public sealed record ReleaseOptions(SemanticVersion? Version, bool DryRun) +public sealed record ReleaseOptions(SemanticVersion? Version, bool DryRun, bool Confirmed) { - private const string Usage = "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run]"; + private const string Usage = "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run] [--yes]"; public static ReleaseOptions Parse(IReadOnlyList args) { SemanticVersion? version = null; var dryRun = false; + var confirmed = false; for (var index = 0; index < args.Count; index++) { @@ -19,6 +20,9 @@ public static ReleaseOptions Parse(IReadOnlyList args) case "--dry-run" when !dryRun: dryRun = true; break; + case "--yes" when !confirmed: + confirmed = true; + break; case "--version" when version is null && index + 1 < args.Count: version = SemanticVersion.Parse(args[++index]); break; @@ -27,6 +31,6 @@ public static ReleaseOptions Parse(IReadOnlyList args) } } - return new(version, dryRun); + return new(version, dryRun, confirmed); } } diff --git a/tools/GuidedCoding.Release/ReleaseWorkflow.cs b/tools/GuidedCoding.Release/ReleaseWorkflow.cs index cf138b7..54a9102 100644 --- a/tools/GuidedCoding.Release/ReleaseWorkflow.cs +++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs @@ -50,8 +50,7 @@ public ReleaseResult Run(ReleaseOptions options) return ReleaseResult.DryRun; } - output.Write($"Release {tag}? [y/N] "); - if (input.ReadLine()?.Trim().ToLowerInvariant() is not ("y" or "yes")) + if (!options.Confirmed && !Confirm(tag)) { output.WriteLine("Release cancelled."); return ReleaseResult.Cancelled; @@ -203,7 +202,6 @@ private void Publish(string tag) { try { - commands.Run(new("gh", "repo", "edit", "--add-topic", "agent-skills")); commands.Run(new("gh", "skill", "publish", "--tag", tag)); } catch (Exception exception) @@ -216,6 +214,12 @@ private void Publish(string tag) } } + private bool Confirm(string tag) + { + output.Write($"Release {tag}? [y/N] "); + return input.ReadLine()?.Trim().ToLowerInvariant() is "y" or "yes"; + } + private string ReadFile(string relativePath) => File.ReadAllText(Path.Combine(repositoryRoot, relativePath)); private static string Label(ReleaseType releaseType) => releaseType.ToString().ToLowerInvariant(); From fb389047a6c1553801e05142c52ca7ae9ab4c3f6 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:37:42 +0200 Subject: [PATCH 05/13] ci: add manually triggered release workflow The Release workflow runs the release tool with --yes on main. It accepts an optional version and defaults to a dry run, so a first run only shows the proposed version. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 54 +++++++++++++++++++++++++++++++++++ AGENTS.md | 2 +- README.md | 12 +++++++- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d220c1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Version to release (MAJOR.MINOR.PATCH). Leave empty to derive it from the Conventional Commits since the last release. + required: false + type: string + dry-run: + description: Only show the proposed version without releasing. + required: false + type: boolean + default: true + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # The release tool needs the full history and all tags to propose the next version. + fetch-depth: 0 + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Validate Claude Code plugin + env: + DISABLE_AUTOUPDATER: "1" + run: npx --yes @anthropic-ai/claude-code@latest plugin validate . --strict + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898208+github-actions[bot]@users.noreply.github.com" + - name: Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + args=(--yes) + if [ -n "$VERSION" ]; then args+=(--version "$VERSION"); fi + if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi + dotnet run --project tools/GuidedCoding.Release -- "${args[@]}" diff --git a/AGENTS.md b/AGENTS.md index 57d2d7a..e5842af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This repository contains skills for Guided Coding. The root package supports the ## Manifests and versions - Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `vMAJOR.MINOR.PATCH` release tags. -- Do not bump versions manually. Between releases, the manifests hold the last released version. Release with `dotnet run --project tools/GuidedCoding.Release`, which derives the next version from the commits since the last release tag, updates the manifests and `CHANGELOG.md`, validates, commits, tags, pushes, and runs `gh skill publish`. +- Do not bump versions manually. Between releases, the manifests hold the last released version. Release with `dotnet run --project tools/GuidedCoding.Release` or the manually triggered Release workflow in GitHub Actions. The tool derives the next version from the commits since the last release tag, updates the manifests and `CHANGELOG.md`, validates, commits, tags, pushes, and runs `gh skill publish`. - Describe user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. ## Commit messages diff --git a/README.md b/README.md index e3f7b92..8214823 100644 --- a/README.md +++ b/README.md @@ -141,4 +141,14 @@ version. After you confirm, it updates `plugin.json`, `claude-plugin/.claude-plu `.claude-plugin/marketplace.json`, and `CHANGELOG.md`, runs the validations, commits, tags, pushes `main` together with the tag, and publishes the Agent Skills with `gh skill publish`. Pushing the new version to `main` releases the Claude Code plugin. The first release needs `--version` because there -is no release tag to start from. +is no release tag to start from. Pass `--yes` to skip the confirmation. + +The repository must carry the `agent-skills` topic for `gh skill publish`. It is already set; the +tool does not set it because GitHub's workflow token lacks the required admin permission. + +#### Releasing from GitHub Actions + +Maintainers with write access can also release from the **Actions** tab by running the **Release** +workflow on `main`. It runs the same tool with `--yes`. Leave **Version** empty to use the proposed +version. **Dry run** is enabled by default, so a first run only shows the proposal; run the workflow +again with **Dry run** unchecked to release. From 04e70ccc74be725db17cc4301e93493cbcc3da60 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 07:37:42 +0200 Subject: [PATCH 06/13] ci: bump actions/checkout and actions/setup-node to v7 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/validate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index af3ea7d..17389ac 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,7 +13,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: global.json @@ -23,7 +23,7 @@ jobs: run: gh skill publish --dry-run env: GH_TOKEN: ${{ github.token }} - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 - name: Validate Claude Code plugin From 8dfd0b929daa0fb619d7b056ba959aeaeebc3086 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 08:01:07 +0200 Subject: [PATCH 07/13] ci: isolate plugin validation from the release job's write token Run the Claude Code plugin validation, which executes an unpinned npm package, in a separate job with read-only permissions. The release job depends on it and is the only job with contents: write. Pin the actions in the release workflow to commit SHAs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d220c1..1e04541 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,30 +14,43 @@ on: default: true permissions: - contents: write + contents: read concurrency: group: release cancel-in-progress: false jobs: - release: + validate: + # Runs third-party npm code, so it must not hold a write token. runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # The release tool needs the full history and all tags to propose the next version. - fetch-depth: 0 - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - - uses: actions/setup-node@v7 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Validate Claude Code plugin env: DISABLE_AUTOUPDATER: "1" run: npx --yes @anthropic-ai/claude-code@latest plugin validate . --strict + + release: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The release tool needs the full history and all tags to propose the next version. + fetch-depth: 0 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json - name: Configure git identity run: | git config user.name "github-actions[bot]" From c97f9401f2fcc8a190c509227167819e94299dc2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 08:14:40 +0200 Subject: [PATCH 08/13] build: roll back the release commit when committing or tagging fails Commit and tag are local and reversible, so they now run under the same guard as the file changes and validations. A failing hook or signature resets to the original HEAD and restores the release files instead of leaving a staged bump or an untagged release commit behind. Co-Authored-By: Claude Opus 5.5 --- .../ReleaseRepository.cs | 24 ++++++---- .../ReleaseWorkflowTests.cs | 48 +++++++++++++++++++ tools/GuidedCoding.Release/Git.cs | 5 ++ tools/GuidedCoding.Release/ReleaseWorkflow.cs | 16 +++++-- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs index efd6be6..a0bc813 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs @@ -127,15 +127,10 @@ public void PushCommitFromAnotherClone(string message) RunGit(clone, "push", "origin", "main"); } - public void RejectPushes() - { - var hook = Path.Combine(OriginDirectory, "hooks", "pre-receive"); - File.WriteAllText(hook, "#!/bin/sh\necho 'Pushes are rejected.' >&2\nexit 1\n"); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(hook, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - } + public void RejectPushes() => InstallFailingHook(OriginDirectory, "pre-receive", "Pushes are rejected."); + + public void RejectCommits() => + InstallFailingHook(Path.Combine(WorkingDirectory, ".git"), "pre-commit", "Commits are rejected."); public string ReadFile(string relativePath) => File.ReadAllText(Path.Combine(WorkingDirectory, relativePath)); @@ -162,6 +157,17 @@ public void Dispose() Directory.Delete(_root, recursive: true); } + private static void InstallFailingHook(string gitDirectory, string name, string message) + { + var hook = Path.Combine(gitDirectory, "hooks", name); + Directory.CreateDirectory(Path.GetDirectoryName(hook)!); + File.WriteAllText(hook, $"#!/bin/sh\necho '{message}' >&2\nexit 1\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(hook, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + private static string RunGit(string workingDirectory, params string[] arguments) => Processes.Capture(workingDirectory, "git", arguments); } diff --git a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs index 1f849ea..dda047d 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs @@ -325,6 +325,54 @@ public void RevertsTheReleaseChangesWhenValidationFails() Assert.Equal([GeneratorCheck, Tests], _commands.Commands); } + [Fact] + public void RevertsTheReleaseChangesWhenCommittingFails() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + repository.RejectCommits(); + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith( + "The release changes were reverted. 'git commit -m chore(release): 1.0.1' failed with exit code 1.", + exception.Message + ); + Assert.True(repository.IsClean); + Assert.Equal(head, repository.Head); + Assert.Equal("v1.0.0", repository.Git("tag", "--list").Trim()); + Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands); + } + + [Fact] + public void RevertsTheReleaseCommitWhenTaggingFails() + { + using var repository = ReleaseRepository.Create(); + repository.Commit("fix: correct a typo"); + var head = repository.Head; + var originHead = repository.Origin("rev-parse", "main").Trim(); + // Someone else creates the tag while the validations run. + _commands.OnRun = command => + { + if (command == PublishDryRun) + { + repository.Tag("v1.0.1"); + } + }; + + var exception = Assert.Throws(() => Release(repository)); + + Assert.StartsWith( + "The release changes were reverted. 'git tag -a v1.0.1 -m v1.0.1' failed with exit code 128.", + exception.Message + ); + Assert.True(repository.IsClean); + Assert.Equal(head, repository.Head); + Assert.Equal(head, repository.Git("rev-parse", "v1.0.1^{commit}").Trim()); + Assert.Equal(originHead, repository.Origin("rev-parse", "main").Trim()); + } + [Fact] public void ExplainsHowToFinishWhenPushingFails() { diff --git a/tools/GuidedCoding.Release/Git.cs b/tools/GuidedCoding.Release/Git.cs index fd71fb5..3d443e7 100644 --- a/tools/GuidedCoding.Release/Git.cs +++ b/tools/GuidedCoding.Release/Git.cs @@ -11,6 +11,8 @@ public sealed class Git(string repositoryRoot) public string CurrentBranch() => Run("branch", "--show-current").Trim(); + public string Head() => Run("rev-parse", "HEAD").Trim(); + public bool HasUncommittedChanges() => Run("status", "--porcelain").Length > 0; public void FetchOrigin() => Run("fetch", "--tags", Remote); @@ -44,6 +46,9 @@ public IReadOnlyList CommitMessagesSince(SemanticVersion? release) public void Restore(IEnumerable paths) => Run(["checkout", "HEAD", "--", .. paths]); + // Moves the branch back to the commit, leaving the index and working tree as they are. + public void ResetSoft(string commit) => Run("reset", "--soft", commit); + public void Commit(IEnumerable paths, string message) { Run(["add", "--", .. paths]); diff --git a/tools/GuidedCoding.Release/ReleaseWorkflow.cs b/tools/GuidedCoding.Release/ReleaseWorkflow.cs index 54a9102..8410fdf 100644 --- a/tools/GuidedCoding.Release/ReleaseWorkflow.cs +++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs @@ -56,9 +56,7 @@ public ReleaseResult Run(ReleaseOptions options) return ReleaseResult.Cancelled; } - ApplyAndValidate(changes); - _git.Commit(changes.Select(change => change.Path), $"chore(release): {version}"); - _git.CreateTag(tag); + CommitRelease(changes, version, tag); Push(tag); Publish(tag); @@ -155,8 +153,11 @@ private List PrepareChanges(SemanticVersion version) return changes; } - private void ApplyAndValidate(IReadOnlyList changes) + // Everything up to the push stays local, so a failure, such as a failing hook or signature, is rolled back. + private void CommitRelease(IReadOnlyList changes, SemanticVersion version, string tag) { + var paths = changes.Select(change => change.Path).ToList(); + var head = _git.Head(); try { foreach (var change in changes) @@ -168,10 +169,15 @@ private void ApplyAndValidate(IReadOnlyList changes) { commands.Run(command); } + + _git.Commit(paths, $"chore(release): {version}"); + _git.CreateTag(tag); } catch (Exception exception) { - _git.Restore(changes.Select(change => change.Path)); + // The tag is created last, so a failure never leaves one behind. + _git.ResetSoft(head); + _git.Restore(paths); throw new InvalidOperationException( $"The release changes were reverted. {exception.Message}", exception From 1f082c555f772995b238b475560e337cd03e0063 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 08:16:11 +0200 Subject: [PATCH 09/13] build: drop the v prefix from release tags Release tags are now plain MAJOR.MINOR.PATCH versions, so a tag is the version itself. gh skill resolves pinned versions as plain git tags and does not require the prefix. No release tags exist yet, so nothing needs to be migrated. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 2 +- tests/GuidedCoding.Release.Tests/GitTests.cs | 4 +- .../ReleaseRepository.cs | 4 +- .../ReleaseWorkflowTests.cs | 52 +++++++++---------- .../SemanticVersionTests.cs | 16 ------ tools/GuidedCoding.Release/Git.cs | 9 ++-- tools/GuidedCoding.Release/ReleaseWorkflow.cs | 4 +- tools/GuidedCoding.Release/SemanticVersion.cs | 15 ------ 9 files changed, 39 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5842af..1eb4ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ This repository contains skills for Guided Coding. The root package supports the ## Manifests and versions -- Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `vMAJOR.MINOR.PATCH` release tags. +- Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `MAJOR.MINOR.PATCH` release tags without a `v` prefix. - Do not bump versions manually. Between releases, the manifests hold the last released version. Release with `dotnet run --project tools/GuidedCoding.Release` or the manually triggered Release workflow in GitHub Actions. The tool derives the next version from the commits since the last release tag, updates the manifests and `CHANGELOG.md`, validates, commits, tags, pushes, and runs `gh skill publish`. - Describe user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. diff --git a/README.md b/README.md index 8214823..171c768 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ dotnet run --project tools/GuidedCoding.Release # release dotnet run --project tools/GuidedCoding.Release -- --version 3.0.0 # release a specific version ``` -The tool derives the next version from the Conventional Commits since the last `vMAJOR.MINOR.PATCH` +The tool derives the next version from the Conventional Commits since the last `MAJOR.MINOR.PATCH` tag: breaking changes release a major version, `feat` a minor version, and `fix` or `perf` a patch version. After you confirm, it updates `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `CHANGELOG.md`, runs the validations, commits, tags, pushes diff --git a/tests/GuidedCoding.Release.Tests/GitTests.cs b/tests/GuidedCoding.Release.Tests/GitTests.cs index 795b7b2..8414602 100644 --- a/tests/GuidedCoding.Release.Tests/GitTests.cs +++ b/tests/GuidedCoding.Release.Tests/GitTests.cs @@ -10,7 +10,7 @@ public sealed class GitTests public void FindsTheHighestReleaseTagNumerically() { using var repository = ReleaseRepository.Create(); - foreach (var tag in new[] { "v1.9.0", "v1.10.0", "latest", "v2.0", "v3.0.0-beta.1", "4.0.0" }) + foreach (var tag in new[] { "1.9.0", "1.10.0", "latest", "2.0", "3.0.0-beta.1", "v4.0.0" }) { repository.Commit($"fix: prepare {tag}"); repository.Tag(tag); @@ -27,7 +27,7 @@ public void IgnoresReleaseTagsOutsideTheCurrentBranch() using var repository = ReleaseRepository.Create(); repository.Git("switch", "--create", "experiment"); repository.Commit("feat!: try something"); - repository.Tag("v2.0.0"); + repository.Tag("2.0.0"); repository.Git("switch", "main"); var version = new Git(repository.WorkingDirectory).FindLatestReleaseVersion(); diff --git a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs index a0bc813..75e204d 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs @@ -42,7 +42,7 @@ private ReleaseRepository(string root) private string OriginDirectory { get; } - // Creates a repository at version 1.0.0 whose initial commit is tagged v1.0.0 unless released is false. + // Creates a repository at version 1.0.0 whose initial commit is tagged 1.0.0 unless released is false. public static ReleaseRepository Create(bool released = true) { var repository = new ReleaseRepository( @@ -61,7 +61,7 @@ public static ReleaseRepository Create(bool released = true) repository.Commit("chore: initial commit"); if (released) { - repository.Tag("v1.0.0"); + repository.Tag("1.0.0"); } repository.Git("remote", "add", "origin", repository.OriginDirectory); diff --git a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs index dda047d..72544d5 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs @@ -40,12 +40,12 @@ public void ReleasesTheNextVersionDerivedFromConventionalCommits() Assert.Equal("chore(release): 1.1.0", repository.Git("log", "-1", "--format=%s").Trim()); Assert.True(repository.IsClean); Assert.Equal(repository.Head, repository.Origin("rev-parse", "main").Trim()); - Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.1.0^{commit}").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "1.1.0^{commit}").Trim()); Assert.Equal( - [GeneratorCheck, Tests, PublishDryRun, "gh skill publish --tag v1.1.0"], + [GeneratorCheck, Tests, PublishDryRun, "gh skill publish --tag 1.1.0"], _commands.Commands ); - Assert.EndsWith("Pushed main and v1.1.0 to origin.\nReleased v1.1.0.\n", Output); + Assert.EndsWith("Pushed main and 1.1.0 to origin.\nReleased 1.1.0.\n", Output); } [Theory] @@ -78,7 +78,7 @@ public void DryRunReportsTheProposalWithoutChangingAnything() Assert.Equal(ReleaseResult.DryRun, result); Assert.Equal( """ - Last release: v1.0.0 + Last release: 1.0.0 Commits to release: none docs: explain the skill minor feat: add a skill @@ -102,7 +102,7 @@ public void UsesTheRequestedVersionInsteadOfTheProposal() Assert.Equal(ReleaseResult.Released, result); Assert.Contains("Next version: 3.0.0 (requested)\n", Output); Assert.Equal(ReleaseRepository.Manifest("plugin.json", "3.0.0"), repository.ReadFile("plugin.json")); - Assert.Equal(repository.Head, repository.Origin("rev-parse", "v3.0.0^{commit}").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "3.0.0^{commit}").Trim()); } [Fact] @@ -120,7 +120,7 @@ public void ReleasesTheFirstVersionWhenItIsRequested() } Assert.Contains("## [1.0.0] - 2026-09-26\n", repository.ReadFile("CHANGELOG.md")); - Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.0.0^{commit}").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "1.0.0^{commit}").Trim()); } [Fact] @@ -157,7 +157,7 @@ public void RefusesToReleaseWithoutNewCommits() var exception = Assert.Throws(() => Release(repository, "--version 2.0.0")); - Assert.Equal("There are no commits since v1.0.0.", exception.Message); + Assert.Equal("There are no commits since 1.0.0.", exception.Message); AssertNothingChanged(repository, head); } @@ -182,14 +182,14 @@ public void RefusesToReuseAnExistingTag() using var repository = ReleaseRepository.Create(); repository.Git("switch", "--create", "experiment"); repository.Commit("fix: try something"); - repository.Tag("v1.0.1"); + repository.Tag("1.0.1"); repository.Git("switch", "main"); repository.Commit("fix: correct a typo"); var head = repository.Head; var exception = Assert.Throws(() => Release(repository)); - Assert.Equal("Tag v1.0.1 already exists.", exception.Message); + Assert.Equal("Tag 1.0.1 already exists.", exception.Message); Assert.True(repository.IsClean); Assert.Equal(head, repository.Head); Assert.Empty(_commands.Commands); @@ -265,7 +265,7 @@ public void ChangesNothingWhenTheReleaseIsNotConfirmed(string answer) var result = Release(repository, answer: answer); Assert.Equal(ReleaseResult.Cancelled, result); - Assert.EndsWith("Release v1.1.0? [y/N] Release cancelled.\n", Output); + Assert.EndsWith("Release 1.1.0? [y/N] Release cancelled.\n", Output); AssertNothingChanged(repository, head); } @@ -279,7 +279,7 @@ public void ReleasesWithoutAskingWhenConfirmedUpFront() Assert.Equal(ReleaseResult.Released, result); Assert.DoesNotContain("[y/N]", Output); - Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.1.0^{commit}").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "1.1.0^{commit}").Trim()); } [Fact] @@ -321,7 +321,7 @@ public void RevertsTheReleaseChangesWhenValidationFails() ); Assert.True(repository.IsClean); Assert.Equal(head, repository.Head); - Assert.Equal("v1.0.0", repository.Git("tag", "--list").Trim()); + Assert.Equal("1.0.0", repository.Git("tag", "--list").Trim()); Assert.Equal([GeneratorCheck, Tests], _commands.Commands); } @@ -341,7 +341,7 @@ public void RevertsTheReleaseChangesWhenCommittingFails() ); Assert.True(repository.IsClean); Assert.Equal(head, repository.Head); - Assert.Equal("v1.0.0", repository.Git("tag", "--list").Trim()); + Assert.Equal("1.0.0", repository.Git("tag", "--list").Trim()); Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands); } @@ -357,19 +357,19 @@ public void RevertsTheReleaseCommitWhenTaggingFails() { if (command == PublishDryRun) { - repository.Tag("v1.0.1"); + repository.Tag("1.0.1"); } }; var exception = Assert.Throws(() => Release(repository)); Assert.StartsWith( - "The release changes were reverted. 'git tag -a v1.0.1 -m v1.0.1' failed with exit code 128.", + "The release changes were reverted. 'git tag -a 1.0.1 -m 1.0.1' failed with exit code 128.", exception.Message ); Assert.True(repository.IsClean); Assert.Equal(head, repository.Head); - Assert.Equal(head, repository.Git("rev-parse", "v1.0.1^{commit}").Trim()); + Assert.Equal(head, repository.Git("rev-parse", "1.0.1^{commit}").Trim()); Assert.Equal(originHead, repository.Origin("rev-parse", "main").Trim()); } @@ -383,12 +383,12 @@ public void ExplainsHowToFinishWhenPushingFails() var exception = Assert.Throws(() => Release(repository)); Assert.StartsWith( - "The release commit and v1.0.1 were created locally, but pushing failed. " + - "Run 'git push --atomic origin main v1.0.1' and 'gh skill publish --tag v1.0.1' to finish the release.", + "The release commit and 1.0.1 were created locally, but pushing failed. " + + "Run 'git push --atomic origin main 1.0.1' and 'gh skill publish --tag 1.0.1' to finish the release.", exception.Message ); - Assert.Equal(repository.Head, repository.Git("rev-parse", "v1.0.1^{commit}").Trim()); - Assert.Equal("v1.0.0", repository.Origin("tag", "--list").Trim()); + Assert.Equal(repository.Head, repository.Git("rev-parse", "1.0.1^{commit}").Trim()); + Assert.Equal("1.0.0", repository.Origin("tag", "--list").Trim()); Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands); } @@ -400,7 +400,7 @@ public void PublishesAfterTheTagReachedOrigin() string? originTagsDuringPublish = null; _commands.OnRun = command => { - if (command == "gh skill publish --tag v1.0.1") + if (command == "gh skill publish --tag 1.0.1") { originTagsDuringPublish = repository.Origin("tag", "--list").ReplaceLineEndings("\n"); } @@ -408,7 +408,7 @@ public void PublishesAfterTheTagReachedOrigin() Release(repository); - Assert.Equal("v1.0.0\nv1.0.1\n", originTagsDuringPublish); + Assert.Equal("1.0.0\n1.0.1\n", originTagsDuringPublish); } [Fact] @@ -416,15 +416,15 @@ public void ExplainsHowToFinishWhenPublishingFails() { using var repository = ReleaseRepository.Create(); repository.Commit("fix: correct a typo"); - _commands.FailingCommand = "gh skill publish --tag v1.0.1"; + _commands.FailingCommand = "gh skill publish --tag 1.0.1"; var exception = Assert.Throws(() => Release(repository)); Assert.StartsWith( - "v1.0.1 was pushed, but publishing failed. Run 'gh skill publish --tag v1.0.1' to finish the release.", + "1.0.1 was pushed, but publishing failed. Run 'gh skill publish --tag 1.0.1' to finish the release.", exception.Message ); - Assert.Equal(repository.Head, repository.Origin("rev-parse", "v1.0.1^{commit}").Trim()); + Assert.Equal(repository.Head, repository.Origin("rev-parse", "1.0.1^{commit}").Trim()); } private ReleaseResult Release(ReleaseRepository repository, string arguments = "", string answer = "y") @@ -447,7 +447,7 @@ private void AssertNothingChanged(ReleaseRepository repository, string head) Assert.Equal(ReleaseRepository.Manifest("plugin.json", "1.0.0"), repository.ReadFile("plugin.json")); Assert.All( repository.Git("tag", "--list").Split('\n', StringSplitOptions.RemoveEmptyEntries), - tag => Assert.Equal("v1.0.0", tag) + tag => Assert.Equal("1.0.0", tag) ); Assert.Empty(_commands.Commands); } diff --git a/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs b/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs index 2c77ea7..9d2416b 100644 --- a/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs +++ b/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs @@ -66,20 +66,4 @@ public void ComparesVersionsNumerically(string greater, string lesser) Assert.True(lesserVersion <= greaterVersion); Assert.True(greaterVersion >= SemanticVersion.Parse(greater)); } - - [Fact] - public void UsesAVPrefixForTags() - { - Assert.Equal("v1.2.3", new SemanticVersion(1, 2, 3).ToTag()); - } - - [Theory] - [InlineData("v1.2.3", true)] - [InlineData("1.2.3", false)] - [InlineData("v1.2", false)] - [InlineData("latest", false)] - public void ParsesOnlyReleaseTags(string tag, bool isReleaseTag) - { - Assert.Equal(isReleaseTag, SemanticVersion.TryParseTag(tag, out _)); - } } diff --git a/tools/GuidedCoding.Release/Git.cs b/tools/GuidedCoding.Release/Git.cs index 3d443e7..0769dfc 100644 --- a/tools/GuidedCoding.Release/Git.cs +++ b/tools/GuidedCoding.Release/Git.cs @@ -20,13 +20,14 @@ public sealed class Git(string repositoryRoot) public int CountCommitsBehindOrigin() => int.Parse(Run("rev-list", "--count", $"HEAD..{Remote}/{MainBranch}").Trim(), CultureInfo.InvariantCulture); - // Only tags reachable from HEAD count, so a tag on another branch cannot become the base of a release. + // Release tags are plain MAJOR.MINOR.PATCH versions. Only tags reachable from HEAD count, so a tag on + // another branch cannot become the base of a release. public SemanticVersion? FindLatestReleaseVersion() { SemanticVersion? latest = null; - foreach (var tag in Lines(Run("tag", "--list", "v*", "--merged", "HEAD"))) + foreach (var tag in Lines(Run("tag", "--list", "--merged", "HEAD"))) { - if (SemanticVersion.TryParseTag(tag, out var version) && (latest is null || version > latest.Value)) + if (SemanticVersion.TryParse(tag, out var version) && (latest is null || version > latest.Value)) { latest = version; } @@ -39,7 +40,7 @@ public int CountCommitsBehindOrigin() => public IReadOnlyList CommitMessagesSince(SemanticVersion? release) { - var range = release is { } version ? $"{version.ToTag()}..HEAD" : "HEAD"; + var range = release is { } version ? $"refs/tags/{version}..HEAD" : "HEAD"; return Run("log", "--no-merges", "--format=%B%x1e", range) .Split('\x1e', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } diff --git a/tools/GuidedCoding.Release/ReleaseWorkflow.cs b/tools/GuidedCoding.Release/ReleaseWorkflow.cs index 8410fdf..64e4826 100644 --- a/tools/GuidedCoding.Release/ReleaseWorkflow.cs +++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs @@ -37,7 +37,7 @@ public ReleaseResult Run(ReleaseOptions options) EnsureReleasableCheckout(); var version = DetermineVersion(options.Version, _git.FindLatestReleaseVersion()); - var tag = version.ToTag(); + var tag = version.ToString(); if (_git.TagExists(tag)) { throw new InvalidOperationException($"Tag {tag} already exists."); @@ -91,7 +91,7 @@ private void EnsureReleasableCheckout() private SemanticVersion DetermineVersion(SemanticVersion? requested, SemanticVersion? lastRelease) { - var lastTag = lastRelease?.ToTag(); + var lastTag = lastRelease?.ToString(); var commits = _git.CommitMessagesSince(lastRelease); if (commits.Count == 0) { diff --git a/tools/GuidedCoding.Release/SemanticVersion.cs b/tools/GuidedCoding.Release/SemanticVersion.cs index d3a6094..853c1b7 100644 --- a/tools/GuidedCoding.Release/SemanticVersion.cs +++ b/tools/GuidedCoding.Release/SemanticVersion.cs @@ -6,8 +6,6 @@ namespace GuidedCoding.Release; // Releases use plain MAJOR.MINOR.PATCH versions without pre-release or build metadata. public readonly record struct SemanticVersion(int Major, int Minor, int Patch) : IComparable { - private const string TagPrefix = "v"; - public int CompareTo(SemanticVersion other) { var major = Major.CompareTo(other.Major); @@ -43,17 +41,6 @@ public static bool TryParse(string value, out SemanticVersion version) return false; } - public static bool TryParseTag(string tag, out SemanticVersion version) - { - if (tag.StartsWith(TagPrefix, StringComparison.Ordinal)) - { - return TryParse(tag[TagPrefix.Length..], out version); - } - - version = default; - return false; - } - public SemanticVersion Bump(ReleaseType releaseType) => releaseType switch { @@ -63,8 +50,6 @@ public SemanticVersion Bump(ReleaseType releaseType) => _ => throw new ArgumentOutOfRangeException(nameof(releaseType), releaseType, "Nothing to bump.") }; - public string ToTag() => TagPrefix + this; - public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}"); From 3fc18eb9fb3249a86864e62041bc569a536f375f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 08:25:08 +0200 Subject: [PATCH 10/13] build: refuse to release when main is ahead of origin Pushing the release also pushed any local commits that never reached origin/main, bypassing pull requests and CI. The release tool now requires main to match origin/main, so the release pushes nothing but its own commit. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../ReleaseRepository.cs | 7 ++ .../ReleaseWorkflowTests.cs | 64 ++++++++++++------- tools/GuidedCoding.Release/Git.cs | 9 ++- tools/GuidedCoding.Release/ReleaseWorkflow.cs | 11 +++- 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 171c768..919cd98 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ gh skill publish --dry-run ### Releasing -Releases are made from an up-to-date `main` branch. Describe the changes under `## [Unreleased]` -in `CHANGELOG.md` first, then run: +Releases are made from a `main` branch that matches `origin/main`, so the release pushes nothing +but its own commit. Describe the changes under `## [Unreleased]` in `CHANGELOG.md` first, then run: ```sh dotnet run --project tools/GuidedCoding.Release -- --dry-run # show the proposed version diff --git a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs index 75e204d..5400d73 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs @@ -117,6 +117,13 @@ public void Commit(string message) Git("commit", "--allow-empty", "-m", message); } + // Commits and pushes the current branch, like a merged pull request. + public void CommitAndPush(string message) + { + Commit(message); + Git("push", "origin", "HEAD"); + } + public void Tag(string tag) => Git("tag", "-a", tag, "-m", tag); public void PushCommitFromAnotherClone(string message) diff --git a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs index 72544d5..5bf1aae 100644 --- a/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs +++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs @@ -22,8 +22,8 @@ public sealed class ReleaseWorkflowTests public void ReleasesTheNextVersionDerivedFromConventionalCommits() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); - repository.Commit("feat: add a skill"); + repository.CommitAndPush("fix: correct a typo"); + repository.CommitAndPush("feat: add a skill"); var result = Release(repository); @@ -57,8 +57,8 @@ public void ReleasesTheNextVersionDerivedFromConventionalCommits() public void ProposesTheLargestBumpOfAllCommits(string message, string expectedVersion) { using var repository = ReleaseRepository.Create(); - repository.Commit("docs: explain the change"); - repository.Commit(message); + repository.CommitAndPush("docs: explain the change"); + repository.CommitAndPush(message); Release(repository, "--dry-run"); @@ -69,8 +69,8 @@ public void ProposesTheLargestBumpOfAllCommits(string message, string expectedVe public void DryRunReportsTheProposalWithoutChangingAnything() { using var repository = ReleaseRepository.Create(); - repository.Commit("feat: add a skill"); - repository.Commit("docs: explain the skill"); + repository.CommitAndPush("feat: add a skill"); + repository.CommitAndPush("docs: explain the skill"); var head = repository.Head; var result = Release(repository, "--dry-run"); @@ -95,7 +95,7 @@ public void DryRunReportsTheProposalWithoutChangingAnything() public void UsesTheRequestedVersionInsteadOfTheProposal() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var result = Release(repository, "--version 3.0.0"); @@ -139,8 +139,8 @@ public void RequiresAVersionForTheFirstRelease() public void RefusesToReleaseWhenNoCommitRequiresIt() { using var repository = ReleaseRepository.Create(); - repository.Commit("docs: explain a skill"); - repository.Commit("chore: tidy up"); + repository.CommitAndPush("docs: explain a skill"); + repository.CommitAndPush("chore: tidy up"); var head = repository.Head; var exception = Assert.Throws(() => Release(repository)); @@ -167,7 +167,7 @@ public void RefusesToReleaseWithoutNewCommits() public void RefusesARequestedVersionThatIsNotNewerThanTheLastRelease(string version) { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; var exception = Assert.Throws(() => Release(repository, $"--version {version}")); @@ -184,7 +184,7 @@ public void RefusesToReuseAnExistingTag() repository.Commit("fix: try something"); repository.Tag("1.0.1"); repository.Git("switch", "main"); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; var exception = Assert.Throws(() => Release(repository)); @@ -213,7 +213,7 @@ public void RefusesToReleaseFromAnotherBranch() public void RefusesToReleaseWithUncommittedChanges() { using var repository = ReleaseRepository.Create(); - repository.Commit("feat: add a skill"); + repository.CommitAndPush("feat: add a skill"); repository.WriteFile("notes.md", "Work in progress"); var head = repository.Head; @@ -239,12 +239,32 @@ public void RefusesToReleaseWhenMainIsBehindOrigin() AssertNothingChanged(repository, head); } + [Fact] + public void RefusesToReleaseWhenMainIsAheadOfOrigin() + { + using var repository = ReleaseRepository.Create(); + repository.CommitAndPush("fix: correct a typo"); + repository.Commit("feat: add a skill"); + repository.Commit("feat: add another skill"); + var head = repository.Head; + var originHead = repository.Origin("rev-parse", "main").Trim(); + + var exception = Assert.Throws(() => Release(repository)); + + Assert.Equal( + "main is 2 commit(s) ahead of origin/main. Merge them through a pull request or drop them before releasing.", + exception.Message + ); + AssertNothingChanged(repository, head); + Assert.Equal(originHead, repository.Origin("rev-parse", "main").Trim()); + } + [Fact] public void RefusesToReleaseWithoutChangelogEntries() { using var repository = ReleaseRepository.Create(); repository.WriteFile("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n- Start.\n"); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; var exception = Assert.Throws(() => Release(repository)); @@ -259,7 +279,7 @@ public void RefusesToReleaseWithoutChangelogEntries() public void ChangesNothingWhenTheReleaseIsNotConfirmed(string answer) { using var repository = ReleaseRepository.Create(); - repository.Commit("feat: add a skill"); + repository.CommitAndPush("feat: add a skill"); var head = repository.Head; var result = Release(repository, answer: answer); @@ -273,7 +293,7 @@ public void ChangesNothingWhenTheReleaseIsNotConfirmed(string answer) public void ReleasesWithoutAskingWhenConfirmedUpFront() { using var repository = ReleaseRepository.Create(); - repository.Commit("feat: add a skill"); + repository.CommitAndPush("feat: add a skill"); var result = Release(repository, "--yes", answer: ""); @@ -286,7 +306,7 @@ public void ReleasesWithoutAskingWhenConfirmedUpFront() public void ValidatesTheBumpedFilesBeforeCommitting() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; string? manifestDuringTests = null; string? headDuringTests = null; @@ -309,7 +329,7 @@ public void ValidatesTheBumpedFilesBeforeCommitting() public void RevertsTheReleaseChangesWhenValidationFails() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; _commands.FailingCommand = Tests; @@ -329,7 +349,7 @@ public void RevertsTheReleaseChangesWhenValidationFails() public void RevertsTheReleaseChangesWhenCommittingFails() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; repository.RejectCommits(); @@ -349,7 +369,7 @@ public void RevertsTheReleaseChangesWhenCommittingFails() public void RevertsTheReleaseCommitWhenTaggingFails() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); var head = repository.Head; var originHead = repository.Origin("rev-parse", "main").Trim(); // Someone else creates the tag while the validations run. @@ -377,7 +397,7 @@ public void RevertsTheReleaseCommitWhenTaggingFails() public void ExplainsHowToFinishWhenPushingFails() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); repository.RejectPushes(); var exception = Assert.Throws(() => Release(repository)); @@ -396,7 +416,7 @@ public void ExplainsHowToFinishWhenPushingFails() public void PublishesAfterTheTagReachedOrigin() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); string? originTagsDuringPublish = null; _commands.OnRun = command => { @@ -415,7 +435,7 @@ public void PublishesAfterTheTagReachedOrigin() public void ExplainsHowToFinishWhenPublishingFails() { using var repository = ReleaseRepository.Create(); - repository.Commit("fix: correct a typo"); + repository.CommitAndPush("fix: correct a typo"); _commands.FailingCommand = "gh skill publish --tag 1.0.1"; var exception = Assert.Throws(() => Release(repository)); diff --git a/tools/GuidedCoding.Release/Git.cs b/tools/GuidedCoding.Release/Git.cs index 0769dfc..f8db0a9 100644 --- a/tools/GuidedCoding.Release/Git.cs +++ b/tools/GuidedCoding.Release/Git.cs @@ -17,8 +17,13 @@ public sealed class Git(string repositoryRoot) public void FetchOrigin() => Run("fetch", "--tags", Remote); - public int CountCommitsBehindOrigin() => - int.Parse(Run("rev-list", "--count", $"HEAD..{Remote}/{MainBranch}").Trim(), CultureInfo.InvariantCulture); + // Counts the commits that only origin/main contains and the commits that only HEAD contains. + public (int Behind, int Ahead) CompareWithOrigin() + { + var counts = Run("rev-list", "--left-right", "--count", $"{Remote}/{MainBranch}...HEAD") + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return (int.Parse(counts[0], CultureInfo.InvariantCulture), int.Parse(counts[1], CultureInfo.InvariantCulture)); + } // Release tags are plain MAJOR.MINOR.PATCH versions. Only tags reachable from HEAD count, so a tag on // another branch cannot become the base of a release. diff --git a/tools/GuidedCoding.Release/ReleaseWorkflow.cs b/tools/GuidedCoding.Release/ReleaseWorkflow.cs index 64e4826..cbc370d 100644 --- a/tools/GuidedCoding.Release/ReleaseWorkflow.cs +++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs @@ -80,13 +80,22 @@ private void EnsureReleasableCheckout() } _git.FetchOrigin(); - var behind = _git.CountCommitsBehindOrigin(); + var (behind, ahead) = _git.CompareWithOrigin(); if (behind > 0) { throw new InvalidOperationException( $"{Git.MainBranch} is {behind} commit(s) behind {Git.Remote}/{Git.MainBranch}. Pull before releasing." ); } + + // Pushing the release would also push these commits, bypassing pull requests and CI. + if (ahead > 0) + { + throw new InvalidOperationException( + $"{Git.MainBranch} is {ahead} commit(s) ahead of {Git.Remote}/{Git.MainBranch}. " + + "Merge them through a pull request or drop them before releasing." + ); + } } private SemanticVersion DetermineVersion(SemanticVersion? requested, SemanticVersion? lastRelease) From 15caf88322637837a0e3a009c78afccce05e2e3c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 08:28:44 +0200 Subject: [PATCH 11/13] build: ignore subsection headings when checking for changelog entries Co-Authored-By: Claude Opus 5.5 --- tests/GuidedCoding.Release.Tests/ChangelogTests.cs | 9 +++++++++ tools/GuidedCoding.Release/Changelog.cs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/GuidedCoding.Release.Tests/ChangelogTests.cs b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs index 4c85d82..1e2945e 100644 --- a/tests/GuidedCoding.Release.Tests/ChangelogTests.cs +++ b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs @@ -23,6 +23,14 @@ public void MovesTheUnreleasedEntriesIntoTheReleasedVersion() ); } + [Fact] + public void KeepsSubsectionHeadingsWithTheirEntries() + { + var released = Changelog.Release("## [Unreleased]\n\n### Added\n\n- Add a skill.\n", Version, Date); + + Assert.Equal("## [Unreleased]\n\n## [1.1.0] - 2026-09-26\n\n### Added\n\n- Add a skill.\n", released); + } + [Fact] public void NormalizesLineEndings() { @@ -44,6 +52,7 @@ public void RequiresAnUnreleasedSection() [Theory] [InlineData("# Changelog\n\n## [Unreleased]\n")] [InlineData("# Changelog\n\n## [Unreleased]\n\n \n## [1.0.0] - 2026-01-01\n\n- Start.\n")] + [InlineData("# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.0.0] - 2026-01-01\n\n- Start.\n")] public void RequiresUnreleasedEntries(string changelog) { var exception = Assert.Throws(() => Changelog.Release(changelog, Version, Date)); diff --git a/tools/GuidedCoding.Release/Changelog.cs b/tools/GuidedCoding.Release/Changelog.cs index a65ca6d..be30d37 100644 --- a/tools/GuidedCoding.Release/Changelog.cs +++ b/tools/GuidedCoding.Release/Changelog.cs @@ -22,7 +22,7 @@ public static string Release(string content, SemanticVersion version, DateOnly d var hasEntries = lines .Skip(headingIndex + 1) .TakeWhile(line => !line.StartsWith("## ", StringComparison.Ordinal)) - .Any(line => !string.IsNullOrWhiteSpace(line)); + .Any(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#')); if (!hasEntries) { throw new InvalidOperationException( From 147bd7d22903f51fa404e5822f88e3c486c81d2f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 12:57:15 +0200 Subject: [PATCH 12/13] build: reject tags that are not MAJOR.MINOR.PATCH versions Every tag in the repository is now a release tag. The release tool used to skip tags in other forms, so a v-prefixed tag from a manual gh skill publish went unnoticed: the tool derived the next version from an older release and could release an existing version a second time. It now refuses to run while any tag is not a plain MAJOR.MINOR.PATCH version. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 4 ++++ tests/GuidedCoding.Release.Tests/GitTests.cs | 23 +++++++++++++++++++- tools/GuidedCoding.Release/Git.cs | 19 ++++++++++++---- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1eb4ab5..19279d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ This repository contains skills for Guided Coding. The root package supports the ## Manifests and versions -- Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `MAJOR.MINOR.PATCH` release tags without a `v` prefix. +- Keep the version synchronized across `plugin.json`, `claude-plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `MAJOR.MINOR.PATCH` release tags without a `v` prefix. Every tag is a release tag: the release tool refuses to run while any tag has another form. - Do not bump versions manually. Between releases, the manifests hold the last released version. Release with `dotnet run --project tools/GuidedCoding.Release` or the manually triggered Release workflow in GitHub Actions. The tool derives the next version from the commits since the last release tag, updates the manifests and `CHANGELOG.md`, validates, commits, tags, pushes, and runs `gh skill publish`. - Describe user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. diff --git a/README.md b/README.md index 919cd98..8dbcf87 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,10 @@ version. After you confirm, it updates `plugin.json`, `claude-plugin/.claude-plu version to `main` releases the Claude Code plugin. The first release needs `--version` because there is no release tag to start from. Pass `--yes` to skip the confirmation. +Every tag in the repository must be a plain `MAJOR.MINOR.PATCH` version, and the tool refuses to +release while any other tag exists. If you ever publish by hand, pass `--tag MAJOR.MINOR.PATCH` to +`gh skill publish`, because its interactive prompt suggests `v`-prefixed tags. + The repository must carry the `agent-skills` topic for `gh skill publish`. It is already set; the tool does not set it because GitHub's workflow token lacks the required admin permission. diff --git a/tests/GuidedCoding.Release.Tests/GitTests.cs b/tests/GuidedCoding.Release.Tests/GitTests.cs index 8414602..125a1d2 100644 --- a/tests/GuidedCoding.Release.Tests/GitTests.cs +++ b/tests/GuidedCoding.Release.Tests/GitTests.cs @@ -10,7 +10,7 @@ public sealed class GitTests public void FindsTheHighestReleaseTagNumerically() { using var repository = ReleaseRepository.Create(); - foreach (var tag in new[] { "1.9.0", "1.10.0", "latest", "2.0", "3.0.0-beta.1", "v4.0.0" }) + foreach (var tag in new[] { "1.9.0", "1.10.0", "1.2.0" }) { repository.Commit($"fix: prepare {tag}"); repository.Tag(tag); @@ -21,6 +21,27 @@ public void FindsTheHighestReleaseTagNumerically() Assert.Equal(new SemanticVersion(1, 10, 0), version); } + [Theory] + [InlineData("v1.1.0")] + [InlineData("1.1")] + [InlineData("1.1.0-beta.1")] + [InlineData("01.1.0")] + [InlineData("latest")] + public void RejectsTagsThatAreNotReleaseVersions(string tag) + { + using var repository = ReleaseRepository.Create(); + repository.Git("switch", "--create", "experiment"); + repository.Commit("fix: try something"); + repository.Tag(tag); + repository.Git("switch", "main"); + + var exception = Assert.Throws( + () => new Git(repository.WorkingDirectory).FindLatestReleaseVersion() + ); + + Assert.Equal($"Tags must be versions in the form MAJOR.MINOR.PATCH, but found: {tag}.", exception.Message); + } + [Fact] public void IgnoresReleaseTagsOutsideTheCurrentBranch() { diff --git a/tools/GuidedCoding.Release/Git.cs b/tools/GuidedCoding.Release/Git.cs index f8db0a9..f980d66 100644 --- a/tools/GuidedCoding.Release/Git.cs +++ b/tools/GuidedCoding.Release/Git.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; namespace GuidedCoding.Release; @@ -21,18 +22,28 @@ public sealed class Git(string repositoryRoot) public (int Behind, int Ahead) CompareWithOrigin() { var counts = Run("rev-list", "--left-right", "--count", $"{Remote}/{MainBranch}...HEAD") - .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + .Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries); return (int.Parse(counts[0], CultureInfo.InvariantCulture), int.Parse(counts[1], CultureInfo.InvariantCulture)); } - // Release tags are plain MAJOR.MINOR.PATCH versions. Only tags reachable from HEAD count, so a tag on - // another branch cannot become the base of a release. + // Every tag is a release tag in the form MAJOR.MINOR.PATCH. Any other tag, such as one with a v prefix + // created by a manual 'gh skill publish', stops the release instead of being skipped. Only tags reachable + // from HEAD count, so a tag on another branch cannot become the base of a release. public SemanticVersion? FindLatestReleaseVersion() { + var invalidTags = Lines(Run("tag", "--list")).Where(tag => !SemanticVersion.TryParse(tag, out _)).ToList(); + if (invalidTags.Count > 0) + { + throw new InvalidOperationException( + $"Tags must be versions in the form MAJOR.MINOR.PATCH, but found: {string.Join(", ", invalidTags)}." + ); + } + SemanticVersion? latest = null; foreach (var tag in Lines(Run("tag", "--list", "--merged", "HEAD"))) { - if (SemanticVersion.TryParse(tag, out var version) && (latest is null || version > latest.Value)) + var version = SemanticVersion.Parse(tag); + if (latest is null || version > latest.Value) { latest = version; } From 3ec6de564cf8cdf300e55f43ec85445bc6777842 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 27 Sep 2026 13:12:05 +0200 Subject: [PATCH 13/13] build: add Dependabot for GitHub Actions and NuGet Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 28 ++++++++++++++++++++++++++++ .github/workflows/validate.yml | 6 +++--- AGENTS.md | 2 ++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3639194 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# Dependency updates never ship to users, so they use the non-releasing "build" commit type. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: build + include: scope + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: build + include: scope + groups: + nuget: + patterns: ["*"] diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 17389ac..6a3ef00 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,8 +13,8 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-dotnet@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - run: dotnet run --project tools/GuidedCoding.ClaudeGenerator --configuration Release -- --check @@ -23,7 +23,7 @@ jobs: run: gh skill publish --dry-run env: GH_TOKEN: ${{ github.token }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Validate Claude Code plugin diff --git a/AGENTS.md b/AGENTS.md index 19279d0..4b975fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ Use Conventional Commits messages. The commit type decides the next version: - `feat` releases a minor version, `fix` and `perf` release a patch version. - `!` after the type or a `BREAKING CHANGE:` footer releases a major version. Removing or renaming a skill is a breaking change. - Other types, such as `docs`, `test`, `refactor`, and `chore`, do not trigger a release. Changes to shipped skill content are therefore `feat` or `fix`, never `docs` or `chore`. +- Dependency updates do not reach users, so Dependabot commits as `build(deps)`. Keep this prefix in `.github/dependabot.yml`. +- Pin third-party actions in workflows to a commit SHA with a version comment. Dependabot keeps both up to date. ## Feedback loops