diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8d9842..31bc276 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,29 +24,14 @@ 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 - # plugin.json is canonical; marketplace.json carries its own copy, so the two are # cross-checked here as well as by the validate-version PR check. - name: Read version from the plugin manifest - if: steps.detect.outputs.triggered == 'true' id: version run: | set -euo pipefail VERSION=$(jq -er '.version' plugin/.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::plugin.json version '$VERSION' is not X.Y.Z — refusing to release" exit 1 fi @@ -57,33 +42,68 @@ 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' + # Fails the job on anything but a clean forward bump, so the steps after it need no + # condition of their own. + - name: Compare version against latest release tag + id: release_gate + env: + VERSION: ${{ steps.version.outputs.version }} 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 the plugin manifests before merging a release marker" + set -euo pipefail + git fetch --tags origin + # git's globs can't anchor digits, so the shape is enforced with grep instead. + LATEST=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + if [ -z "$LATEST" ]; then + 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 the plugin manifests 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::plugin.json version ${VERSION} is older than the latest release ${LATEST} — check for an accidental revert" exit 1 fi + echo "Version ${VERSION} is newer than ${LATEST} — proceeding with release" # NOTE: no plugin-layout validator exists in this repo to gate the release on. If one # lands, run it as a step here. See CONTRIBUTING.md#releasing. # 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 the release, so a failure can't leave an orphan tag. - name: Create GitHub Release - if: steps.detect.outputs.triggered == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} run: | - gh release create "v${{ steps.version.outputs.version }}" \ + set -euo pipefail + gh release create "v${VERSION}" \ release.zip \ --target "$GITHUB_SHA" \ - --title "Release v${{ steps.version.outputs.version }}" \ + --title "Release v${VERSION}" \ --generate-notes + + # Covers the case where the release is created but the asset upload fails. Gated on the + # gate passing, so a v$VERSION tag or release can only have been created by this run — + # never by an earlier, legitimate release. + - name: Clean up a partial release + if: failure() && steps.release_gate.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo "Removing partially published release v${VERSION}" + gh release delete "v${VERSION}" --yes --cleanup-tag + elif git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then + echo "Removing orphan tag v${VERSION}" + git push origin --delete "refs/tags/v${VERSION}" + fi diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml index 0449104..f9ea1fc 100644 --- a/.github/workflows/validate-version.yml +++ b/.github/workflows/validate-version.yml @@ -14,11 +14,16 @@ jobs: # This repo has two manifests that both carry the version: the plugin's own # plugin/.claude-plugin/plugin.json, and marketplace.json which lists it for the # marketplace. plugin.json is canonical; this check keeps marketplace.json in step with it - # so a release can't ship two different version numbers. - - name: Check version consistency + # so a release can't ship two different version numbers. The X.Y.Z shape is checked here + # too, so a malformed version is caught in the PR rather than by the release job. + - name: Check version shape and consistency run: | set -euo pipefail VERSION=$(jq -er '.version' plugin/.claude-plugin/plugin.json) + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::plugin/.claude-plugin/plugin.json version '$VERSION' is not X.Y.Z" + exit 1 + fi MARKET_VERSION=$(jq -er '.plugins[] | select(.name == "jfrog") | .version' marketplace.json) if [ "$VERSION" != "$MARKET_VERSION" ]; then echo "::error::Version mismatch: plugin/.claude-plugin/plugin.json is $VERSION but marketplace.json lists $MARKET_VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccaebc2..f7e8b12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,14 +18,11 @@ All contributors must sign the [JFrog CLA](https://jfrog.com/cla/) before contri To cut a release: 1. In your PR, bump `.version` in [`plugin/.claude-plugin/plugin.json`](plugin/.claude-plugin/plugin.json) and sync the matching entry in [`marketplace.json`](marketplace.json) to match. `plugin.json` is canonical; the `validate-version` PR check enforces that the two agree. -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 manifests fails the release rather than re-tagging a shipped version. +This applies to **every** merge to `main`, including docs-only and chore changes: the bump is reviewed in the PR that makes it, and a merge that leaves both manifests untouched fails the `Release` workflow rather than silently skipping or re-tagging a shipped version. That failure is by design — it is the signal that the merge shipped without a version, not a broken workflow. Fix it by opening a follow-up PR that bumps both manifests. -The workflow reads the version from `plugin.json`, confirms `marketplace.json` agrees, refuses to continue if that version is already tagged, 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 `plugin.json`, confirms `marketplace.json` agrees, 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: diff --git a/marketplace.json b/marketplace.json index aaa1a89..8953ed2 100644 --- a/marketplace.json +++ b/marketplace.json @@ -9,7 +9,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.18", + "version": "1.0.19", "license": "Apache-2.0", "source": "plugin", "categories": [ diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index dfeafb3..4450e32 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.18", + "version": "1.0.19", "license": "Apache-2.0", "author": { "name": "JFrog",