Repository navigation
Release #26
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 what the app's updater | |
| # compares against the one installed: 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 | |
| # file with scripts/bump-version.sh, and opens a release PR. | |
| # 2. Merging that PR changes canvas/package.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. | |
| # A Windows runner adds the same app as an installer. | |
| # | |
| # 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 package.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 version before it tags. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Release version, e.g. 1.1.0' | |
| required: true | |
| type: string | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'canvas/package.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 | |
| # bump-version.sh relocks tools/uv.lock, which the shims run --frozen. | |
| - uses: astral-sh/setup-uv@v7 | |
| - name: Bump every version | |
| 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 version is already $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 what | |
| the app's updater compares. | |
| 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 package.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 < canvas/package.json) | |
| fresh=true | |
| if ! git cat-file -e "$BEFORE:canvas/package.json" 2> /dev/null; then | |
| echo "no canvas/package.json at $BEFORE, so there is nothing to compare against and nothing to tag" | |
| fresh=false | |
| elif before=$(git show "$BEFORE:canvas/package.json" | read_version); [ "$before" = "$version" ]; then | |
| echo "canvas/package.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" | |
| # The trigger read one file; the desktop app and the toolkit must agree with | |
| # it at the moment of tagging, or the release is half-versioned. | |
| - name: Every version still agrees | |
| if: steps.v.outputs.fresh == 'true' | |
| run: scripts/bump-version.sh --check | |
| - name: Tag | |
| if: steps.v.outputs.fresh == 'true' | |
| env: | |
| VERSION: ${{ steps.v.outputs.version }} | |
| run: | | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| git tag -a "super-prototyping--v$VERSION" -m "super-prototyping $VERSION" | |
| git push origin "super-prototyping--v$VERSION" | |
| - 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 a checkout'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 build's knob for the index it writes | |
| # into dist, 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 macOS calls damaged until | |
| # `xattr -dr com.apple.quarantine` clears it from the installed app. 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 | |
| # The whole history: desktop/stamp.mjs dates each shipped file by its last commit. | |
| with: | |
| fetch-depth: 0 | |
| - uses: oven-sh/setup-bun@v2 | |
| # The example boards stay out of the canvas build, as above. The app ships | |
| # 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 zip --arm64 --x64 --publish never | |
| # The zips are what an installed app updates itself from. latest-mac.yml is | |
| # the feed that names them, so it goes up last: an app that reads it must | |
| # find every file it lists. | |
| gh release upload "super-prototyping--v$VERSION" dist/out/*.dmg dist/out/*.zip dist/out/*.zip.blockmap | |
| gh release upload "super-prototyping--v$VERSION" dist/out/latest-mac.yml | |
| # 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" | |
| auto_updates true | |
| 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 | |
| # The Windows app is that same canvas in that same shell, as one NSIS installer | |
| # for x64 that installs for the current user and asks for no administrator. It | |
| # is built on Windows for the reason the dmg is built on macOS: the platform's | |
| # own tools make its installer. It is not signed | |
| # (docs/2026-09-21-windows-app-unsigned.md), so SmartScreen warns the first time | |
| # it is run. Like the dmgs it is attached after the release exists, so a failure | |
| # here is a missing asset, and the commands below add it by hand. | |
| nsis: | |
| name: Attach the Windows app | |
| needs: tag | |
| if: needs.tag.outputs.fresh == 'true' | |
| runs-on: windows-latest | |
| permissions: | |
| contents: write | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The whole history: desktop/stamp.mjs dates each shipped file by its last commit. | |
| with: | |
| fetch-depth: 0 | |
| - uses: oven-sh/setup-bun@v2 | |
| - 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 and attach the installer | |
| working-directory: desktop | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ needs.tag.outputs.version }} | |
| CSC_IDENTITY_AUTO_DISCOVERY: false | |
| run: | | |
| set -euo pipefail | |
| bun install --frozen-lockfile | |
| bun run build | |
| bunx electron-builder --win nsis --x64 --publish never | |
| # The installer is what people run, and nothing else starts it before they do. | |
| MSYS_NO_PATHCONV=1 dist/out/*.exe /S | |
| "$LOCALAPPDATA/Programs/super-prototyping-desktop/Super Prototyping.exe" --port 5199 "$(mktemp -d)" & | |
| curl --retry 60 --retry-connrefused --retry-delay 1 -fsSL http://127.0.0.1:5199/ | grep -q 'id="root"' | |
| # latest.yml is the feed an installed app updates itself from. Last, as on macOS. | |
| gh release upload "super-prototyping--v$VERSION" dist/out/*.exe dist/out/*.exe.blockmap | |
| gh release upload "super-prototyping--v$VERSION" dist/out/latest.yml |