Skip to content

Commit 3dde0f9

Browse files
committed
ci: automate the release — draft notes, sign in CI, publish behind approval
Replaces the hybrid model (CI builds unsigned, you sign on your laptop, then hand-juggle six `gh` commands) with three chained workflows. The only manual steps left are the two that need judgement: writing the prose, and deciding to ship. prepare-release.yml → you edit the PR → tag-on-merge.yml → release.yml (drafts the notes) (the prose) (pushes the tag) (builds, signs, notarizes, waits) **Notes.** `draft-release-notes.mjs` already filled in every fact and left TODO markers where judgement is needed — its header argues that a changelog generated from commit subjects is why most release notes go unread. So the workflow opens a PR with that draft rather than committing it, and `tag-on-merge.yml` refuses to tag while a TODO or the scaffolding block survives, or when the first line doesn't name the version being tagged. Automation cannot ship scaffolding, and it cannot ship last release's notes either. **Signing.** `notarize.sh` already took APPLE_ID + TEAM_ID + APP_SPECIFIC_PASSWORD as the CI alternative to a local keychain profile, so no build script changed. The workflow imports the Developer ID cert into a temporary keychain in RUNNER_TEMP, runs the existing `make-dmg.sh`, verifies with codesign + stapler + spctl, and deletes the keychain in an `always()` step. Two details that are load-bearing: `set-key-partition-list` (without it codesign blocks on a GUI prompt nobody can click and the job hangs to timeout) and a 6h keychain lock timeout (the 5-minute default re-locks mid-notarization and the next codesign fails with a misleading "user interaction is not allowed"). Also `fetch-depth: 0` on the build checkout: `build-macos.sh` stamps the version from `git describe --tags`, and its failure mode is silent — it warns and ships a build whose About box reads 1.126.0, the Code-OSS base. **The gate.** Publishing is deploying: the Squirrel updater installs a published release on every existing install at its next check, with no staged rollout, and the rollback pin cannot un-update anyone who already took it. So the publish job sits in a `release` Environment with required reviewers. Everything before it is reversible — a branch, a tag, a draft. That step is not. After publishing it polls the update feed and warns (not fails — the release is already correct) if the feed hasn't picked it up. The draft job also refuses to publish unless all four assets are present. Losing only the x64 job would otherwise strand every Intel user silently, since the feed serves per-arch. RELEASING.md §7 rewritten: the six secrets, the environment setup (called out hard — GitHub creates a missing environment with NO protection rules, so skipping it makes the gate decorative), the new flow, and the trade-off the old §7 named when it described this as the road not taken: the signing identity now lives in the cloud. Revocation path and blast-radius limits documented, along with how to go back. Verified: actionlint (which shellchecks every run: block) clean on all three. The notes gate was extracted from the workflow and exercised — real v1.0.4 notes pass; a real generated draft fails on TODOs and scaffolding; TODOs-removed-but- scaffolding-left still fails; v1.0.4 notes presented as v1.0.5 fail as stale; and generate → fill → delete scaffolding passes.
1 parent 95ddcad commit 3dde0f9

4 files changed

Lines changed: 464 additions & 65 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
name: Prepare release
2+
3+
# Step 1 of 3 in the release pipeline (docs/RELEASING.md §7):
4+
#
5+
# prepare-release.yml → you edit the PR → tag-on-merge.yml → release.yml
6+
# (this file) (the only prose (pushes vX.Y.Z) (builds, signs,
7+
# step that is notarizes, waits
8+
# still yours) for your approval)
9+
#
10+
# What this does: drafts RELEASE-NOTES.md for the next version and opens a PR with it.
11+
#
12+
# Why a PR rather than a straight commit + tag. scripts/draft-release-notes.mjs fills in every
13+
# FACT (commit range, PRs, previous tag, suite and case counts, the compare URL) and deliberately
14+
# leaves `<!-- TODO -->` markers where judgement is required — which two of fourteen commits
15+
# actually matter, what to lead with, how to frame a change so it is not misread. Its own header
16+
# argues the case: "a changelog auto-generated from commit subjects is the reason most release
17+
# notes go unread." So the tedious, misrememberable 90% is automated and the prose stays human,
18+
# with a PR as the place to write it. tag-on-merge.yml then refuses to tag while any TODO remains,
19+
# so the automation cannot ship scaffolding.
20+
#
21+
# There is NO version file to bump. The release version is derived from the tag at build time
22+
# (`git describe --tags` in scripts/build-macos.sh), so the tag IS the version and this workflow
23+
# only has to produce notes.
24+
25+
on:
26+
workflow_dispatch:
27+
inputs:
28+
version:
29+
description: "Release version, without the leading v (e.g. 1.0.5)"
30+
required: true
31+
32+
permissions:
33+
contents: write # push the release/vX.Y.Z branch
34+
pull-requests: write # open the PR
35+
36+
jobs:
37+
prepare:
38+
runs-on: ubuntu-latest
39+
steps:
40+
- uses: actions/checkout@v7
41+
with:
42+
# draft-release-notes.mjs walks `prevTag..HEAD` and reads `git tag --list`, so a shallow
43+
# clone would silently produce an empty or wrong range — the class of quiet mistake this
44+
# whole pipeline exists to remove.
45+
fetch-depth: 0
46+
47+
- uses: actions/setup-node@v7
48+
with:
49+
node-version: "24"
50+
51+
- name: Validate the version and refuse to reuse a tag
52+
env:
53+
# Through the environment, never interpolated into the script body: `${{ inputs.version }}`
54+
# inline would splice user text straight into the shell.
55+
VERSION: ${{ inputs.version }}
56+
run: |
57+
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
58+
echo "::error::\"$VERSION\" is not X.Y.Z. Pass the version without a leading v."
59+
exit 1
60+
fi
61+
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
62+
echo "::error::Tag v$VERSION already exists. Releases are immutable — pick the next version."
63+
exit 1
64+
fi
65+
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
66+
67+
- name: Draft RELEASE-NOTES.md
68+
run: node scripts/draft-release-notes.mjs "$VERSION" --write
69+
70+
- name: Open the release PR
71+
env:
72+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73+
run: |
74+
BRANCH="release/v$VERSION"
75+
git config user.name "github-actions[bot]"
76+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
77+
git checkout -b "$BRANCH"
78+
git add RELEASE-NOTES.md
79+
80+
# A release whose range contains nothing is a mistake worth catching here rather than
81+
# three workflows later, at the point where it would have published an empty release.
82+
if git diff --cached --quiet; then
83+
echo "::error::draft-release-notes.mjs produced no change — is there anything to release since the last tag?"
84+
exit 1
85+
fi
86+
87+
git commit -m "release: draft notes for v$VERSION"
88+
git push --set-upstream origin "$BRANCH"
89+
90+
# QUOTED heredoc + placeholder, deliberately. The body is full of backticks, so an
91+
# unquoted heredoc would run them as command substitution; a quoted one leaves them
92+
# alone but also leaves $VERSION literal, hence the sed. Written to a file rather than
93+
# inlined so `gh` gets it verbatim, with no second round of shell parsing.
94+
cat > /tmp/pr-body.md <<'BODY'
95+
Drafted by `.github/workflows/prepare-release.yml`. **The facts are filled in; the prose is yours.**
96+
97+
### Before merging
98+
99+
1. Replace every `<!-- TODO … -->` in `RELEASE-NOTES.md` with real prose.
100+
2. Read the **excluded as internal** list at the bottom — anything user-visible in there belongs in the notes.
101+
3. Delete the whole scaffolding block (everything under `EVERYTHING BELOW IS SCAFFOLDING`).
102+
103+
`tag-on-merge.yml` refuses to tag while any TODO or scaffolding marker survives, so a half-finished
104+
draft cannot reach users.
105+
106+
### What merging does
107+
108+
Merging this PR pushes the `v__VERSION__` tag, which starts `release.yml`: both arches build on native
109+
runners, get signed with the Developer ID cert and notarized by Apple, and land on a **draft** release
110+
with these notes as the body.
111+
112+
It then **waits for your approval** in the Actions tab before publishing — because publishing is
113+
deploying. Auto-update installs a published release on every existing install at its next check, and
114+
that cannot be reversed for anyone who already took it (`docs/RELEASING.md` §5). Download the dmg from
115+
the draft and launch it on a real Mac before you approve.
116+
BODY
117+
sed -i "s/__VERSION__/$VERSION/g" /tmp/pr-body.md
118+
119+
gh pr create \
120+
--base "${{ github.ref_name }}" \
121+
--head "$BRANCH" \
122+
--title "release: v$VERSION" \
123+
--body-file /tmp/pr-body.md

.github/workflows/release.yml

Lines changed: 178 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
11
name: Build release apps
22

3-
# Hybrid release model (see docs/RELEASING.md): CI does the heavy, awkward part — building BOTH
4-
# macOS arches on their NATIVE runners (you can't easily build x64 on an Apple-silicon Mac) — with
5-
# NO signing secrets. It produces unsigned .app bundles and attaches them to a DRAFT release. You
6-
# then sign + notarize + staple LOCALLY (your Developer ID cert never leaves your machine), attach
7-
# the notarized dmgs, delete the UNSIGNED-*.app.zip assets, and publish.
3+
# Step 3 of 3 (docs/RELEASING.md §7): prepare-release.yml → tag-on-merge.yml → THIS.
4+
#
5+
# CI now does the whole pipeline — build both macOS arches on their NATIVE runners (you can't
6+
# easily build x64 on an Apple-silicon Mac), sign with the Developer ID cert, notarize with Apple,
7+
# staple, and attach the four release assets to a DRAFT release carrying RELEASE-NOTES.md.
8+
#
9+
# Then it STOPS and waits for you.
10+
#
11+
# That pause is the point, not a limitation. Publishing a release is deploying it: the built-in
12+
# Squirrel updater installs a published release on every existing install at its next check, with
13+
# no staged rollout, and the rollback pin "cannot un-update anyone who already took it"
14+
# (docs/RELEASING.md §5). So the publish job runs in the `release` GitHub Environment, which
15+
# requires a reviewer. Download the draft's dmg, launch it on a real Mac, then approve.
16+
#
17+
# THE SIGNING SECRETS (repo → Settings → Secrets and variables → Actions):
18+
# APPLE_CERT_P12_BASE64 Developer ID Application cert + private key, exported as .p12
19+
# from Keychain Access, then `base64 -i cert.p12 | pbcopy`.
20+
# APPLE_CERT_PASSWORD the password you set on that .p12 export.
21+
# APPLE_SIGNING_IDENTITY e.g. "Developer ID Application: NAME (TEAMID)" — exactly as
22+
# `security find-identity -v -p codesigning` prints it.
23+
# APPLE_ID the Apple ID that owns the Developer Program membership.
24+
# APPLE_TEAM_ID the (TEAMID) from the identity string.
25+
# APPLE_APP_SPECIFIC_PASSWORD appleid.apple.com → Sign-In and Security → App-Specific Passwords.
26+
#
27+
# scripts/notarize.sh already supported this path — it takes APPLE_ID + TEAM_ID +
28+
# APP_SPECIFIC_PASSWORD as the alternative to a local `NOTARY_PROFILE` keychain profile — so
29+
# nothing in the build scripts changed to enable CI signing.
830

931
on:
1032
push:
@@ -91,6 +113,11 @@ jobs:
91113
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
92114
steps:
93115
- uses: actions/checkout@v7
116+
with:
117+
# build-macos.sh stamps the release version from `git describe --tags`. A shallow clone
118+
# can leave that resolving to nothing, and its failure mode is SILENT — it warns and
119+
# ships a build whose About box reads "1.126.0", the Code-OSS base. Fetch everything.
120+
fetch-depth: 0
94121
- uses: actions/setup-node@v7
95122
with:
96123
node-version: "24" # bootstrap.sh checks the .nvmrc major; 24.x satisfies the 1.126 pin
@@ -106,48 +133,170 @@ jobs:
106133
VSCODE_TAG: ${{ github.event.inputs.vscode_tag }}
107134
- name: Build LevelCode.app (${{ matrix.arch }}, proprietary stripped)
108135
run: ./scripts/build-macos.sh ${{ matrix.arch }}
109-
- name: Zip the unsigned app
110-
run: ditto -c -k --sequesterRsrc --keepParent "VSCode-darwin-${{ matrix.arch }}/LevelCode.app" "UNSIGNED-LevelCode-${{ matrix.arch }}.app.zip"
111-
- name: Upload app artifact
136+
137+
- name: Import the Developer ID certificate
138+
env:
139+
CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
140+
CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
141+
run: |
142+
# A DEDICATED keychain in RUNNER_TEMP, never the login keychain. The runner is ephemeral,
143+
# but this also keeps the cert out of anything a later step might enumerate, and makes
144+
# cleanup a single file delete.
145+
KEYCHAIN="$RUNNER_TEMP/levelcode-signing.keychain-db"
146+
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
147+
CERT_PATH="$RUNNER_TEMP/certificate.p12"
148+
149+
echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
150+
151+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
152+
# Default keychains re-lock after 5 minutes of inactivity; notarization waits on Apple for
153+
# longer than that, and a re-locked keychain fails the NEXT codesign call with a misleading
154+
# "user interaction is not allowed". 6h, and no auto-lock on sleep.
155+
security set-keychain-settings -lut 21600 "$KEYCHAIN"
156+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
157+
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
158+
-T /usr/bin/codesign -T /usr/bin/security
159+
# Without this, codesign blocks on a GUI "allow access to your keychain?" prompt that no
160+
# one is there to click, and the job hangs until it times out rather than failing.
161+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
162+
# Put it on the search list so codesign finds the identity by name.
163+
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
164+
165+
rm -f "$CERT_PATH"
166+
167+
# Prove the identity the secrets describe is actually present, here — rather than 40
168+
# minutes later inside notarize.sh with a vaguer error.
169+
security find-identity -v -p codesigning "$KEYCHAIN" | grep -q "Developer ID Application" \
170+
|| { echo "::error::No 'Developer ID Application' identity in the imported keychain — check APPLE_CERT_P12_BASE64 / APPLE_CERT_PASSWORD."; exit 1; }
171+
172+
- name: Sign, notarize, staple, and package (${{ matrix.arch }})
173+
env:
174+
CODESIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
175+
# notarize.sh takes these three as the CI alternative to a stored NOTARY_PROFILE.
176+
APPLE_ID: ${{ secrets.APPLE_ID }}
177+
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
178+
APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
179+
# Emits BOTH release assets for this arch: LevelCode-<arch>.dmg (humans) and
180+
# LevelCode-<arch>.app.zip (the Squirrel update feed). The .app.zip is written only on the
181+
# Developer-ID path, because Squirrel refuses an update whose signing identity differs from
182+
# the running app — so an ad-hoc build must never be served as one.
183+
run: ./scripts/make-dmg.sh ${{ matrix.arch }}
184+
185+
- name: Verify the signature and the stapled ticket
186+
# Independent of make-dmg.sh's own checks: this is the assertion that what we are about to
187+
# hand every existing install will actually launch on a clean Mac, offline. An un-stapled
188+
# app passes codesign and still fails Gatekeeper on first run.
189+
run: |
190+
APP="VSCode-darwin-${{ matrix.arch }}/LevelCode.app"
191+
codesign --verify --deep --strict --verbose=2 "$APP"
192+
xcrun stapler validate "$APP"
193+
xcrun stapler validate "LevelCode-${{ matrix.arch }}.dmg"
194+
spctl --assess --type execute --verbose "$APP"
195+
196+
- name: Upload the signed release assets
112197
uses: actions/upload-artifact@v7
113198
with:
114-
name: UNSIGNED-LevelCode-${{ matrix.arch }}
115-
path: UNSIGNED-LevelCode-${{ matrix.arch }}.app.zip
199+
name: LevelCode-${{ matrix.arch }}
200+
path: |
201+
LevelCode-${{ matrix.arch }}.dmg
202+
LevelCode-${{ matrix.arch }}.app.zip
116203
if-no-files-found: error
117204
retention-days: 14
118205

206+
- name: Delete the signing keychain
207+
# `always()` so a failed build still takes the cert with it.
208+
if: always()
209+
run: security delete-keychain "$RUNNER_TEMP/levelcode-signing.keychain-db" || true
210+
119211
draft-release:
120212
name: Draft release
121213
needs: build
122214
if: startsWith(github.ref, 'refs/tags/')
123215
runs-on: ubuntu-latest
124216
steps:
217+
# RELEASE-NOTES.md at the tag IS the release body. tag-on-merge.yml has already refused to
218+
# create this tag unless the notes are finished and name this version, so there is nothing
219+
# left to check here.
220+
- uses: actions/checkout@v7
125221
- uses: actions/download-artifact@v8
126222
with:
127223
path: apps
128224
merge-multiple: true
129-
- name: Create / refresh the draft release with the UNSIGNED apps
225+
226+
- name: Check all four assets arrived
227+
# `fail_on_unmatched_files` below only catches a glob matching NOTHING. Losing one arch —
228+
# the x64 job failing while arm64 succeeds — would still publish, and would silently strand
229+
# every Intel user on their current version, because the update feed serves per-arch.
230+
run: |
231+
MISSING=0
232+
for f in LevelCode-arm64.dmg LevelCode-x64.dmg LevelCode-arm64.app.zip LevelCode-x64.app.zip; do
233+
if [ -f "apps/$f" ]; then
234+
echo " ok $f ($(du -h "apps/$f" | cut -f1))"
235+
else
236+
echo "::error::missing release asset: $f"
237+
MISSING=1
238+
fi
239+
done
240+
[ "$MISSING" -eq 0 ] || exit 1
241+
242+
- name: Create the draft release
130243
uses: softprops/action-gh-release@v2
131244
with:
132245
draft: true
133246
name: LevelCode ${{ github.ref_name }}
134247
tag_name: ${{ github.ref_name }}
135-
files: apps/*.zip
248+
files: |
249+
apps/LevelCode-arm64.dmg
250+
apps/LevelCode-x64.dmg
251+
apps/LevelCode-arm64.app.zip
252+
apps/LevelCode-x64.app.zip
136253
fail_on_unmatched_files: true
137-
body: |
138-
**Draft — not for release as-is.** The attached `UNSIGNED-LevelCode-<arch>.app.zip`
139-
files are CI build artifacts with **no Developer ID signature or notarization**.
140-
141-
To finish the release **locally** (your signing cert never touches CI):
142-
143-
1. `gh release download ${{ github.ref_name }} --pattern 'UNSIGNED-*.app.zip'`
144-
2. For each arch — unzip into `VSCode-darwin-<arch>/`, then
145-
`CODESIGN_IDENTITY="Developer ID Application: …" NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh <arch>`
146-
(signs → notarizes → staples → `LevelCode-<arch>.dmg` **and** `LevelCode-<arch>.app.zip`).
147-
3. `gh release upload ${{ github.ref_name }} LevelCode-arm64.dmg LevelCode-x64.dmg LevelCode-arm64.app.zip LevelCode-x64.app.zip`
148-
— the `.dmg`s are for humans, the `.app.zip`s are the auto-update feed assets (`docs/AUTO-UPDATE.md`).
149-
Upload exactly these four; the `.app.zip.sha256` files `make-dmg.sh` writes stay **local**
150-
(the feed reads GitHub's own asset `digest`, never a sidecar).
151-
4. **Delete the `UNSIGNED-*.app.zip` assets**, add real notes, and publish.
152-
153-
Full runbook: `docs/RELEASING.md`.
254+
body_path: RELEASE-NOTES.md
255+
256+
publish:
257+
name: Publish (requires approval)
258+
needs: draft-release
259+
if: startsWith(github.ref, 'refs/tags/')
260+
runs-on: ubuntu-latest
261+
# THE GATE. This environment must have "Required reviewers" configured in
262+
# repo → Settings → Environments → release. Without that setting the job runs immediately and
263+
# the gate is decorative — see docs/RELEASING.md §7 for the one-time setup.
264+
#
265+
# Why a human stands here: publishing is deploying. The Squirrel updater installs a published
266+
# release on every existing install at its next check, there is no staged rollout, and the
267+
# rollback pin cannot un-update anyone who already took it. Everything before this point is
268+
# reversible; this step is not.
269+
environment: release
270+
permissions:
271+
contents: write
272+
steps:
273+
- name: Publish the release
274+
env:
275+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
276+
run: |
277+
gh release edit "${{ github.ref_name }}" \
278+
--repo "${{ github.repository }}" \
279+
--draft=false --latest
280+
echo "::notice::${{ github.ref_name }} is live. Auto-update will begin serving it within ~5 minutes (feed cache)."
281+
282+
- name: Confirm the update feed picked it up
283+
# The release is only half the story: Levelcode::EditorReleaseFeed (thin.ly) reads
284+
# releases/latest and serves the update endpoint. If this does not flip, users never see
285+
# the release — so surface it here rather than leaving it to a manual curl in the runbook.
286+
run: |
287+
EXPECTED="${GITHUB_REF_NAME#v}"
288+
for attempt in 1 2 3 4 5 6; do
289+
sleep 60 # the feed caches for 5 minutes
290+
BODY="$(curl -fsS -H 'User-Agent: LevelCode Updater' \
291+
https://levelcode.ai/api/update/darwin-arm64/stable/deadbeef || true)"
292+
case "$BODY" in
293+
*"$EXPECTED"*)
294+
echo "::notice::Update feed is serving $EXPECTED."
295+
exit 0 ;;
296+
esac
297+
echo "attempt $attempt: feed not showing $EXPECTED yet"
298+
done
299+
# A warning, not a failure: the release IS published and correct at this point, and
300+
# failing the job here would imply otherwise. The feed lagging is a thin.ly-side thing to
301+
# go and look at.
302+
echo "::warning::Feed did not report $EXPECTED within 6 minutes — check Levelcode::EditorReleaseFeed and the LEVELCODE_UPDATE_FEED rollback pin."

0 commit comments

Comments
 (0)