Repository navigation
Release #18
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>, 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 |