build(release): universal2 DMG built + validated in CI (pipeline PR 3/6) - #183
Conversation
Moves the macOS Desktop build into CI as a universal2 artifact — one DMG that runs on Apple Silicon AND Intel — replacing the hand-built, never-CI-validated local DMG. This is the structural fix for "ship → user finds it broken": the DMG now can't reach a release without passing the bundle smoke test + native Intel validation. ## Universal binary (mechanism proven locally before writing CI) - `bundle-node.sh BUNDLE_NODE_UNIVERSAL=1` → fetch both arch Node binaries, lipo into one universal2 `node`. Validated: 256M fat binary, runs natively. - `stage-universal-server.sh` → lipo the native `.node` modules from the two arch server bundles (built on separate CI runners) into fat binaries under the arm64 filenames the bundled JS references. dlopen() picks the right slice at runtime regardless of filename. Validated: real impit arm64+x64 lipo'd and loaded under BOTH arches. - `smoke-test-bundle.sh` SMOKE_EXPECT_UNIVERSAL=1 → asserts every bundled `.node` + the Node binary contain both x86_64 and arm64 slices. ## release.yml (rewritten) Four stages: 1. build-server (matrix: darwin/linux × arm64/x64) — 4 tarballs for install.sh; the two darwin bundles also feed the DMG's native-module lipo. 2. build-dmg (macos-14) — stage universal resources → smoke test (HARD GATE) → electron-builder universal DMG/ZIP/blockmap → validate-dmg via CDP (continue-on-error for now: headless-runner behavior is the one unknown). 3. validate-intel (macos-15-intel, HARD GATE) — mount the DMG on REAL x64 hardware, run the smoke test natively, proving the x64 slice executes. 4. release — assemble tarballs + DMG + ZIP + blockmap + latest-mac.yml + SHA256SUMS; GitHub Release body = the CHANGELOG section (release-notes.ts). ## electron-builder.yml universal target + zip (Squirrel.Mac needs it) + github publish config (so latest-mac.yml is generated for electron-updater in PR #5). Signing stays off — PR #4 flips identity + notarize with the Apple key. ## Local builds unchanged build-dmg.sh now passes `--arm64`/`--x64` to keep local builds single-arch + fast. Validated: local DMG builds AND passes the full validate-dmg.sh end-to-end (Welcome cards, Try-It-Out, auto-auth, first-run UX, Dispatcher badge). ## Validated locally - universal Node lipo ✓ · stage-universal-server lipo + cross-arch load ✓ - actionlint clean on all 3 workflows ✓ · all YAML parses ✓ - `make check` 353/353 ✓ · `biome check packages/` clean ✓ - local single-arch DMG build + full CDP validation ✓ (no regression) CI iteration expected for: the universal CI build, the Intel native job, and whether validate-dmg runs headless. Babysitting CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| working-directory: packages/app | ||
|
|
||
| - name: Smoke test (HARD GATE — universal slices + server boot + auth) | ||
| run: SMOKE_EXPECT_UNIVERSAL=1 bash scripts/smoke-test-bundle.sh |
There was a problem hiding this comment.
🟡 Warning — the bundle smoke test (now run as a HARD GATE in CI) spawns a real claude-code agent at line 184-193 of smoke-test-bundle.sh via POST /api/agents. That code path goes through resolveBinary() for the claude CLI (packages/server/src/providers/claude-code.ts:93 → shared.ts:151) which falls back to which claude and throws if not found. GitHub-hosted macos-14 / macos-15-intel runners do not have the claude CLI pre-installed, and this workflow does not install it.
Expected first-CI-run result: the spawn step returns 500, the smoke test fails the regex grep for "status":"running", and the build-dmg + validate-intel jobs both fail before producing any artifact.
You called out babysitting CI on this PR, so this may already be on your iteration list — but worth flagging explicitly: this is the most likely thing to bite the first green run. Either skip the spawn portion under an env flag (e.g. SMOKE_SKIP_AGENT_SPAWN=1 in CI), or install @anthropic-ai/claude-code before running the smoke test.
nox-0x
left a comment
There was a problem hiding this comment.
Approving — the universal2 mechanism is well-thought-out (lipo'd Node + native modules under the arm64-named filenames, validated by a static slice check plus native x64 execution on a real Intel runner), the gating structure is clean (smoke test → universal build → native Intel validation → publish), and the local single-arch path is preserved. The lipo correctness rests on dlopen() picking the right slice regardless of the cosmetic arm64 filename, which is standard and is then double-checked by the Intel runner mounting the real DMG.
One heads-up posted inline: the smoke test's POST /api/agents step requires the claude CLI on the runner, and the workflow doesn't install it — likely to fail on the first CI run. Easy fix when you hit it (skip the spawn under a CI env flag, or install the CLI before the smoke step). Non-blocking since you've already called out CI iteration as expected.
Minor follow-up nits (don't block):
build-dmg.shhardcodes-arm64inSUFFIXand the prunePATTERNwhile usingHOST_EB_ARCHfor the actual build — produces mislabeled output names on an x64 host. Local-only cosmetic.stage-universal-server.sh'skey="${base%%-*}"is brittle if a future native module's first hyphen-split token isn't unique across arches. Fine for currentpty/impitkeys.
Summary
PR 3 of 6. Moves the macOS Desktop build into CI as a universal2 artifact — one DMG that runs on Apple Silicon AND Intel — replacing the hand-built, never-CI-validated local DMG. This is the structural fix for "ship → user finds it broken": the DMG can no longer reach a release without passing the bundle smoke test + native Intel validation.
Universal binary — mechanism proven locally before writing the CI
bundle-node.sh BUNDLE_NODE_UNIVERSAL=1stage-universal-server.sh.nodemodules from the two arch server bundlessmoke-test-bundle.sh SMOKE_EXPECT_UNIVERSAL=1dlopen() selects the correct slice at runtime regardless of the (cosmetic) arm64 filename — confirmed by loading a lipo'd fat
.nodeunder botharch -x86_64 nodeand native arm64 node.release.yml(rewritten) — 4 stagesvalidate-dmg(CDP UI test) runscontinue-on-errorfor now — whether Electron+CDP runs on a headless GitHub macOS runner is the one genuine CI unknown. We observe it in CI, then harden to a gate.electron-builder.yml
universaltarget +zip(Squirrel.Mac requires it) +blockmap(delta updates) + github publish config (generateslatest-mac.ymlfor electron-updater in PR #5). Signing stays off — PR #4 flipsidentity+notarizewith your Apple key.Local builds unchanged
build-dmg.shnow passes--arm64/--x64to keep local builds single-arch + fast (overriding the yml's universal target).Validated locally (real)
make check353/353 ✓ ·biome check packages/clean ✓validate-dmg.shend-to-end ✓ — Welcome cards, Try-It-Out, auto-auth, first-run UX, Dispatcher badge all pass on the freshly-built DMG (no regression from the universal config)CI iteration expected
The universal CI build, the Intel native job, and the headless
validate-dmgbehavior can only be validated by running Actions. I'm babysitting CI on this PR and will iterate.Test plan
🤖 Generated with Claude Code