Skip to content

Release

Release #18

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>, cuts the
# GitHub Release from the matching RELEASE-NOTES.md section, and attaches
# the canvas, built without the example boards, as canvas-dist.tgz.
# Re-running a run whose attach step failed does that last part again for
# the tag already cut. A macOS runner then adds the app, a dmg per
# architecture, and writes the Homebrew tap's cask to name those two dmgs.
#
# 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
outputs:
version: ${{ steps.v.outputs.version }}
fresh: ${{ steps.v.outputs.fresh }}
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 }}
GH_TOKEN: ${{ github.token }}
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 this push cuts nothing"
fresh=false
else
echo "this push released $version"
fi
# A release whose bundle step failed has a tag and a release and no
# canvas-dist.tgz. The tag cannot be cut again, but the asset can, so
# re-running the failed run takes just that half: the repair is one click,
# not `gh release upload` by hand.
# Only the run at the commit the tag names, which is what a re-run is: a later
# push would build the bundle from another tree, and a revert restores an
# older version whose release is not this push's to touch.
attach=$fresh
tagged=$(git rev-parse -q --verify "refs/tags/super-prototyping--v$version^{commit}" || true)
if [ "$fresh" = false ] && [ "$tagged" = "$GITHUB_SHA" ]; then
assets=$(gh release view "super-prototyping--v$version" --json assets --jq '.assets[].name' 2> /dev/null || true)
if ! grep -qx canvas-dist.tgz <<< "$assets"; then
echo "super-prototyping--v$version is cut here but has no canvas-dist.tgz, so this run attaches it"
attach=true
fi
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "fresh=$fresh" >> "$GITHUB_OUTPUT"
echo "attach=$attach" >> "$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
- uses: oven-sh/setup-bun@v2
if: steps.v.outputs.attach == 'true'
# The canvas, built once here so that an install does not have to: `sp
# start` fetches this bundle for the plugin's version on first start (#108), by
# this asset name under this tag. The example boards stay out, because an
# install shows the user's boards and not this repo's;
# PROTOTYPING_CANVASES_DIR is the same knob `sp start` sets,
# and an empty directory builds an empty canvas. After the release rather than
# before it: the tag is pushed by now and the ruleset forbids moving it, so a
# build failure must not leave a tag with no release behind it. A failure here
# is a missing asset, and every `sp start` on this version stops and prints
# the URL it wanted until re-running this run attaches it (`attach` above).
- name: Attach the canvas, built without the example boards
if: steps.v.outputs.attach == 'true'
working-directory: canvas
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
run: |
bun install --frozen-lockfile
PROTOTYPING_CANVASES_DIR="$(mktemp -d)" bun run build
tar -czf "$RUNNER_TEMP/canvas-dist.tgz" dist
gh release upload "super-prototyping--v$VERSION" "$RUNNER_TEMP/canvas-dist.tgz"
# The macOS app (#111) is the canvas built exactly as above, wrapped in Electron
# by desktop/, one dmg per architecture. It has its own runner, because signing
# and notarising need macOS. It runs after `tag` for the same reason the bundle
# is attached last, so a failure here is a missing asset. `gh release upload`
# by hand adds that asset, and the commands below are the list to run. Signing
# turns on when the secrets are present and stays off otherwise, so a fork or a
# checkout without them builds an unsigned dmg, which Gatekeeper refuses until
# the `xattr` line in README's Install section clears its quarantine. That line
# goes once the secrets are set. The secrets are listed by name, and their
# values are nowhere in this repo:
#
# MAC_CERT_P12 Developer ID Application certificate, a .p12, base64
# MAC_CERT_PASSWORD the password that .p12 was exported with
# APPLE_API_KEY_P8 App Store Connect API key, the .p8, base64
# APPLE_API_KEY_ID that key's id
# APPLE_API_ISSUER that key's issuer id
#
# electron-builder signs when CSC_LINK holds the certificate and notarises when
# the three APPLE_API_* variables are set. Notarising is
# `xcrun notarytool submit --wait`, then `xcrun stapler staple`, through
# @electron/notarize.
dmg:
name: Attach the macOS app
needs: tag
if: needs.tag.outputs.fresh == 'true'
runs-on: macos-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
# The example boards stay out of the canvas build, as above. The app ships
# mockups/canvases itself, as the read-only examples its server shows
# (extraResources in desktop/package.json), and a second copy under dist/board
# would double the dmg.
- name: Build the canvas, without the example boards
working-directory: canvas
run: |
bun install --frozen-lockfile
PROTOTYPING_CANVASES_DIR="$(mktemp -d)" bun run build
- name: Build, sign, notarise and attach the dmgs
working-directory: desktop
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.tag.outputs.version }}
MAC_CERT_P12: ${{ secrets.MAC_CERT_P12 }}
MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
if [ -n "$MAC_CERT_P12" ]; then
# A certificate without the notarisation key would ship a signed dmg
# that Gatekeeper still refuses, so it is all five secrets or none.
: "${MAC_CERT_PASSWORD:?}" "${APPLE_API_KEY_P8:?}" "${APPLE_API_KEY_ID:?}" "${APPLE_API_ISSUER:?}"
printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
export CSC_LINK="$MAC_CERT_P12" CSC_KEY_PASSWORD="$MAC_CERT_PASSWORD" APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
else
echo "::warning::MAC_CERT_P12 is not set, so the dmgs are unsigned"
export CSC_IDENTITY_AUTO_DISCOVERY=false
fi
bun install --frozen-lockfile
bun run build
bunx electron-builder --mac dmg --arm64 --x64 --publish never
gh release upload "super-prototyping--v$VERSION" dist/out/*.dmg
# Homebrew installs the app through this cask and nothing else, and the cask
# names the two dmgs just uploaded, so `brew install --cask` and the release
# page hand over the same file. The cask is written whole from here, so the tap
# is pure output and its text lives in this one place. Stable releases only: the
# cask is what `brew install` hands everyone, so naming a prerelease would ship
# the rc to every user. Not gated on signing: an unsigned dmg is
# quarantined the same whether a browser or Homebrew downloaded it.
# HOMEBREW_TAP_TOKEN is a fine-grained token with contents write on
# ReScienceLab/homebrew-tap and nothing else; the org disallows deploy keys,
# which would have been narrower still. Re-running does not repair a failed
# push, because the upload above refuses an asset that is already there:
# `shasum -a 256` the two released dmgs and put the version and both checksums
# into the tap's cask by hand.
- uses: actions/checkout@v7
if: ${{ !contains(needs.tag.outputs.version, '-') }}
with:
repository: ReScienceLab/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
- name: Write the Homebrew cask for this release
if: ${{ !contains(needs.tag.outputs.version, '-') }}
working-directory: tap
env:
VERSION: ${{ needs.tag.outputs.version }}
run: |
set -euo pipefail
arm=$(shasum -a 256 "../desktop/dist/out/Super-Prototyping-$VERSION-arm64.dmg" | cut -d' ' -f1)
intel=$(shasum -a 256 "../desktop/dist/out/Super-Prototyping-$VERSION-x64.dmg" | cut -d' ' -f1)
mkdir -p Casks
# Unquoted, so the shell fills in the version and the two checksums. Ruby's
# `#{version}` and `#{arch}` hold no `$` and arrive as written.
cat > Casks/super-prototyping.rb <<RUBY
cask "super-prototyping" do
arch arm: "arm64", intel: "x64"
version "$VERSION"
sha256 arm: "$arm",
intel: "$intel"
url "https://github.com/ReScienceLab/super-prototyping/releases/download/super-prototyping--v#{version}/Super-Prototyping-#{version}-#{arch}.dmg"
name "Super Prototyping"
desc "Prototyping canvas and skills for coding agents"
homepage "https://github.com/ReScienceLab/super-prototyping"
depends_on macos: :ventura
app "Super Prototyping.app"
zap trash: [
"~/.cache/super-prototyping",
"~/.local/state/super-prototyping",
"~/Library/Preferences/com.rescience.super-prototyping.plist",
]
end
RUBY
if [ -z "$(git status --porcelain)" ]; then
echo "the cask already names $VERSION with these checksums"
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add Casks/super-prototyping.rb
git commit -m "super-prototyping $VERSION"
git push