Repository navigation
483 lines (449 loc) · 22.5 KB
/
Copy pathrelease.yml
File metadata and controls
483 lines (449 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
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 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 build carries no boards of its own,
# since an install shows the user's. 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
if: steps.v.outputs.attach == 'true'
working-directory: canvas
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
run: |
bun install --frozen-lockfile
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 canvas build carries no boards, as above. The app ships the template
# itself (extraResources in desktop/package.json), and every other example
# is a community project it reads from the site
# (docs/2026-09-26-projects-on-demand.md).
- name: Build the canvas
working-directory: canvas
run: |
bun install --frozen-lockfile
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
working-directory: canvas
run: |
bun install --frozen-lockfile
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