Skip to content
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
yanivt-jfrog marked this conversation as resolved.
Comment thread
yanivt-jfrog marked this conversation as resolved.
Comment thread
yanivt-jfrog marked this conversation as resolved.

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
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading