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/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1e04541
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,67 @@
+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: read
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ 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]"
+ 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/.github/workflows/validate.yml b/.github/workflows/validate.yml
index af3ea7d..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@v6
- - 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@v6
+ - 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 89b7d89..4b975fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,16 +15,26 @@ 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 `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`.
-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`.
+- 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
- 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/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/README.md b/README.md
index 935427c..8dbcf87 100644
--- a/README.md
+++ b/README.md
@@ -124,5 +124,35 @@ 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 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
+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 `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
+`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. 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.
+
+#### 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.
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.Release.Tests/ChangelogTests.cs b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs
new file mode 100644
index 0000000..1e2945e
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/ChangelogTests.cs
@@ -0,0 +1,62 @@
+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 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()
+ {
+ 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")]
+ [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));
+
+ 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..125a1d2
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/GitTests.cs
@@ -0,0 +1,97 @@
+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[] { "1.9.0", "1.10.0", "1.2.0" })
+ {
+ repository.Commit($"fix: prepare {tag}");
+ repository.Tag(tag);
+ }
+
+ var version = new Git(repository.WorkingDirectory).FindLatestReleaseVersion();
+
+ 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()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.Git("switch", "--create", "experiment");
+ repository.Commit("feat!: try something");
+ repository.Tag("2.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..9bcb572
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/ReleaseOptionsTests.cs
@@ -0,0 +1,43 @@
+using System;
+using Xunit;
+
+namespace GuidedCoding.Release.Tests;
+
+public sealed class ReleaseOptionsTests
+{
+ [Fact]
+ public void ProposesTheVersionAndReleasesByDefault()
+ {
+ Assert.Equal(new ReleaseOptions(null, false, false), ReleaseOptions.Parse([]));
+ }
+
+ [Theory]
+ [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, 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)
+ {
+ var exception = Assert.Throws(() => ReleaseOptions.Parse(args));
+
+ Assert.Equal(
+ "Usage: GuidedCoding.Release [--version MAJOR.MINOR.PATCH] [--dry-run] [--yes]",
+ 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..5400d73
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/ReleaseRepository.cs
@@ -0,0 +1,180 @@
+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 1.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("1.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);
+ }
+
+ // 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)
+ {
+ 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() => 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));
+
+ 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 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
new file mode 100644
index 0000000..5bf1aae
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/ReleaseWorkflowTests.cs
@@ -0,0 +1,474 @@
+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 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.CommitAndPush("fix: correct a typo");
+ repository.CommitAndPush("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", "1.1.0^{commit}").Trim());
+ Assert.Equal(
+ [GeneratorCheck, Tests, PublishDryRun, "gh skill publish --tag 1.1.0"],
+ _commands.Commands
+ );
+ Assert.EndsWith("Pushed main and 1.1.0 to origin.\nReleased 1.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.CommitAndPush("docs: explain the change");
+ repository.CommitAndPush(message);
+
+ Release(repository, "--dry-run");
+
+ Assert.Contains($"Next version: {expectedVersion}\n", Output);
+ }
+
+ [Fact]
+ public void DryRunReportsTheProposalWithoutChangingAnything()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("feat: add a skill");
+ repository.CommitAndPush("docs: explain the skill");
+ var head = repository.Head;
+
+ var result = Release(repository, "--dry-run");
+
+ Assert.Equal(ReleaseResult.DryRun, result);
+ Assert.Equal(
+ """
+ Last release: 1.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.CommitAndPush("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", "3.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", "1.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.CommitAndPush("docs: explain a skill");
+ repository.CommitAndPush("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 1.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.CommitAndPush("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("1.0.1");
+ repository.Git("switch", "main");
+ repository.CommitAndPush("fix: correct a typo");
+ var head = repository.Head;
+
+ var exception = Assert.Throws(() => Release(repository));
+
+ Assert.Equal("Tag 1.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.CommitAndPush("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 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.CommitAndPush("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.CommitAndPush("feat: add a skill");
+ var head = repository.Head;
+
+ var result = Release(repository, answer: answer);
+
+ Assert.Equal(ReleaseResult.Cancelled, result);
+ Assert.EndsWith("Release 1.1.0? [y/N] Release cancelled.\n", Output);
+ AssertNothingChanged(repository, head);
+ }
+
+ [Fact]
+ public void ReleasesWithoutAskingWhenConfirmedUpFront()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("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", "1.1.0^{commit}").Trim());
+ }
+
+ [Fact]
+ public void ValidatesTheBumpedFilesBeforeCommitting()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("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.CommitAndPush("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("1.0.0", repository.Git("tag", "--list").Trim());
+ Assert.Equal([GeneratorCheck, Tests], _commands.Commands);
+ }
+
+ [Fact]
+ public void RevertsTheReleaseChangesWhenCommittingFails()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("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("1.0.0", repository.Git("tag", "--list").Trim());
+ Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands);
+ }
+
+ [Fact]
+ public void RevertsTheReleaseCommitWhenTaggingFails()
+ {
+ using var repository = ReleaseRepository.Create();
+ 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.
+ _commands.OnRun = command =>
+ {
+ if (command == PublishDryRun)
+ {
+ repository.Tag("1.0.1");
+ }
+ };
+
+ var exception = Assert.Throws(() => Release(repository));
+
+ Assert.StartsWith(
+ "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", "1.0.1^{commit}").Trim());
+ Assert.Equal(originHead, repository.Origin("rev-parse", "main").Trim());
+ }
+
+ [Fact]
+ public void ExplainsHowToFinishWhenPushingFails()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("fix: correct a typo");
+ repository.RejectPushes();
+
+ var exception = Assert.Throws(() => Release(repository));
+
+ Assert.StartsWith(
+ "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", "1.0.1^{commit}").Trim());
+ Assert.Equal("1.0.0", repository.Origin("tag", "--list").Trim());
+ Assert.Equal([GeneratorCheck, Tests, PublishDryRun], _commands.Commands);
+ }
+
+ [Fact]
+ public void PublishesAfterTheTagReachedOrigin()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("fix: correct a typo");
+ string? originTagsDuringPublish = null;
+ _commands.OnRun = command =>
+ {
+ if (command == "gh skill publish --tag 1.0.1")
+ {
+ originTagsDuringPublish = repository.Origin("tag", "--list").ReplaceLineEndings("\n");
+ }
+ };
+
+ Release(repository);
+
+ Assert.Equal("1.0.0\n1.0.1\n", originTagsDuringPublish);
+ }
+
+ [Fact]
+ public void ExplainsHowToFinishWhenPublishingFails()
+ {
+ using var repository = ReleaseRepository.Create();
+ repository.CommitAndPush("fix: correct a typo");
+ _commands.FailingCommand = "gh skill publish --tag 1.0.1";
+
+ var exception = Assert.Throws(() => Release(repository));
+
+ Assert.StartsWith(
+ "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", "1.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("1.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..9d2416b
--- /dev/null
+++ b/tests/GuidedCoding.Release.Tests/SemanticVersionTests.cs
@@ -0,0 +1,69 @@
+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));
+ }
+}
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/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/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
-
-
-
+
diff --git a/tools/GuidedCoding.Release/Changelog.cs b/tools/GuidedCoding.Release/Changelog.cs
new file mode 100644
index 0000000..be30d37
--- /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) && !line.TrimStart().StartsWith('#'));
+ 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..f980d66
--- /dev/null
+++ b/tools/GuidedCoding.Release/Git.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+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 string Head() => Run("rev-parse", "HEAD").Trim();
+
+ public bool HasUncommittedChanges() => Run("status", "--porcelain").Length > 0;
+
+ public void FetchOrigin() => Run("fetch", "--tags", Remote);
+
+ // 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));
+ }
+
+ // 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")))
+ {
+ var version = SemanticVersion.Parse(tag);
+ if (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 ? $"refs/tags/{version}..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]);
+
+ // 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]);
+ 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..955962c
--- /dev/null
+++ b/tools/GuidedCoding.Release/ReleaseOptions.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace GuidedCoding.Release;
+
+public sealed record ReleaseOptions(SemanticVersion? Version, bool DryRun, bool Confirmed)
+{
+ 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++)
+ {
+ switch (args[index])
+ {
+ 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;
+ default:
+ throw new InvalidOperationException(Usage);
+ }
+ }
+
+ return new(version, dryRun, confirmed);
+ }
+}
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..cbc370d
--- /dev/null
+++ b/tools/GuidedCoding.Release/ReleaseWorkflow.cs
@@ -0,0 +1,243 @@
+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.ToString();
+ 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;
+ }
+
+ if (!options.Confirmed && !Confirm(tag))
+ {
+ output.WriteLine("Release cancelled.");
+ return ReleaseResult.Cancelled;
+ }
+
+ CommitRelease(changes, version, 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, 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)
+ {
+ var lastTag = lastRelease?.ToString();
+ 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;
+ }
+
+ // 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)
+ {
+ File.WriteAllText(Path.Combine(repositoryRoot, change.Path), change.Content);
+ }
+
+ foreach (var command in Validations)
+ {
+ commands.Run(command);
+ }
+
+ _git.Commit(paths, $"chore(release): {version}");
+ _git.CreateTag(tag);
+ }
+ catch (Exception exception)
+ {
+ // 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
+ );
+ }
+ }
+
+ 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", "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 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();
+
+ 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..853c1b7
--- /dev/null
+++ b/tools/GuidedCoding.Release/SemanticVersion.cs
@@ -0,0 +1,68 @@
+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
+{
+ 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 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 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');
+}