diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bbc5f72 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +# 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: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + # Full history, so the tag check below can see existing tags. + - uses: actions/checkout@v5 + 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 + 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" + + # 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: | + 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 + + - uses: actions/setup-node@v5 + if: steps.detect.outputs.triggered == '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' + 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' + 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 }} + run: | + gh release create "v${{ steps.version.outputs.version }}" \ + release.zip \ + --target "$GITHUB_SHA" \ + --title "Release v${{ steps.version.outputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06018fd..e1fea83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,25 @@ 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]` 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 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 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 Open a [GitHub issue](https://github.com/jfrog/claude-plugin/issues) with: