Skip to content

build(release): universal2 DMG built + validated in CI (pipeline PR 3/6) - #183

Merged
aterrylu merged 1 commit into
mainfrom
terry/release-dmg-ci
Jun 6, 2026
Merged

build(release): universal2 DMG built + validated in CI (pipeline PR 3/6)#183
aterrylu merged 1 commit into
mainfrom
terry/release-dmg-ci

Conversation

@aterrylu

@aterrylu aterrylu commented Jun 6, 2026

Copy link
Copy Markdown
Owner

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

Piece What Proven locally
bundle-node.sh BUNDLE_NODE_UNIVERSAL=1 lipo both arch Node binaries → universal2 ✅ 256M fat binary, runs
stage-universal-server.sh lipo native .node modules from the two arch server bundles ✅ real impit arm64+x64 lipo'd, loaded under both arches
smoke-test-bundle.sh SMOKE_EXPECT_UNIVERSAL=1 assert both x86_64 + arm64 slices present ✅ logic validated

dlopen() selects the correct slice at runtime regardless of the (cosmetic) arm64 filename — confirmed by loading a lipo'd fat .node under both arch -x86_64 node and native arm64 node.

release.yml (rewritten) — 4 stages

flowchart LR
  A["build-server<br/>matrix: darwin/linux × arm64/x64"] --> B["build-dmg (macos-14)<br/>lipo universal → smoke test (GATE)<br/>→ electron-builder universal<br/>→ validate-dmg (observe)"]
  A --> D["release (ubuntu)<br/>tarballs + DMG + ZIP + blockmap<br/>+ latest-mac.yml + SHA256SUMS<br/>body = CHANGELOG section"]
  B --> C["validate-intel (macos-15-intel, GATE)<br/>mount DMG on REAL x64<br/>→ native smoke test"]
  C --> D
  B --> D
Loading
  • Hard gates: the headless bundle smoke test, and a real Intel runner that mounts the universal DMG and runs the smoke test natively on x64 (proves the x64 slice executes — no Rosetta dependency).
  • validate-dmg (CDP UI test) runs continue-on-error for 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

universal target + zip (Squirrel.Mac requires it) + blockmap (delta updates) + github publish config (generates latest-mac.yml for electron-updater in PR #5). Signing stays off — PR #4 flips identity + notarize with your Apple key.

Local builds unchanged

build-dmg.sh now passes --arm64/--x64 to keep local builds single-arch + fast (overriding the yml's universal target).

Validated locally (real)

  • 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 validate-dmg.sh end-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-dmg behavior can only be validated by running Actions. I'm babysitting CI on this PR and will iterate.

Test plan

  • make check (353/353), biome clean
  • universal binary mechanism proven locally
  • local DMG build + validate-dmg (regression)
  • actionlint + YAML parse
  • CI: universal build green
  • CI: Intel native validation green
  • observe validate-dmg headless behavior

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:93shared.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.

@aterrylu
aterrylu enabled auto-merge (squash) June 6, 2026 18:03

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh hardcodes -arm64 in SUFFIX and the prune PATTERN while using HOST_EB_ARCH for the actual build — produces mislabeled output names on an x64 host. Local-only cosmetic.
  • stage-universal-server.sh's key="${base%%-*}" is brittle if a future native module's first hyphen-split token isn't unique across arches. Fine for current pty / impit keys.

@aterrylu
aterrylu merged commit f301061 into main Jun 6, 2026
4 checks passed
@aterrylu
aterrylu deleted the terry/release-dmg-ci branch June 6, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants