Skip to content

Commit 7abd0f6

Browse files
authored
Merge pull request #22 from levelcodeai/feat/auto-update-signed-zip
feat(release): emit a signed .app.zip update asset (auto-update S1)
2 parents 3943d66 + bca2059 commit 7abd0f6

4 files changed

Lines changed: 178 additions & 16 deletions

File tree

.github/workflows/release.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,11 @@ jobs:
106106
1. `gh release download ${{ github.ref_name }} --pattern 'UNSIGNED-*.app.zip'`
107107
2. For each arch — unzip into `VSCode-darwin-<arch>/`, then
108108
`CODESIGN_IDENTITY="Developer ID Application: …" NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh <arch>`
109-
(signs → dmg → notarizes → staples → `LevelCode-<arch>.dmg`).
110-
3. `gh release upload ${{ github.ref_name }} LevelCode-arm64.dmg LevelCode-x64.dmg`
109+
(signs → notarizes → staples → `LevelCode-<arch>.dmg` **and** `LevelCode-<arch>.app.zip`).
110+
3. `gh release upload ${{ github.ref_name }} LevelCode-arm64.dmg LevelCode-x64.dmg LevelCode-arm64.app.zip LevelCode-x64.app.zip`
111+
— the `.dmg`s are for humans, the `.app.zip`s are the auto-update feed assets (`docs/AUTO-UPDATE.md`).
112+
Upload exactly these four; the `.app.zip.sha256` files `make-dmg.sh` writes stay **local**
113+
(the feed reads GitHub's own asset `digest`, never a sidecar).
111114
4. **Delete the `UNSIGNED-*.app.zip` assets**, add real notes, and publish.
112115
113116
Full runbook: `docs/RELEASING.md`.

docs/AUTO-UPDATE.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# LevelCode — Signed auto-update (Squirrel `.zip`) — scope
2+
3+
**Goal:** make the editor's built-in **Check for Updates…** actually install a new build, instead of
4+
always reporting "There are currently no updates available."
5+
6+
**Today:** that dialog is *correct behaviour*, not a bug. `Api::UpdatesController` (thin.ly) serves
7+
**204** to every client except the notify-only extension — a deliberate **unsigned-build guard**,
8+
because the built-in Squirrel updater *auto-downloads and installs* whatever `url` a 200 returns, and
9+
today that `url` is a GitHub **release page**, not a signed `.zip`. Squirrel would download it and fail.
10+
11+
Verified against production while scoping:
12+
13+
| Client | Request | Response |
14+
|---|---|---|
15+
| Built-in Squirrel updater | `GET /api/update/darwin-arm64/stable/<commit>` | **204** (guard) |
16+
| Notify-only extension (`User-Agent: LevelCode Updater`) | same | **200** `productVersion: 0.7.2` |
17+
18+
## What already exists (most of the hard part)
19+
20+
- **`scripts/notarize.sh sign`** — Developer ID signing with **hardened runtime** + `levelcode.entitlements`.
21+
- **`scripts/notarize.sh notarize-app`** — notarizes **and staples the `.app` itself**, precisely so a
22+
copied-out app validates offline. **This is exactly the artifact Squirrel needs.**
23+
- **CI already zips an app** with `ditto -c -k --sequesterRsrc --keepParent` (for the UNSIGNED artifact) —
24+
the same command, applied to the *signed* app, produces the feed asset.
25+
- **`Api::UpdatesController`** already implements the full Code-OSS feed contract, the guard, and
26+
`LEVELCODE_UPDATE_FEED` (a JSON env override) — which doubles as the **rollback/pin lever**.
27+
- **`Levelcode::EditorReleaseFeed`** already resolves tag → commit, `product_version`, and timestamp.
28+
29+
So this is **not** a new signing pipeline. It is: publish one more asset, teach the feed to point at it,
30+
then lift the guard.
31+
32+
## The gap
33+
34+
1. **Release artifact.** Produce `LevelCode-<arch>.app.zip` from the **signed + notarized + stapled**
35+
`.app` (a `ditto` after `make-dmg.sh`'s signing step) and publish it on the release. The `.dmg` stays —
36+
it remains the fresh-install path; the `.zip` is update-only.
37+
*The zip is the only new release asset.* `make-dmg.sh` also writes a `.app.zip.sha256` beside it, but
38+
that stays **local**: the feed's `sha256hash` comes from GitHub's API-computed asset `digest`
39+
(`"sha256:<hex>"`), so no sidecar is ever fetched. The file is for verifying by hand that the zip you
40+
published is the zip you built.
41+
2. **Feed asset resolution.** `EditorReleaseFeed#fetch_release` currently returns `url: rel["html_url"]`
42+
(the release page) and `sha256hash: nil`. It must select the **right asset** from `rel["assets"]` by
43+
arch and return its `browser_download_url` plus the hash from that asset's own `digest` field.
44+
3. **Arch mapping.** Feed targets are `darwin-arm64` and `darwin` (Intel). Map to the arm64 / x64 zips
45+
respectively — **never serve a cross-arch zip**.
46+
4. **Lift the guard.** Set `LEVELCODE_UPDATE_FEED_SIGNED=1` on Elastic Beanstalk — **only after 1–3**.
47+
5. **Notify-only Download button.** `extensions/levelcode-updater/extension.js:96` is
48+
`feed.url || product.downloadUrl || base`. Once `feed.url` is a raw `.zip`, that button would hand
49+
users a zip instead of the release page. Reorder to prefer `product.downloadUrl` / the release page.
50+
51+
## Slices
52+
53+
**S1 — publish the signed zip (client).** Add the `ditto` + `shasum` step to `make-dmg.sh` (or a
54+
`make-update-zip.sh`), update `docs/RELEASING.md`, and upload `LevelCode-<arch>.app.zip` with the dmg.
55+
*Ship this alone first — it is inert until the feed points at it.*
56+
57+
**S2 — serve it (server).** Teach `EditorReleaseFeed` to pick the arch-matched asset + hash. Guard stays
58+
on, so behaviour is unchanged; assert the new shape in `spec/requests/api/updates_spec.rb`.
59+
60+
**S3 — extension URL fix.** Reorder the Download preference so it never opens a raw zip.
61+
62+
**S4 — flip the flag + verify.** Set `LEVELCODE_UPDATE_FEED_SIGNED=1`, then run the end-to-end test below.
63+
64+
## Risks (the ones that actually bite)
65+
66+
- **Sequencing.** Flipping the flag before S1–S2 makes things *worse* — Squirrel would download a web
67+
page and fail loudly. S4 must be last.
68+
- **Signing-identity continuity.** Squirrel.Mac refuses an update whose Developer ID doesn't match the
69+
running app. **Rotating or changing the signing cert breaks auto-update for every installed build**,
70+
with no in-app recovery — those users must re-download manually. Treat the identity as long-lived.
71+
- **No staged rollout.** Publishing a release auto-installs for everyone on the next check. The rollback
72+
lever is `LEVELCODE_UPDATE_FEED` (pin the previous commit) — but installs that already updated are
73+
*not* reverted. Consider a canary/percentage gate before this is a large install base.
74+
- **Unverifiable by inspection.** Auto-update can only be proven by actually doing it on a real Mac
75+
(install N, publish N+1, watch the swap). Budget a real test cycle, not a code review.
76+
- **Stapling must survive the zip.** `ditto --sequesterRsrc --keepParent` preserves the stapled ticket;
77+
re-zipping with `zip(1)` can drop extended attributes. Keep using `ditto`.
78+
- **Notarization latency.** Apple's notarization is minutes, occasionally longer — the zip must be cut
79+
*after* `notarize-app` completes, or you publish an unstapled app.
80+
81+
## Exit test
82+
83+
1. Install **N** (e.g. v0.7.2) from the dmg, confirm `About` shows its commit.
84+
2. Publish **N+1** with the signed zip attached and the feed serving it.
85+
3. In N: **Check for Updates…** → it offers, downloads, and **relaunches into N+1**; `About` shows the
86+
new commit. No Gatekeeper prompt.
87+
4. Confirm the Intel build receives the x64 zip (not arm64).
88+
5. Pin `LEVELCODE_UPDATE_FEED` back to N's commit → a fresh N+1 install reports "up to date"
89+
(proves the rollback lever).
90+
91+
## Not doing (explicitly)
92+
93+
- Windows/Linux feeds — `EditorReleaseFeed::TARGETS` is macOS-only by design; announcing a build that
94+
doesn't exist is worse than silence.
95+
- Delta updates. Full-zip replacement is fine at this size.
96+
- Auto-update for the notify-only extension — it stays notify-only by design.

docs/RELEASING.md

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,46 @@ Build **both architectures** on matching hardware (Apple silicon → `arm64`, In
8181

8282
```bash
8383
gh release create v0.1.0 --title "LevelCode v0.1.0" --notes "First public build. …" \
84-
LevelCode-arm64.dmg LevelCode-x64.dmg
84+
LevelCode-arm64.dmg LevelCode-x64.dmg \
85+
LevelCode-arm64.app.zip LevelCode-x64.app.zip
8586
```
87+
Upload **both kinds**: the `.dmg` is what humans install; the `.app.zip` is what the built-in updater
88+
installs (Squirrel takes a zip, never a dmg). `make-dmg.sh` also writes a `.app.zip.sha256` next to each
89+
zip — that one is **not** a release asset; it stays local. See §5.
8690
Stable URL: `https://github.com/levelcodeai/levelcode/releases/latest/download/LevelCode-arm64.dmg` — link it
8791
from **levelcode.ai/download**.
8892

89-
## 5. Point the update feed at the release
93+
## 5. The update feed
9094

91-
The notify-only updater (`extensions/levelcode-updater`) polls a feed from `tools/update-server`. After
92-
publishing, update the feed's release entry (version + the release-asset URLs above) so running installs
93-
see the new version. Keep the feed version in lockstep with the tag.
95+
Publishing the GitHub release **is** the announcement — `Levelcode::EditorReleaseFeed` (thin.ly) reads
96+
`releases/latest` and serves `/api/update/{target}/{quality}/{commit}`. There is no feed file to
97+
hand-maintain per release.
98+
99+
Two assets, **not** interchangeable:
100+
101+
| Asset | Consumer |
102+
| --- | --- |
103+
| `LevelCode-<arch>.dmg` | Humans — fresh install, drag to Applications. |
104+
| `LevelCode-<arch>.app.zip` | The built-in **Squirrel** updater — it installs from a zip, never a dmg. |
105+
106+
`make-dmg.sh` emits the `.app.zip` **only on the Developer-ID path**, because Squirrel refuses an update
107+
whose signing identity doesn't match the running app — an ad-hoc build must never be served as an update.
108+
109+
**Only those two files get uploaded per arch.** `make-dmg.sh` also writes `LevelCode-<arch>.app.zip.sha256`,
110+
but that is a **local verification convenience, not a release asset**: the feed reads `sha256hash` from
111+
GitHub's own API-computed asset `digest` (`"sha256:<hex>"`), so nothing ever fetches a sidecar file.
112+
Uploading one is harmless — `EditorReleaseFeed` matches assets by exact filename and ignores anything
113+
else — just unnecessary. Use it to confirm the zip you published is the zip you built:
114+
```bash
115+
Z=LevelCode-arm64.app.zip
116+
[ "$(shasum -a 256 "$Z" | cut -d' ' -f1)" = "$(cat "$Z.sha256")" ] && echo "$Z OK" || echo "$Z MISMATCH"
117+
```
118+
119+
`LEVELCODE_UPDATE_FEED` (env JSON on the server) overrides the GitHub lookup and is the **rollback pin**:
120+
point it at the previous commit to stop a bad release propagating. Note it can't un-update anyone who
121+
already took the release.
122+
123+
Full contract, rollout order, and risks: **`docs/AUTO-UPDATE.md`**.
94124

95125
## 6. Troubleshooting *(the ones that actually bit us are marked ⚑)*
96126

@@ -130,11 +160,13 @@ gh release download v0.1.0 --pattern 'UNSIGNED-*.app.zip'
130160
for A in arm64 x64; do
131161
rm -rf "VSCode-darwin-$A" && ditto -x -k "UNSIGNED-LevelCode-$A.app.zip" "VSCode-darwin-$A"
132162
CODESIGN_IDENTITY="Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)" \
133-
NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh "$A" # → LevelCode-$A.dmg (signed+notarized+stapled)
163+
NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh "$A" # → LevelCode-$A.dmg + LevelCode-$A.app.zip
134164
done
135165

136-
# 3. Verify (§3), then attach the dmgs, drop the unsigned zips, and publish
137-
gh release upload v0.1.0 LevelCode-arm64.dmg LevelCode-x64.dmg
166+
# 3. Verify (§3), then attach the dmgs AND the update zips, drop the unsigned zips, and publish.
167+
# The .app.zip.sha256 files stay local on purpose — the feed uses GitHub's own asset digest (§5).
168+
gh release upload v0.1.0 LevelCode-arm64.dmg LevelCode-x64.dmg \
169+
LevelCode-arm64.app.zip LevelCode-x64.app.zip
138170
# `gh release delete-asset` takes ONE asset per call — drop each unsigned zip separately (`-y` skips the prompt).
139171
gh release delete-asset v0.1.0 UNSIGNED-LevelCode-arm64.app.zip -y
140172
gh release delete-asset v0.1.0 UNSIGNED-LevelCode-x64.app.zip -y

scripts/make-dmg.sh

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,24 @@
66
# loose files and it can't be "installed". A .dmg wraps the whole app into one
77
# compressed file you can upload, download, and drag-to-install.
88
#
9-
# This script:
10-
# 1. Locates VSCode-darwin-<arch>/LevelCode.app (run ./scripts/build-macos.sh first).
11-
# 2. Ad-hoc code-signs it. arm64 macOS refuses to launch *unsigned* binaries at all,
12-
# so even for personal use the app must carry at least an ad-hoc signature.
13-
# 3. Builds a compressed .dmg containing the app + an /Applications symlink.
9+
# It operates on VSCode-darwin-<arch>/LevelCode.app (run ./scripts/build-macos.sh first).
10+
# Steps below are numbered to match the `# N.` markers in the script body:
11+
# 0. De-Microsoft the bundle + hide not-yet-ready features, before signing covers the result.
12+
# 1. Sign the app. Ad-hoc by default (arm64 macOS refuses to launch *unsigned* binaries at all);
13+
# with CODESIGN_IDENTITY set, real Developer ID signing + notarize + staple.
14+
# 2. Stage a clean folder: the app + an /Applications symlink.
15+
# 3. Build the compressed .dmg from it.
16+
# 4. Developer ID path only — sign, notarize + staple the .dmg itself.
17+
# 5. Developer ID path only — emit LevelCode-<arch>.app.zip, the auto-update feed asset the
18+
# built-in Squirrel updater installs. See docs/AUTO-UPDATE.md.
1419
#
15-
# The result is UNNOTARIZED. On another Mac, Gatekeeper will still warn on first launch:
20+
# Gatekeeper: on the DEFAULT (ad-hoc) path the result is UNNOTARIZED, so on another Mac Gatekeeper
21+
# warns on first launch:
1622
# - Right-click the app > Open (once), OR
1723
# - clear quarantine after copying out of the dmg:
1824
# xattr -dr com.apple.quarantine "/Applications/LevelCode.app"
25+
# With CODESIGN_IDENTITY set, steps 1 + 4 notarize and staple both the app and the dmg — a plain
26+
# double-click then works on any Mac, offline, with no warning.
1927
#
2028
set -euo pipefail
2129
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -98,11 +106,34 @@ if [ "$NOTARIZE" = "1" ]; then
98106
"$SCRIPT_DIR/notarize.sh" submit "$DMG_OUT"
99107
fi
100108

109+
# 5. Squirrel update asset — a .zip of the SIGNED + NOTARIZED + STAPLED .app.
110+
# The built-in updater (Squirrel.Mac) installs from a .zip, never a .dmg, and refuses an update whose
111+
# Developer ID doesn't match the running app — so this is produced ONLY on the signed path; an ad-hoc
112+
# build must never masquerade as an update asset. Use `ditto` (not `zip`): it preserves the stapled
113+
# notarization ticket, so the updated app still validates offline. Feed + rollout: docs/AUTO-UPDATE.md.
114+
#
115+
# The `.sha256` sidecar is LOCAL ONLY — do not upload it as a release asset. The update feed reads the
116+
# hash from GitHub's own API-computed asset `digest` ("sha256:<hex>"), not from a sidecar file; see
117+
# Levelcode::EditorReleaseFeed#build_assets in thin.ly. It exists so a releaser can verify by hand
118+
# that the zip they published is the zip they built.
119+
ZIP_OUT=""
120+
if [ "$NOTARIZE" = "1" ]; then
121+
ZIP_OUT="$ROOT_DIR/LevelCode-$ARCH.app.zip"
122+
echo "[make-dmg] Creating the signed update asset (Squirrel .zip) …"
123+
rm -f "$ZIP_OUT" "$ZIP_OUT.sha256"
124+
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP_OUT"
125+
shasum -a 256 "$ZIP_OUT" | cut -d' ' -f1 > "$ZIP_OUT.sha256"
126+
fi
127+
101128
SIZE="$(du -sh "$DMG_OUT" | cut -f1)"
102129
echo "[make-dmg] Done."
103130
echo "[make-dmg] Output: $DMG_OUT ($SIZE)"
104131
if [ "$NOTARIZE" = "1" ]; then
105132
echo "[make-dmg] Signed + notarized — upload it; users just open the dmg and drag to Applications."
133+
echo "[make-dmg] Update asset: $ZIP_OUT ($(du -sh "$ZIP_OUT" | cut -f1))"
134+
echo "[make-dmg] sha256: $(cat "$ZIP_OUT.sha256") (local check only — the feed uses GitHub's own digest)"
135+
echo "[make-dmg] Upload EXACTLY these two: the .dmg (fresh installs) and the .app.zip (auto-update"
136+
echo "[make-dmg] feed). The .sha256 stays local — uploading it is harmless but pointless."
106137
else
107138
echo "[make-dmg] Upload this single file. On the other Mac: open the dmg, drag LevelCode to"
108139
echo "[make-dmg] Applications, then right-click > Open the first time (it's unnotarized)."

0 commit comments

Comments
 (0)