From abe0571e08d6f14fda6caeb9228bf298a39859c7 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Tue, 23 Jun 2026 21:59:43 +0300 Subject: [PATCH 1/7] [minor] Add tag/release mechanism and drift-prevention check (AX-1734) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 81 ++++++++++++++++++++++++++ .github/workflows/validate-version.yml | 21 +++++++ VERSION | 1 + 3 files changed, 103 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate-version.yml create mode 100644 VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de61de0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect release tag in commit message + id: detect + run: | + MSG="${{ github.event.head_commit.message }}" + if echo "$MSG" | grep -qE '\[(major|minor|patch)\]'; then + TAG=$(echo "$MSG" | grep -oE '\[(major|minor|patch)\]' | head -1 | tr -d '[]') + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + + - name: Compute next version + if: steps.detect.outputs.triggered == 'true' + id: version + run: | + VERSION=$(cat VERSION) + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + PATCH=$(echo "$VERSION" | cut -d. -f3) + case "${{ steps.detect.outputs.tag }}" in + major) NEXT="$((MAJOR + 1)).0.0" ;; + minor) NEXT="${MAJOR}.$((MINOR + 1)).0" ;; + patch) NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" ;; + esac + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + + - name: Update VERSION and JSON files + if: steps.detect.outputs.triggered == 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "$VERSION" > VERSION + jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > /tmp/plugin.json + mv /tmp/plugin.json .claude-plugin/plugin.json + + - name: Commit, tag, and push + if: steps.detect.outputs.triggered == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add VERSION .claude-plugin/plugin.json + git commit -m "Release v${{ steps.version.outputs.version }}" + git push origin main + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Package release artifact + if: steps.detect.outputs.triggered == 'true' + run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + + - name: Create GitHub Release + if: steps.detect.outputs.triggered == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.version.outputs.version }}" \ + release.zip \ + --title "Release v${{ steps.version.outputs.version }}" \ + --generate-notes diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml new file mode 100644 index 0000000..03ffaaf --- /dev/null +++ b/.github/workflows/validate-version.yml @@ -0,0 +1,21 @@ +name: Validate version + +on: + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check version consistency + run: | + VERSION=$(cat VERSION) + PLUGIN_VERSION=$(jq -r '.version' .claude-plugin/plugin.json) + if [ "$VERSION" != "$PLUGIN_VERSION" ]; then + echo "::error::Version mismatch: VERSION=$VERSION but .claude-plugin/plugin.json.version=$PLUGIN_VERSION" + exit 1 + fi + echo "All versions consistent: $VERSION" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b003284 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.7 From 3eda24c4d034ed39cac32d7fbab87e495301e037 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Wed, 15 Jul 2026 13:39:58 +0300 Subject: [PATCH 2/7] Aligning VERSION --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b003284..25cd22b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.7 +0.2.12 \ No newline at end of file From 3849aeb580eaf24e69b588501fe5eb93b5769260 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Thu, 16 Jul 2026 12:48:39 +0300 Subject: [PATCH 3/7] AX-1734 - Apply PR review feedback: copyright headers, checkout@v5, step comments Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 10 +++++++++- .github/workflows/validate-version.yml | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de61de0..a35ab4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Copyright (c) JFrog Ltd. 2026 name: Release on: @@ -15,11 +16,13 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Check out the repository with full history for tag inspection + - uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + # Inspect the commit message for a [major], [minor], or [patch] release tag - name: Detect release tag in commit message id: detect run: | @@ -32,6 +35,7 @@ jobs: echo "triggered=false" >> "$GITHUB_OUTPUT" fi + # Calculate the next semantic version based on the detected release tag - name: Compute next version if: steps.detect.outputs.triggered == 'true' id: version @@ -47,6 +51,7 @@ jobs: esac echo "version=$NEXT" >> "$GITHUB_OUTPUT" + # Write the new version into VERSION and .claude-plugin/plugin.json - name: Update VERSION and JSON files if: steps.detect.outputs.triggered == 'true' run: | @@ -55,6 +60,7 @@ jobs: jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > /tmp/plugin.json mv /tmp/plugin.json .claude-plugin/plugin.json + # Commit the version bump, create a git tag, and push both to origin - name: Commit, tag, and push if: steps.detect.outputs.triggered == 'true' run: | @@ -66,10 +72,12 @@ jobs: git tag "v${{ steps.version.outputs.version }}" git push origin "v${{ steps.version.outputs.version }}" + # Zip the repository contents into a release artifact - name: Package release artifact if: steps.detect.outputs.triggered == 'true' run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + # Publish a GitHub Release with the packaged artifact and auto-generated notes - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml index 03ffaaf..bf0f94b 100644 --- a/.github/workflows/validate-version.yml +++ b/.github/workflows/validate-version.yml @@ -1,3 +1,4 @@ +# Copyright (c) JFrog Ltd. 2026 name: Validate version on: @@ -8,7 +9,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Check version consistency run: | From f7578cba7aaa0106d51692f6c7172c8368a7aa26 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Sun, 2 Aug 2026 17:28:44 +0300 Subject: [PATCH 4/7] AX-1734 - Use plugin.json as the single version source; drop the VERSION file .claude-plugin/plugin.json already carries the version Claude Code reads, and CONTRIBUTING already told contributors to bump it. Adding a VERSION file made a second copy and then needed validate-version.yml to police the two against each other, so both are gone. The merge with main also exposed why that mattered: VERSION was pinned at 0.2.12 while the manifest had moved to 0.2.17, so the drift check this branch added would have failed on the first PR after merge. release.yml now reads the version from the manifest instead of bumping and pushing to main, matching the flow the other plugin repos settled on. It also refuses to re-tag a version that already shipped (the one mistake a marker-triggered release allows) and reads the commit message from env rather than interpolating it into the script. --- .github/workflows/release.yml | 68 ++++++++++++-------------- .github/workflows/validate-version.yml | 22 --------- CONTRIBUTING.md | 11 +++++ VERSION | 1 - 4 files changed, 43 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/validate-version.yml delete mode 100644 VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a35ab4f..2317817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,68 +16,64 @@ jobs: release: runs-on: ubuntu-latest steps: - # Check out the repository with full history for tag inspection + # Full history so the "already released" check below can see existing tags. - uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # Inspect the commit message for a [major], [minor], or [patch] release tag - - name: Detect release tag in commit message + # Releasing is opt-in: only a merge whose commit message carries [major], [minor] or + # [patch] cuts one. The marker is purely a trigger — the version itself always comes from + # the manifest, so the bump is reviewable in the PR that makes it. + # The message is passed through env rather than interpolated into the script, so a commit + # subject can never inject shell. + - name: Detect release marker in commit message id: detect + env: + MSG: ${{ github.event.head_commit.message }} run: | - MSG="${{ github.event.head_commit.message }}" - if echo "$MSG" | grep -qE '\[(major|minor|patch)\]'; then - TAG=$(echo "$MSG" | grep -oE '\[(major|minor|patch)\]' | head -1 | tr -d '[]') - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + if printf '%s' "$MSG" | grep -qE '\[(major|minor|patch)\]'; then echo "triggered=true" >> "$GITHUB_OUTPUT" else echo "triggered=false" >> "$GITHUB_OUTPUT" fi - # Calculate the next semantic version based on the detected release tag - - name: Compute next version + # .claude-plugin/plugin.json is the single source of truth for the version — it is the file + # Claude Code itself reads. Nothing duplicates it, so there is no drift to police. + - name: Read version from the plugin manifest if: steps.detect.outputs.triggered == 'true' id: version run: | - VERSION=$(cat VERSION) - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - PATCH=$(echo "$VERSION" | cut -d. -f3) - case "${{ steps.detect.outputs.tag }}" in - major) NEXT="$((MAJOR + 1)).0.0" ;; - minor) NEXT="${MAJOR}.$((MINOR + 1)).0" ;; - patch) NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" ;; - esac - echo "version=$NEXT" >> "$GITHUB_OUTPUT" + set -euo pipefail + VERSION=$(jq -er '.version' .claude-plugin/plugin.json) + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::.claude-plugin/plugin.json version '$VERSION' is not X.Y.Z — refusing to release" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # Write the new version into VERSION and .claude-plugin/plugin.json - - name: Update VERSION and JSON files + # Catches the one mistake this flow allows: merging a release marker without bumping the + # manifest, which would otherwise try to re-tag a version that already shipped. + - name: Refuse to re-release an existing version if: steps.detect.outputs.triggered == 'true' run: | - VERSION="${{ steps.version.outputs.version }}" - echo "$VERSION" > VERSION - jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > /tmp/plugin.json - mv /tmp/plugin.json .claude-plugin/plugin.json + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::$TAG already exists — bump .claude-plugin/plugin.json before merging a release marker" + exit 1 + fi - # Commit the version bump, create a git tag, and push both to origin - - name: Commit, tag, and push + - name: Create and push tag if: steps.detect.outputs.triggered == 'true' run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add VERSION .claude-plugin/plugin.json - git commit -m "Release v${{ steps.version.outputs.version }}" - git push origin main - git tag "v${{ steps.version.outputs.version }}" - git push origin "v${{ steps.version.outputs.version }}" + TAG="v${{ steps.version.outputs.version }}" + git tag "$TAG" + git push origin "$TAG" - # Zip the repository contents into a release artifact - name: Package release artifact if: steps.detect.outputs.triggered == 'true' run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" - # Publish a GitHub Release with the packaged artifact and auto-generated notes - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml deleted file mode 100644 index bf0f94b..0000000 --- a/.github/workflows/validate-version.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) JFrog Ltd. 2026 -name: Validate version - -on: - pull_request: - branches: [main] - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Check version consistency - run: | - VERSION=$(cat VERSION) - PLUGIN_VERSION=$(jq -r '.version' .claude-plugin/plugin.json) - if [ "$VERSION" != "$PLUGIN_VERSION" ]; then - echo "::error::Version mismatch: VERSION=$VERSION but .claude-plugin/plugin.json.version=$PLUGIN_VERSION" - exit 1 - fi - echo "All versions consistent: $VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06018fd..e037cb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,17 @@ Use [Submitting your plugin](https://claude.com/docs/plugins/submit). Submit the Compliance: [Anthropic Software Directory Terms](https://support.claude.com/en/articles/13145338-anthropic-software-directory-terms), [Anthropic Software Directory Policy](https://support.claude.com/en/articles/13145358-anthropic-software-directory-policy). +## Releasing + +To cut a release: + +1. In your PR, bump `.version` in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json). That manifest is the only place the version lives. +2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` anywhere in the commit message. + +The release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. The marker only decides *whether* to release; the version comes from the manifest either way, so the bump is reviewed in the PR that makes it. There is no bot push to `main`. + +Merging a marker without bumping the manifest fails the release rather than re-tagging a shipped version. + ## Reporting Issues Open a [GitHub issue](https://github.com/jfrog/claude-plugin/issues) with: diff --git a/VERSION b/VERSION deleted file mode 100644 index 25cd22b..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.2.12 \ No newline at end of file From 119a8ba4faeadf04db6a5e32f890346d3e64e1dd Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Mon, 3 Aug 2026 09:58:46 +0300 Subject: [PATCH 5/7] AX-1734 - Trigger releases from the commit subject only These repos squash-merge, and GitHub pre-fills the squash message body from the branch's commit messages (or the PR description). Both of those quote [major]/[minor]/[patch] while only documenting the flow, so matching the whole message meant merging this very PR would have cut a release nobody asked for. Matching the subject line alone keeps the trigger deliberate: a release happens when someone writes the marker in the subject they are merging, not when a marker happens to appear in generated body text. --- .github/workflows/release.yml | 11 ++++++++--- CONTRIBUTING.md | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2317817..6aafb74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,22 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # Releasing is opt-in: only a merge whose commit message carries [major], [minor] or + # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or # [patch] cuts one. The marker is purely a trigger — the version itself always comes from # the manifest, so the bump is reviewable in the PR that makes it. + # Subject line only, deliberately. This repo squash-merges, and GitHub pre-fills the squash + # message body from the branch's commit messages (or the PR description). Either one can + # mention a marker while merely documenting it — matching the whole message would then + # release by accident on a merge nobody intended as a release. # The message is passed through env rather than interpolated into the script, so a commit # subject can never inject shell. - - name: Detect release marker in commit message + - name: Detect release marker in commit subject id: detect env: MSG: ${{ github.event.head_commit.message }} run: | - if printf '%s' "$MSG" | grep -qE '\[(major|minor|patch)\]'; then + SUBJECT=$(printf '%s\n' "$MSG" | head -1) + if printf '%s' "$SUBJECT" | grep -qE '\[(major|minor|patch)\]'; then echo "triggered=true" >> "$GITHUB_OUTPUT" else echo "triggered=false" >> "$GITHUB_OUTPUT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e037cb8..1025035 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,10 @@ Compliance: [Anthropic Software Directory Terms](https://support.claude.com/en/a To cut a release: 1. In your PR, bump `.version` in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json). That manifest is the only place the version lives. -2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` anywhere in the commit message. +2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` in the commit **subject** — the first + line. A marker further down in the body is ignored on purpose: this repo squash-merges, and + GitHub pre-fills the squash body from the branch commits or the PR description, either of + which may quote a marker while only documenting it. The release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. The marker only decides *whether* to release; the version comes from the manifest either way, so the bump is reviewed in the PR that makes it. There is no bot push to `main`. From 005c19f749de5090be55d79a921f976b1fecaa35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Aug 2026 14:22:32 +0300 Subject: [PATCH 6/7] AX-1734 - Gate the release on validation, and create the tag with the release Three fixes from review, all in release.yml. An orphan tag was possible: the tag was pushed in its own step before `gh release create`, so a failure in between left a tag with no release behind it. The re-run then hit the "already exists" check with nothing actually wrong but the tag, and it needed deleting by hand. The tag is now created by `gh release create --target "$GITHUB_SHA"` in the same API call as the release, so there is no window between the two. Nothing pushes over git anymore, so the write token is gone from the checkout step. Releases were not gated on validation. The validate workflow triggers on the same push, but as a separate workflow with no relationship to this one, so it could still be red while a release went out. The same check now runs here, before the release is created. `zip -r release.zip .` packed the working tree, so anything an earlier step left on the runner would ship inside the artifact. Replaced with `git archive`, which exports tracked files at HEAD, still excluding .github. --- .github/workflows/release.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aafb74..3324fab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,11 @@ jobs: release: runs-on: ubuntu-latest steps: - # Full history so the "already released" check below can see existing tags. + # Full history (and therefore tags) so the "already released" check below can see them. + # No write token needed: nothing here pushes over git, the release API creates the tag. - uses: actions/checkout@v5 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or # [patch] cuts one. The marker is purely a trigger — the version itself always comes from @@ -58,7 +58,8 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" # Catches the one mistake this flow allows: merging a release marker without bumping the - # manifest, which would otherwise try to re-tag a version that already shipped. + # manifest. The tag is created as part of the release below, so a tag that already exists + # means that version genuinely shipped. - name: Refuse to re-release an existing version if: steps.detect.outputs.triggered == 'true' run: | @@ -68,17 +69,27 @@ jobs: exit 1 fi - - name: Create and push tag + - uses: actions/setup-node@v5 if: steps.detect.outputs.triggered == 'true' - run: | - TAG="v${{ steps.version.outputs.version }}" - git tag "$TAG" - git push origin "$TAG" + with: + node-version: "24" + + # validate.yml runs on this same push, but as a separate workflow — in parallel and with no + # relationship to this one — so on its own it cannot stop a bad manifest or skill layout from + # being released. Running the same check here is what actually gates the release on it. + - name: Validate plugin layout before releasing + if: steps.detect.outputs.triggered == 'true' + run: node scripts/validate-claude-plugin.mjs + # git archive exports tracked files at HEAD, so nothing an earlier step happened to leave on + # the runner can end up in the artifact. .github is excluded to match what users get. - name: Package release artifact if: steps.detect.outputs.triggered == 'true' - run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' + # --target creates the tag as part of creating the release, in a single API call. Pushing the + # tag separately beforehand meant a failure in between left an orphan tag with no release, and + # the re-run then tripped the "already exists" check above with nothing wrong but the tag. - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: @@ -86,5 +97,6 @@ jobs: run: | gh release create "v${{ steps.version.outputs.version }}" \ release.zip \ + --target "$GITHUB_SHA" \ --title "Release v${{ steps.version.outputs.version }}" \ --generate-notes From 1478112a9584fee3386b05f3217def50a34c9dee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Aug 2026 11:10:52 +0300 Subject: [PATCH 7/7] AX-1734 - Move the release rationale out of the workflow and into CONTRIBUTING Review feedback: the per-step comments in release.yml had grown into several paragraphs of rationale, which is documentation rather than a code comment. Each step now carries at most two lines - what it does, or the one constraint a reader could otherwise undo by "simplifying" it: subject-line matching, env rather than interpolation, git archive rather than the working tree, --target creating the tag. A pointer at the top of the file sends readers to CONTRIBUTING.md for the full flow. CONTRIBUTING.md gains the parts the comments had that it did not already say: what the workflow does in order, what ends up in the release zip, why validation runs inside the release job instead of relying on the separate validate workflow, and why the tag is created by the release rather than pushed before it. No behaviour change - the release.yml diff is comments only. --- .github/workflows/release.yml | 37 ++++++++++++----------------------- CONTRIBUTING.md | 11 ++++++++--- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3324fab..bbc5f72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,7 @@ # Copyright (c) JFrog Ltd. 2026 +# +# Cuts a GitHub Release when a release marker is merged to main. +# Full flow and rationale: CONTRIBUTING.md#releasing name: Release on: @@ -16,21 +19,13 @@ jobs: release: runs-on: ubuntu-latest steps: - # Full history (and therefore tags) so the "already released" check below can see them. - # No write token needed: nothing here pushes over git, the release API creates the tag. + # Full history, so the tag check below can see existing tags. - uses: actions/checkout@v5 with: fetch-depth: 0 - # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or - # [patch] cuts one. The marker is purely a trigger — the version itself always comes from - # the manifest, so the bump is reviewable in the PR that makes it. - # Subject line only, deliberately. This repo squash-merges, and GitHub pre-fills the squash - # message body from the branch's commit messages (or the PR description). Either one can - # mention a marker while merely documenting it — matching the whole message would then - # release by accident on a merge nobody intended as a release. - # The message is passed through env rather than interpolated into the script, so a commit - # subject can never inject shell. + # Subject line only, not the whole message. MSG goes through env rather than string + # interpolation, so a crafted commit subject can't inject shell. - name: Detect release marker in commit subject id: detect env: @@ -43,8 +38,7 @@ jobs: echo "triggered=false" >> "$GITHUB_OUTPUT" fi - # .claude-plugin/plugin.json is the single source of truth for the version — it is the file - # Claude Code itself reads. Nothing duplicates it, so there is no drift to police. + # The manifest is the only place the version lives. - name: Read version from the plugin manifest if: steps.detect.outputs.triggered == 'true' id: version @@ -57,9 +51,8 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # Catches the one mistake this flow allows: merging a release marker without bumping the - # manifest. The tag is created as part of the release below, so a tag that already exists - # means that version genuinely shipped. + # A tag exists only if that version was released, so this catches a marker that was merged + # without a manifest bump. - name: Refuse to re-release an existing version if: steps.detect.outputs.triggered == 'true' run: | @@ -74,22 +67,18 @@ jobs: with: node-version: "24" - # validate.yml runs on this same push, but as a separate workflow — in parallel and with no - # relationship to this one — so on its own it cannot stop a bad manifest or skill layout from - # being released. Running the same check here is what actually gates the release on it. + # validate.yml runs on this same push, but as an independent workflow that can't gate this + # one. Re-running its check here is what actually gates the release on it. - name: Validate plugin layout before releasing if: steps.detect.outputs.triggered == 'true' run: node scripts/validate-claude-plugin.mjs - # git archive exports tracked files at HEAD, so nothing an earlier step happened to leave on - # the runner can end up in the artifact. .github is excluded to match what users get. + # Tracked files at HEAD only, so nothing left on the runner can end up in the zip. - name: Package release artifact if: steps.detect.outputs.triggered == 'true' run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' - # --target creates the tag as part of creating the release, in a single API call. Pushing the - # tag separately beforehand meant a failure in between left an orphan tag with no release, and - # the re-run then tripped the "already exists" check above with nothing wrong but the tag. + # --target creates the tag as part of the release, so a failure can't leave an orphan tag. - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1025035..e1fea83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,14 +59,19 @@ Compliance: [Anthropic Software Directory Terms](https://support.claude.com/en/a To cut a release: 1. In your PR, bump `.version` in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json). That manifest is the only place the version lives. -2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` in the commit **subject** — the first +2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` in the commit **subject** - the first line. A marker further down in the body is ignored on purpose: this repo squash-merges, and GitHub pre-fills the squash body from the branch commits or the PR description, either of which may quote a marker while only documenting it. -The release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. The marker only decides *whether* to release; the version comes from the manifest either way, so the bump is reviewed in the PR that makes it. There is no bot push to `main`. +The marker only decides *whether* to release; the version comes from the manifest either way, so the bump is reviewed in the PR that makes it. There is no bot push to `main`. Merging a marker without bumping the manifest fails the release rather than re-tagging a shipped version. -Merging a marker without bumping the manifest fails the release rather than re-tagging a shipped version. +The workflow reads the version from the manifest, refuses to continue if that version is already tagged, runs the same plugin-layout check as the `validate` PR workflow, packages the tracked files at `HEAD` (minus `.github/`) into `release.zip`, and creates the `vX.Y.Z` tag as part of publishing the GitHub Release. + +Two things to know before changing it: + +- Validation runs inside the release job. `validate.yml` triggers on the same push, but as an independent workflow, so it can be red while a release still goes out. Re-running its check in the release job is what actually gates the release on it. +- The tag is created by the release, not before it. `gh release create --target` does both in one API call, so a failed run can't leave a tag behind with no release attached to it. ## Reporting Issues