Repository navigation
Merge pull request #104 from ReScienceLab/release/1.4.1 #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # A release is a version string, because the version is the cache key both | |
| # Claude Code and Codex compare against an install: merging to main ships | |
| # nothing until the number moves. This runs in two halves, because the "Protect | |
| # main" ruleset requires a pull request and no actor bypasses it. | |
| # | |
| # 1. Dispatch this workflow with a version. It runs the gates, bumps every | |
| # manifest with scripts/bump-version.sh, and opens a release PR. | |
| # 2. Merging that PR changes .claude-plugin/plugin.json on main, which fires | |
| # the second half: it tags super-prototyping--v<version> and cuts the | |
| # GitHub Release from the matching RELEASE-NOTES.md section. | |
| # | |
| # Nothing tags a commit that is not on main, and the second half only acts when | |
| # the version actually changed in that push: the tag ruleset forbids moving a | |
| # tag, and an edit to plugin.json that is not a release must not cut one. | |
| # | |
| # Two settings this depends on, neither of them a file in the repo: "Allow | |
| # GitHub Actions to create and approve pull requests" must be on, or the first | |
| # job pushes its branch and then fails to open the PR; and a PR opened by | |
| # GITHUB_TOKEN does not itself trigger `pull_request` workflows, so the release | |
| # PR shows no Validate run until a person pushes to it, which step 4 of the | |
| # release asks for anyway. Either way it is covered: `gates` ran on the commit | |
| # being released, the bump touches only version strings, and the tag job | |
| # re-checks every manifest before `claude plugin tag` runs. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Release version, e.g. 1.1.0' | |
| required: true | |
| type: string | |
| push: | |
| branches: [main] | |
| paths: | |
| - '.claude-plugin/plugin.json' | |
| permissions: | |
| contents: read | |
| jobs: | |
| gates: | |
| name: Gates | |
| # A release is cut from main, so dispatching from a branch would bump a tree | |
| # nobody reviewed and open a PR carrying every commit on it. | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| uses: ./.github/workflows/validate.yml | |
| prepare: | |
| name: Prepare the release PR | |
| # A group per half. Sharing one lets a push queue behind a dispatch and then be | |
| # cancelled by the next push, which would drop a version on the floor untagged. | |
| concurrency: | |
| group: release-prepare | |
| cancel-in-progress: false | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| needs: gates | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Bump every manifest | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| scripts/bump-version.sh "$VERSION" | |
| scripts/bump-version.sh --check | |
| - name: Open the release PR | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if git diff --quiet; then | |
| echo "::error::every manifest is already at $VERSION, so there is nothing to release" | |
| exit 1 | |
| fi | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| # A re-dispatch of the same version must not discard the release notes | |
| # someone has already written on the branch, so the bump is replayed on | |
| # top of what is there. A lease cannot protect that: actions/checkout | |
| # maps only the branch it checked out, so --force-with-lease finds no | |
| # remote-tracking ref for release/<version>, reads the lease as "must | |
| # not exist", and rejects the push whenever the branch is already up. | |
| if git ls-remote --exit-code --heads origin "release/$VERSION" > /dev/null 2>&1; then | |
| git fetch --depth 1 origin "release/$VERSION" | |
| git checkout -f -B "release/$VERSION" FETCH_HEAD | |
| scripts/bump-version.sh "$VERSION" | |
| scripts/bump-version.sh --check | |
| if git diff --quiet; then | |
| echo "release/$VERSION is already at $VERSION; leaving the branch as it is" | |
| else | |
| git commit -am "release $VERSION" | |
| git push origin "release/$VERSION" | |
| fi | |
| else | |
| git switch -c "release/$VERSION" | |
| git commit -am "release $VERSION" | |
| git push --set-upstream origin "release/$VERSION" | |
| fi | |
| if [ -n "$(gh pr list --head "release/$VERSION" --state open --json number --jq '.[].number')" ]; then | |
| echo "a PR for release/$VERSION is already open; pushed the new bump to it" | |
| exit 0 | |
| fi | |
| # The tag and the notes come after the merge; this PR is only the bump. | |
| gh pr create --base main --title "release $VERSION" --body "$(cat <<BODY | |
| Version bump only: \`scripts/bump-version.sh $VERSION\`. | |
| Merging this tags \`super-prototyping--v$VERSION\` and cuts the GitHub | |
| Release from the \`## v$VERSION\` section of \`RELEASE-NOTES.md\`, so write | |
| that section before merging if it is not in yet. | |
| Users are on the old version until this merges: the version string is the | |
| cache key \`/plugin update\` and \`codex plugin marketplace upgrade\` compare. | |
| BODY | |
| )" || { | |
| echo "::error::could not open the PR. The branch release/$VERSION is pushed, so nothing is lost. Open it by hand at https://github.com/${{ github.repository }}/compare/main...release/$VERSION, and check that Settings → Actions → General → 'Allow GitHub Actions to create and approve pull requests' is on." | |
| exit 1 | |
| } | |
| tag: | |
| name: Tag and release | |
| concurrency: | |
| group: release-tag | |
| cancel-in-progress: false | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| # The trigger only says plugin.json was touched, which any edit does. A | |
| # release is the narrower thing: the version *changed* in this push, and no | |
| # tag names it yet. Renaming a field or adding a keyword must not cut a | |
| # release of whatever version happens to be sitting in the file. | |
| - name: Read the version, and stop unless this push released it | |
| id: v | |
| env: | |
| BEFORE: ${{ github.event.before }} | |
| run: | | |
| set -euo pipefail | |
| read_version() { python3 -c 'import json,sys;print(json.load(sys.stdin)["version"])'; } | |
| # Semver precedence, which `sort -V` does not implement: it ranks 1.1.0-rc.1 | |
| # above 1.1.0, so the ordinary rc-to-final release would read as a downgrade | |
| # and a real downgrade would read as a release. A version neither side can | |
| # parse exits 2 here, which counts as "did not move forward" and is not tagged. | |
| newer() { | |
| python3 -c 'import re, sys | |
| def key(v): | |
| m = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", v) | |
| if not m: | |
| sys.exit(2) | |
| pre = m.group(4) | |
| rel = tuple(int(x) for x in m.group(1, 2, 3)) | |
| return rel + ((0, tuple(int(n) for n in re.findall(r"\d+", pre))) if pre else (1, ())) | |
| sys.exit(0 if key(sys.argv[2]) > key(sys.argv[1]) else 1)' "$1" "$2" | |
| } | |
| version=$(read_version < .claude-plugin/plugin.json) | |
| fresh=true | |
| if ! git cat-file -e "$BEFORE:.claude-plugin/plugin.json" 2> /dev/null; then | |
| echo "no plugin.json at $BEFORE, so there is nothing to compare against and nothing to tag" | |
| fresh=false | |
| elif before=$(git show "$BEFORE:.claude-plugin/plugin.json" | read_version); [ "$before" = "$version" ]; then | |
| echo "plugin.json changed but the version did not ($version), so this is not a release" | |
| fresh=false | |
| elif ! newer "$before" "$version"; then | |
| # Reverting the release PR restores the old number on a tree that still | |
| # holds the new code, and the tag ruleset forbids moving a tag once it is | |
| # cut. So a version that does not move forward is a revert, not a release, | |
| # and tagging it would be unfixable. | |
| echo "the version did not move forward ($before to $version), so this is not a release" | |
| fresh=false | |
| elif git rev-parse -q --verify "refs/tags/super-prototyping--v$version" > /dev/null; then | |
| echo "super-prototyping--v$version already exists, so there is nothing to do" | |
| fresh=false | |
| else | |
| echo "this push released $version" | |
| fi | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "fresh=$fresh" >> "$GITHUB_OUTPUT" | |
| - uses: actions/setup-node@v7 | |
| if: steps.v.outputs.fresh == 'true' | |
| with: | |
| node-version: "22" | |
| - name: Install Claude Code | |
| if: steps.v.outputs.fresh == 'true' | |
| # Unpinned, so the version that tagged a release is worth having in the log: | |
| # the tag format and what --strict rejects are both its to change. | |
| run: | | |
| npm install -g @anthropic-ai/claude-code | |
| claude --version | |
| # `claude plugin tag` reads .claude-plugin/plugin.json and the marketplace entry | |
| # and nothing else, so without this the root, Codex, CodeBuddy and pyproject | |
| # versions are unchecked at the moment of tagging: exactly the half-versioned | |
| # release scripts/bump-version.sh exists to prevent. | |
| - name: Every manifest still agrees | |
| if: steps.v.outputs.fresh == 'true' | |
| run: scripts/bump-version.sh --check | |
| # `claude plugin tag` is the tool's own spelling of this repo's tag: it | |
| # validates the plugin, checks plugin.json and the marketplace entry agree on | |
| # the version, and refuses a dirty tree. | |
| - name: Tag | |
| if: steps.v.outputs.fresh == 'true' | |
| run: | | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| claude plugin tag . --push -m 'super-prototyping %s' | |
| - name: Cut the GitHub Release | |
| if: steps.v.outputs.fresh == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ steps.v.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| # The section for this version, up to the next heading of any kind. The | |
| # heading must match exactly: a prefix match would hand `1.1.0` the notes | |
| # written under `## v1.1.0-rc.1`. | |
| awk -v want="## v$VERSION" ' | |
| { sub(/\r$/, ""); sub(/[ \t]+$/, "") } | |
| $0 == want { on = 1; next } | |
| on && /^## / { exit } | |
| on { print } | |
| ' RELEASE-NOTES.md > notes.md | |
| # A version with a suffix is a prerelease; GitHub should not offer it as | |
| # the latest release. | |
| case "$VERSION" in | |
| *-*) prerelease=--prerelease ;; | |
| *) prerelease= ;; | |
| esac | |
| # -s would pass a section that is nothing but blank lines. | |
| if grep -q '[^[:space:]]' notes.md; then | |
| gh release create "super-prototyping--v$VERSION" $prerelease \ | |
| --title "super-prototyping $VERSION" --notes-file notes.md | |
| else | |
| echo "::warning::RELEASE-NOTES.md has no '## v$VERSION' section; falling back to generated notes" | |
| gh release create "super-prototyping--v$VERSION" $prerelease \ | |
| --title "super-prototyping $VERSION" --generate-notes | |
| fi |