Goal: make the editor's built-in Check for Updates… actually install a new build, instead of always reporting "There are currently no updates available."
Today: that dialog is correct behaviour, not a bug. Api::UpdatesController (thin.ly) serves
204 to every client except the notify-only extension — a deliberate unsigned-build guard,
because the built-in Squirrel updater auto-downloads and installs whatever url a 200 returns, and
today that url is a GitHub release page, not a signed .zip. Squirrel would download it and fail.
Verified against production while scoping:
| Client | Request | Response |
|---|---|---|
| Built-in Squirrel updater | GET /api/update/darwin-arm64/stable/<commit> |
204 (guard) |
Notify-only extension (User-Agent: LevelCode Updater) |
same | 200 productVersion: 0.7.2 |
scripts/notarize.sh sign— Developer ID signing with hardened runtime +levelcode.entitlements.scripts/notarize.sh notarize-app— notarizes and staples the.appitself, precisely so a copied-out app validates offline. This is exactly the artifact Squirrel needs.- CI already zips an app with
ditto -c -k --sequesterRsrc --keepParent(for the UNSIGNED artifact) — the same command, applied to the signed app, produces the feed asset. Api::UpdatesControlleralready implements the full Code-OSS feed contract, the guard, andLEVELCODE_UPDATE_FEED(a JSON env override) — which doubles as the rollback/pin lever.Levelcode::EditorReleaseFeedalready resolves tag → commit,product_version, and timestamp.
So this is not a new signing pipeline. It is: publish one more asset, teach the feed to point at it, then lift the guard.
- Release artifact. Produce
LevelCode-<arch>.app.zipfrom the signed + notarized + stapled.app(adittoaftermake-dmg.sh's signing step) and publish it on the release. The.dmgstays — it remains the fresh-install path; the.zipis update-only. The zip is the only new release asset.make-dmg.shalso writes a.app.zip.sha256beside it, but that stays local: the feed'ssha256hashcomes from GitHub's API-computed assetdigest("sha256:<hex>"), so no sidecar is ever fetched. The file is for verifying by hand that the zip you published is the zip you built. - Feed asset resolution.
EditorReleaseFeed#fetch_releasecurrently returnsurl: rel["html_url"](the release page) andsha256hash: nil. It must select the right asset fromrel["assets"]by arch and return itsbrowser_download_urlplus the hash from that asset's owndigestfield. - Arch mapping. Feed targets are
darwin-arm64anddarwin(Intel). Map to the arm64 / x64 zips respectively — never serve a cross-arch zip. - Lift the guard. Set
LEVELCODE_UPDATE_FEED_SIGNED=1on Elastic Beanstalk — only after 1–3. - Notify-only Download button.
extensions/levelcode-updater/extension.js:96wasfeed.url || product.downloadUrl || base. Oncefeed.urlis a raw.zip, that button hands users a zip instead of a page. NowU.downloadUrl(feed, product, base)—product.downloadUrl→feed.releaseNotesUrl→ base, droppingfeed.urlfrom the human path entirely. See the Sequencing risk below: this gated the next release, not the flag flip.
S1 — publish the signed zip (client). ✅ done. Add the ditto step to make-dmg.sh, update
docs/RELEASING.md, and upload LevelCode-<arch>.app.zip with the dmg.
Shipped alone first — it is inert until the feed points at it.
S2 — serve it (server). ✅ done. Teach EditorReleaseFeed to pick the arch-matched asset + hash.
Guard stays on, so behaviour is unchanged; the new shape is asserted in spec/requests/api/updates_spec.rb.
S3 — extension URL fix. ✅ done. U.downloadUrl() in extensions/levelcode-updater/update.js, so
the Download button can never open a raw zip.
S4 — flip the flag + verify. Set LEVELCODE_UPDATE_FEED_SIGNED=1, then run the end-to-end test below.
Blocked until a release actually carries the S1 zips — every release cut before S1 resolves to
installable: false, so flipping the flag against today's releases is inert (Squirrel still gets 204).
- Sequencing. Two independent constraints — the second is easy to miss:
- Flipping the flag before S1–S2 makes things worse: Squirrel would download a web page and fail
loudly. S4 must be last. This is now enforced in code, not just documented — the controller requires
a resolved entry to be
installable, so an early flip still serves 204. - S3 gated the next RELEASE, not S4. The notify-only extension is served a 200 regardless of
LEVELCODE_UPDATE_FEED_SIGNED— seeupdates_controller.rb:unless notify_only_client? || (signed_feed? && rel[:installable]). So the moment any release carries an.app.zip,feed.urlbecomes that zip for the extension too. Publishing an S1-asset release with S3 unshipped would have pointed every "Download" button at a raw zip, flag or no flag. Unlike the constraint above, nothing in the code would have stopped it — henceU.downloadUrl()and its regression test.
- Flipping the flag before S1–S2 makes things worse: Squirrel would download a web page and fail
loudly. S4 must be last. This is now enforced in code, not just documented — the controller requires
a resolved entry to be
- Signing-identity continuity. Squirrel.Mac refuses an update whose Developer ID doesn't match the running app. Rotating or changing the signing cert breaks auto-update for every installed build, with no in-app recovery — those users must re-download manually. Treat the identity as long-lived.
- No staged rollout. Publishing a release auto-installs for everyone on the next check. The rollback
lever is
LEVELCODE_UPDATE_FEED(pin the previous commit) — but installs that already updated are not reverted. Consider a canary/percentage gate before this is a large install base. - Unverifiable by inspection. Auto-update can only be proven by actually doing it on a real Mac (install N, publish N+1, watch the swap). Budget a real test cycle, not a code review.
- Stapling must survive the zip.
ditto --sequesterRsrc --keepParentpreserves the stapled ticket; re-zipping withzip(1)can drop extended attributes. Keep usingditto. - Notarization latency. Apple's notarization is minutes, occasionally longer — the zip must be cut
after
notarize-appcompletes, or you publish an unstapled app.
- Install N (e.g. v0.7.2) from the dmg, confirm
Aboutshows its commit. - Publish N+1 with the signed zip attached and the feed serving it.
- In N: Check for Updates… → it offers, downloads, and relaunches into N+1;
Aboutshows the new commit. No Gatekeeper prompt. - Confirm the Intel build receives the x64 zip (not arm64).
- Pin
LEVELCODE_UPDATE_FEEDback to N's commit → a fresh N+1 install reports "up to date" (proves the rollback lever).
- Windows/Linux feeds —
EditorReleaseFeed::TARGETSis macOS-only by design; announcing a build that doesn't exist is worse than silence. - Delta updates. Full-zip replacement is fine at this size.
- Auto-update for the notify-only extension — it stays notify-only by design.