From 8d46d47c6362ff5b3120feeafe978cf4fcfe729f Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Thu, 20 Aug 2026 17:30:40 +0300 Subject: [PATCH 1/3] AX-2134: unify release trigger on version-file diff --- .github/workflows/release.yml | 54 +++++++++++++++++------------------ CONTRIBUTING.md | 9 ++---- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbc5f72..2fa6ef5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # Copyright (c) JFrog Ltd. 2026 # -# Cuts a GitHub Release when a release marker is merged to main. +# Cuts a GitHub Release when the version file on main is newer than the latest tag. # Full flow and rationale: CONTRIBUTING.md#releasing name: Release @@ -24,23 +24,8 @@ jobs: with: fetch-depth: 0 - # 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: - MSG: ${{ github.event.head_commit.message }} - run: | - 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" - fi - # The manifest is the only place the version lives. - name: Read version from the plugin manifest - if: steps.detect.outputs.triggered == 'true' id: version run: | set -euo pipefail @@ -51,36 +36,51 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # 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' + - name: Compare version against latest release tag + id: release_gate run: | - 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" + set -euo pipefail + VERSION="${{ steps.version.outputs.version }}" + git fetch --tags origin + LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1) + if [ -z "$LATEST" ]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "No prior release tag — v${VERSION} will be the first release" + exit 0 + fi + LATEST_VERSION="${LATEST#v}" + if [ "$VERSION" = "$LATEST_VERSION" ]; then + echo "::error::v${VERSION} was already released — bump .claude-plugin/plugin.json before merging" + exit 1 + fi + TAG_FOR_VERSION="v${VERSION}" + LOWEST=$(printf '%s\n%s\n' "$TAG_FOR_VERSION" "$LATEST" | sort -V | head -1) + if [ "$LOWEST" = "$TAG_FOR_VERSION" ]; then + echo "::error::.claude-plugin/plugin.json version ${VERSION} is older than the latest release ${LATEST} — check for an accidental revert" exit 1 fi + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "Version ${VERSION} is newer than ${LATEST} — proceeding with release" - uses: actions/setup-node@v5 - if: steps.detect.outputs.triggered == 'true' + if: steps.release_gate.outputs.should_release == 'true' with: node-version: "24" # 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' + if: steps.release_gate.outputs.should_release == 'true' run: node scripts/validate-claude-plugin.mjs # 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' + if: steps.release_gate.outputs.should_release == 'true' run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' # --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' + if: steps.release_gate.outputs.should_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1fea83..28acd75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,14 +59,11 @@ 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 - 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. +2. Merge to `main`. Every push to `main` compares the manifest version against the latest release tag: if the version is newer, a release proceeds; if it matches the latest tag, the workflow fails with a clear "already released" error; if it is older, it fails with a revert warning. -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. +The bump is reviewed in the PR that makes it. Merging without bumping the manifest fails the release rather than silently skipping or 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. +The workflow reads the version from the manifest, 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: From d22b40e5f07cc9b7a4985a2dd76dde72782c42d4 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Sat, 22 Aug 2026 17:30:22 +0300 Subject: [PATCH 2/3] AX-2134: address review findings for version-file releases Co-authored-by: Cursor --- .claude-plugin/plugin.json | 2 +- .github/workflows/release.yml | 30 ++++++++++++++++++++---------- .github/workflows/validate.yml | 11 +++++++++++ CONTRIBUTING.md | 10 +++++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 08eaffa..04b527b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "jfrog", "displayName": "JFrog", "description": "Official JFrog plugin. Connect Claude Code to JFrog to manage, secure, and govern your software supply chain. Give agents the context to build secure, compliant software.", - "version": "0.2.21", + "version": "0.2.22", "author": { "name": "JFrog Ltd.", "email": "devrel@jfrog.com", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fa6ef5..52bc0b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,8 @@ jobs: release: runs-on: ubuntu-latest steps: - # Full history, so the tag check below can see existing tags. + # fetch-depth: 0 here, plus `git fetch --tags` in the gate below, so the gate sees every + # release tag — including one created by a prior run that was still queued behind this one. - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -30,7 +31,7 @@ jobs: run: | set -euo pipefail VERSION=$(jq -er '.version' .claude-plugin/plugin.json) - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + if [[ ! "$VERSION" =~ ^[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 @@ -38,11 +39,14 @@ jobs: - name: Compare version against latest release tag id: release_gate + env: + VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - VERSION="${{ steps.version.outputs.version }}" git fetch --tags origin - LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1) + # grep exits 1 when no tag matches, which pipefail would turn into a step failure — + # an empty LATEST is the legitimate first-release case, handled just below. + LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) || LATEST="" if [ -z "$LATEST" ]; then echo "should_release=true" >> "$GITHUB_OUTPUT" echo "No prior release tag — v${VERSION} will be the first release" @@ -63,29 +67,35 @@ jobs: echo "Version ${VERSION} is newer than ${LATEST} — proceeding with release" - uses: actions/setup-node@v5 - if: steps.release_gate.outputs.should_release == 'true' with: node-version: "24" # 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.release_gate.outputs.should_release == 'true' run: node scripts/validate-claude-plugin.mjs # Tracked files at HEAD only, so nothing left on the runner can end up in the zip. - name: Package release artifact - if: steps.release_gate.outputs.should_release == 'true' run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' # --target creates the tag as part of the release, so a failure can't leave an orphan tag. - name: Create GitHub Release - if: steps.release_gate.outputs.should_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} run: | - gh release create "v${{ steps.version.outputs.version }}" \ + gh release create "v${VERSION}" \ release.zip \ --target "$GITHUB_SHA" \ - --title "Release v${{ steps.version.outputs.version }}" \ + --title "Release v${VERSION}" \ --generate-notes + + # Only when the gate passed: a gate failure means the version was already released, and that + # release belongs to an earlier run — deleting it here would destroy a shipped release. + - name: Roll back a partially published release + if: failure() && steps.release_gate.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: gh release delete "v${VERSION}" --yes --cleanup-tag || true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9fe59bb..e480ce2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,6 +15,17 @@ jobs: steps: - uses: actions/checkout@v5 + # Catches a malformed version in review, where it is cheap to fix. The release workflow + # compares it against the latest tag; that comparison only makes sense on main. + - name: Check the manifest version is X.Y.Z + run: | + set -euo pipefail + VERSION=$(jq -er '.version' .claude-plugin/plugin.json) + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::.claude-plugin/plugin.json version '$VERSION' is not X.Y.Z" + exit 1 + fi + - name: Set up Node.js uses: actions/setup-node@v5 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28acd75..c1b0751 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ This downloads the pinned upstream tarball and replaces the contents of `skills/ - [ ] `node scripts/validate-claude-plugin.mjs` passes. - [ ] `claude plugin validate` passes (before directory submission or major releases). -- [ ] Version bumped in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json) when the plugin changes. +- [ ] Version bumped in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json) — required on every PR to `main`, not only when the plugin itself changes. - [ ] No secrets, credentials, or files under `**/local-cache/` committed. - [ ] If the skill tree changed: `pin` in `.github/scripts/sync-skills-vendor.json` matches the upstream tag the new tree was generated from. - [ ] Smoke-test: `claude --plugin-dir .` from the repo root. @@ -56,12 +56,11 @@ Compliance: [Anthropic Software Directory Terms](https://support.claude.com/en/a ## Releasing -To cut a release: +Every merge to `main` releases, so **every** PR to `main` must bump `.version` in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json) — including docs- and CI-only changes. That manifest is the only place the version lives. -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`. Every push to `main` compares the manifest version against the latest release tag: if the version is newer, a release proceeds; if it matches the latest tag, the workflow fails with a clear "already released" error; if it is older, it fails with a revert warning. +Every push to `main` compares the manifest version against the latest release tag: if the version is newer, a release proceeds; if it matches the latest tag, the workflow fails with a clear "already released" error; if it is older, it fails with a revert warning. -The bump is reviewed in the PR that makes it. Merging without bumping the manifest fails the release rather than silently skipping or re-tagging a shipped version. +A merge without a bump therefore turns `Release` red. That is by design, not a bug to work around: the bump is reviewed in the PR that makes it, and failing loudly beats silently skipping a release or re-tagging a shipped version. The workflow reads the version from the manifest, 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. @@ -69,6 +68,7 @@ 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. +- The rollback step only runs when the gate itself passed. A gate failure means the version was already released by an earlier run, and deleting that release would destroy something already shipped. ## Reporting Issues From fd81d3560869231696d3595e22d9713b4e83fe28 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Sun, 23 Aug 2026 17:30:14 +0300 Subject: [PATCH 3/3] AX-2134: drop dead should_release output and tighten release docs Co-authored-by: Cursor --- .github/workflows/release.yml | 9 ++++----- CONTRIBUTING.md | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52bc0b6..477c5ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ # Copyright (c) JFrog Ltd. 2026 # -# Cuts a GitHub Release when the version file on main is newer than the latest tag. +# Cuts a GitHub Release when `.claude-plugin/plugin.json` on main is newer than the +# latest tag. Equal or older versions fail the job (they do not no-op). # Full flow and rationale: CONTRIBUTING.md#releasing name: Release @@ -20,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: # fetch-depth: 0 here, plus `git fetch --tags` in the gate below, so the gate sees every - # release tag — including one created by a prior run that was still queued behind this one. + # release tag — including one created by a prior run that this run was queued behind. - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -46,9 +47,8 @@ jobs: git fetch --tags origin # grep exits 1 when no tag matches, which pipefail would turn into a step failure — # an empty LATEST is the legitimate first-release case, handled just below. - LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) || LATEST="" + LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sort -V | tail -1) if [ -z "$LATEST" ]; then - echo "should_release=true" >> "$GITHUB_OUTPUT" echo "No prior release tag — v${VERSION} will be the first release" exit 0 fi @@ -63,7 +63,6 @@ jobs: echo "::error::.claude-plugin/plugin.json version ${VERSION} is older than the latest release ${LATEST} — check for an accidental revert" exit 1 fi - echo "should_release=true" >> "$GITHUB_OUTPUT" echo "Version ${VERSION} is newer than ${LATEST} — proceeding with release" - uses: actions/setup-node@v5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1b0751..07088af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ A merge without a bump therefore turns `Release` red. That is by design, not a b The workflow reads the version from the manifest, 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: +Three 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.