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'); +}