Skip to content

Merge pull request #104 from ReScienceLab/release/1.4.1 #13

Merge pull request #104 from ReScienceLab/release/1.4.1

Merge pull request #104 from ReScienceLab/release/1.4.1 #13

Workflow file for this run

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