Skip to content

Commit 7b13352

Browse files
authored
fix(installer): bundle uv binary for mac-arm64 (#967)
Before: installing the macOS DMG on a clean Apple-Silicon Mac with no system `uv` on `PATH` hard-failed during `ensure-uv` — `bundledUvPlatformKey()` claimed `mac-arm64` support, but no CI step ever fetched the binary. First-launch error: `"No bundled uv available for darwin-arm64 and no system uv found"`. After: the macOS build job fetches the pinned `uv` v0.5.14 `aarch64-apple-darwin` binary, verifies its archive SHA, and places it at `build/vendor/uv/mac-arm64/uv` so `electron-builder` ships it inside `Contents/Resources/vendor/uv/mac-arm64/uv`. A new `dmg-structural-smoke` CI job mounts the built DMG, asserts the binary is present + executable + SHA-pinned + actually runs (`uv --version`), and is wired into the `build-complete` gate so future drift fails at smoke time, not on a user's first launch. Also bundled: the `pr-review` job in `.github/workflows/claude.yml` is temporarily disabled (`if: false`) because the CI bot's Anthropic API key is out of credits — auto-review will not run on this or any PR until the credit balance is restored. Tracked for re-enable separately from this fix. Closes #941. ## Test plan - [ ] Trigger `build-installers` workflow against this branch and confirm `dmg-structural-smoke` and `build-complete` are both green - [ ] On a clean Apple-Silicon Mac with no system `uv` (verify: `which uv` → not found): download the DMG via browser (not `gh run download` — browser sets the quarantine xattr that Gatekeeper checks), install, launch GAIA, confirm it reaches `state: ready` with no `ensure-uv` error and no `Killed: 9` on the spawned uv child - [ ] Confirm `~/.gaia/bin/uv --version` works after install - [ ] Test on macOS Sequoia (15.x) — Sequoia tightened CS checks for child processes spawned by hardened-runtime apps
1 parent ba011b4 commit 7b13352

8 files changed

Lines changed: 490 additions & 81 deletions

File tree

.github/workflows/build-installers.yml

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ on:
9292
- 'src/gaia/apps/webui/main.cjs'
9393
- 'src/gaia/apps/webui/bin/**'
9494
- 'src/gaia/apps/webui/services/**'
95+
# Cover the installer-smoke test tree (issue #941) so a PR that
96+
# only touches the smoke-test layer still triggers structural smoke.
97+
# Narrower than `tests/electron/**` so Jest-only edits to e.g.
98+
# test_electron_chat_app.js don't re-run the multi-platform build.
99+
- 'tests/electron/_helpers/**'
100+
- 'tests/electron/*-smoke.test.mjs'
95101
- 'tests/electron/fixtures/**'
96102
- 'src/gaia/version.py'
97103

@@ -229,6 +235,44 @@ jobs:
229235
chmod 0755 "${DEST_DIR}/uv"
230236
"${DEST_DIR}/uv" --version
231237
238+
# ─── Fetch uv binary for macOS DMG (issue #941) ─────────────────
239+
# Mirror of the Linux step above. Without this, the macOS .app
240+
# ships no bundled uv even though backend-installer.cjs claims
241+
# support for darwin-arm64, so first-launch on a clean Mac (no
242+
# system uv on PATH) hard-fails in ensure-uv. The runtime hashes
243+
# the *extracted* binary against BUNDLED_UV_SHA256["mac-arm64"]
244+
# in src/gaia/apps/webui/services/backend-installer.cjs — those
245+
# two pins MUST be bumped in lockstep with this step.
246+
- name: Fetch uv binary (macOS)
247+
if: matrix.platform == 'macos'
248+
shell: bash
249+
run: |
250+
set -euo pipefail
251+
UV_VERSION="0.5.14"
252+
UV_TARBALL="uv-aarch64-apple-darwin.tar.gz"
253+
UV_SHA256="d548dffc256014c4c8c693e148140a3a21bcc2bf066a35e1d5f0d24c91d32112"
254+
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}"
255+
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/mac-arm64"
256+
mkdir -p "${DEST_DIR}"
257+
tmpdir="$(mktemp -d)"
258+
# --retry 3/5s matches the Lemonade MSI step below; survives
259+
# transient GitHub Releases CDN flakes on hosted macOS runners.
260+
curl -fsSL --retry 3 --retry-delay 5 -o "${tmpdir}/${UV_TARBALL}" "${UV_URL}"
261+
# macOS shasum -a 256 -c accepts the GNU "hash file" format.
262+
echo "${UV_SHA256} ${tmpdir}/${UV_TARBALL}" | shasum -a 256 -c -
263+
tar -xzf "${tmpdir}/${UV_TARBALL}" -C "${tmpdir}"
264+
cp "${tmpdir}/uv-aarch64-apple-darwin/uv" "${DEST_DIR}/uv"
265+
chmod 0755 "${DEST_DIR}/uv"
266+
"${DEST_DIR}/uv" --version
267+
# Echo the extracted-binary SHA. This is the PRE-codesign digest
268+
# (i.e., the upstream tarball's uv byte-for-byte), useful for
269+
# confirming what feeds into electron-builder's codesign step.
270+
# NOTE: this is NOT directly comparable to BUNDLED_UV_SHA256[mac-arm64],
271+
# which is the POST-codesign digest — see backend-installer.cjs.
272+
# To bump BUNDLED_UV_SHA256[mac-arm64], run the CI build and copy
273+
# the SHA from the dmg-structural-smoke failure message.
274+
shasum -a 256 "${DEST_DIR}/uv"
275+
232276
- name: Build frontend (Vite)
233277
working-directory: src/gaia/apps/webui
234278
shell: bash
@@ -532,6 +576,46 @@ jobs:
532576
GAIA_APPIMAGE: ${{ steps.locate.outputs.appimage }}
533577
run: node --test tests/electron/appimage-smoke.test.mjs
534578

579+
# ─── DMG structural smoke (issue #941) ──────────────────────────────
580+
# Mirrors appimage-structural-smoke for the macOS DMG. Catches the
581+
# failure mode that bit v0.17.5: a darwin-arm64 install that hard-fails
582+
# in ensure-uv on first launch because the bundled uv either was never
583+
# shipped or has the wrong SHA256 against BUNDLED_UV_SHA256[mac-arm64]
584+
# in backend-installer.cjs.
585+
#
586+
# MUST be present in build-complete `needs:` below — without that
587+
# wiring, a failing DMG smoke does not block release-readiness.
588+
dmg-structural-smoke:
589+
name: DMG structural smoke
590+
needs: build
591+
if: always() && needs.build.result == 'success'
592+
runs-on: macos-latest # Apple Silicon (arm64) — matches build matrix
593+
permissions:
594+
contents: read
595+
steps:
596+
- name: Checkout
597+
uses: actions/checkout@v4
598+
599+
- name: Download macOS installer artifact
600+
uses: actions/download-artifact@v6
601+
with:
602+
name: macos-installer # ${{ matrix.platform }}-installer with platform=macos
603+
path: ${{ runner.temp }}/macos-installer
604+
605+
- name: Locate DMG
606+
id: locate
607+
shell: bash
608+
run: |
609+
set -euo pipefail
610+
DMG=$(ls "${RUNNER_TEMP}/macos-installer"/*.dmg | head -n1)
611+
echo "Found DMG: ${DMG}"
612+
echo "dmg=${DMG}" >> "$GITHUB_OUTPUT"
613+
614+
- name: Structural smoke (uv binary, mode, sha256, --version)
615+
env:
616+
GAIA_DMG: ${{ steps.locate.outputs.dmg }}
617+
run: node --test tests/electron/dmg-smoke.test.mjs
618+
535619
appimage-distro-matrix:
536620
name: AppImage distro matrix
537621
needs: build
@@ -971,6 +1055,7 @@ jobs:
9711055
- appimage-structural-smoke
9721056
- appimage-distro-matrix
9731057
- appimage-userns-restricted
1058+
- dmg-structural-smoke
9741059
if: always()
9751060
steps:
9761061
- name: Verify all platform builds and smoke tests succeeded
@@ -980,22 +1065,24 @@ jobs:
9801065
structural_result="${{ needs.appimage-structural-smoke.result }}"
9811066
distro_result="${{ needs.appimage-distro-matrix.result }}"
9821067
userns_result="${{ needs.appimage-userns-restricted.result }}"
1068+
dmg_result="${{ needs.dmg-structural-smoke.result }}"
9831069
echo "build: $build_result"
9841070
echo "appimage-structural-smoke: $structural_result"
9851071
echo "appimage-distro-matrix: $distro_result"
9861072
echo "appimage-userns-restricted: $userns_result"
1073+
echo "dmg-structural-smoke: $dmg_result"
9871074
fail=0
9881075
if [ "$build_result" != "success" ]; then
9891076
echo "::error::One or more platform installer builds failed"
9901077
fail=1
9911078
fi
992-
for r in "$structural_result" "$distro_result" "$userns_result"; do
1079+
for r in "$structural_result" "$distro_result" "$userns_result" "$dmg_result"; do
9931080
if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
994-
echo "::error::AppImage smoke job failed (status: $r)"
1081+
echo "::error::Installer smoke job failed (status: $r)"
9951082
fail=1
9961083
fi
9971084
done
9981085
if [ "$fail" -eq 1 ]; then
9991086
exit 1
10001087
fi
1001-
echo "All platform installers built and AppImage smoke suite passed."
1088+
echo "All platform installers built and installer smoke suite passed."

.github/workflows/claude.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ permissions:
5757
jobs:
5858
# Auto-review new PRs (including forks)
5959
pr-review:
60-
if: |
61-
github.repository == 'amd/gaia' &&
62-
github.event_name == 'pull_request_target' &&
63-
(github.event.pull_request.draft == false ||
64-
contains(github.event.pull_request.labels.*.name, 'ready_for_ci'))
60+
# Temporarily disabled 2026-05 — Anthropic credit balance exhausted
61+
# (see commit a013e384). Tracked for re-enable in
62+
# https://github.com/amd/gaia/issues/970. To re-enable, replace the
63+
# `if: false` line below with this commented gate:
64+
# if: |
65+
# github.repository == 'amd/gaia' &&
66+
# github.event_name == 'pull_request_target' &&
67+
# (github.event.pull_request.draft == false ||
68+
# contains(github.event.pull_request.labels.*.name, 'ready_for_ci'))
69+
if: false
6570
runs-on: ubuntu-latest
6671
concurrency:
6772
group: claude-pr-review-${{ github.event.pull_request.number }}

src/gaia/apps/webui/services/backend-installer.cjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,22 @@ const NETWORK_CHECK_TIMEOUT_MS = 5000;
7777
// archive against upstream's published .sha256, then extracts the `uv` binary
7878
// which is what `ensureUv()` hashes at runtime.
7979
//
80-
// Currently pinned: uv v0.5.14 linux-x64.
80+
// Currently pinned: uv v0.5.14 linux-x64, mac-arm64.
81+
// (win-x64 deferred to a follow-up issue — its SHA must ship together with an
82+
// NSIS structural-smoke verifier, not on its own; see the #849 lesson.)
83+
//
84+
// IMPORTANT: per-platform SHA origin differs:
85+
// - linux-x64: raw extracted-from-tarball digest (no post-build modification).
86+
// - mac-arm64: POST-CODESIGN digest. electron-builder code-signs the bundled
87+
// uv during packaging, so this hash matches what ensureUv() sees
88+
// at runtime, NOT the upstream tarball. Bumping this pin means
89+
// running the CI build, then copying the SHA from the
90+
// dmg-structural-smoke failure message — never from `shasum`
91+
// against the freshly downloaded tarball.
8192
const BUNDLED_UV_VERSION = "0.5.14";
8293
const BUNDLED_UV_SHA256 = {
8394
"linux-x64": "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6",
95+
"mac-arm64": "6099aa8cd701f0c81227ee30c304777ce151e4d47c53a75ce53cd2243448d8c8",
8496
};
8597

8698
const MANAGED_UV_DIR = path.join(GAIA_HOME, "bin");
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright(C) 2024-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
//
4+
// Shared assertions for installer structural smoke tests (issue #941).
5+
//
6+
// One source of truth for:
7+
// 1. The in-resources path layout for the bundled `uv` binary
8+
// (mirrors electron-builder.yml `extraResources.to: vendor/uv`).
9+
// 2. Parsing BUNDLED_UV_SHA256 out of backend-installer.cjs.
10+
// 3. The full existence + executable-bit + SHA256 check.
11+
//
12+
// Consumed by tests/electron/appimage-smoke.test.mjs and
13+
// tests/electron/dmg-smoke.test.mjs. A future NSIS smoke test should
14+
// also consume this module so all three installers stay in lockstep.
15+
16+
import assert from "node:assert/strict";
17+
import crypto from "node:crypto";
18+
import fs from "node:fs";
19+
import path from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
22+
// Mirrors electron-builder.yml `extraResources.to: vendor/uv` — the single
23+
// source of truth for the in-resources layout. If electron-builder.yml
24+
// changes the `to:` path, every smoke test breaks here with the same
25+
// actionable diff: update UV_RESOURCE_SUBPATH.
26+
export const UV_RESOURCE_SUBPATH = ["vendor", "uv"];
27+
28+
/**
29+
* Build the runtime-equivalent path to the bundled uv binary inside an
30+
* already-extracted resources directory.
31+
*
32+
* @param {string} resourcesDir
33+
* Absolute path to the extracted resources directory. Examples:
34+
* - AppImage: <squashfs-root>/resources
35+
* - DMG: <mountpoint>/<App>.app/Contents/Resources
36+
* @param {"linux-x64"|"mac-arm64"|"win-x64"} platformKey
37+
* @returns {string} absolute path to the bundled `uv`/`uv.exe`
38+
*/
39+
export function bundledUvPath(resourcesDir, platformKey) {
40+
const binary = platformKey === "win-x64" ? "uv.exe" : "uv";
41+
return path.join(resourcesDir, ...UV_RESOURCE_SUBPATH, platformKey, binary);
42+
}
43+
44+
/**
45+
* Parse `BUNDLED_UV_SHA256[platformKey]` from backend-installer.cjs source.
46+
*
47+
* The constant spans multiple lines once it has 2+ entries. `[^}]*?` spans
48+
* newlines natively (character classes match `\n` without dotall) and the
49+
* stop class on `}` is safe because SHA hex strings cannot contain `}`.
50+
*
51+
* @param {string} installerCjsPath absolute path to backend-installer.cjs
52+
* @param {string} platformKey e.g. "linux-x64"
53+
* @returns {string} the 64-character hex digest
54+
*/
55+
export function parseBundledUvSha(installerCjsPath, platformKey) {
56+
const src = fs.readFileSync(installerCjsPath, "utf8");
57+
// Defensive: escape regex metacharacters in the platform key, even
58+
// though the keys we use have none today.
59+
const escapedKey = platformKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
60+
const re = new RegExp(
61+
`BUNDLED_UV_SHA256\\s*=\\s*\\{[^}]*?"${escapedKey}"\\s*:\\s*"([0-9a-f]{64})"`,
62+
);
63+
const m = src.match(re);
64+
assert.ok(
65+
m,
66+
`could not parse BUNDLED_UV_SHA256["${platformKey}"] from ${installerCjsPath}`,
67+
);
68+
return m[1];
69+
}
70+
71+
/**
72+
* Existence + executable-bit (POSIX only) + SHA256-vs-pin check.
73+
*
74+
* Catches the failure mode that bit issue #849 and motivated #941:
75+
* a packaged binary whose SHA does not match the pin in
76+
* BUNDLED_UV_SHA256, which `ensureUv()` would reject at runtime with
77+
* a hard SHA256 mismatch error on the user's first launch.
78+
*
79+
* @param {string} uvPath absolute path to packaged `uv` binary
80+
* @param {string} platformKey e.g. "mac-arm64"
81+
* @param {string} installerCjsPath absolute path to backend-installer.cjs
82+
*/
83+
export function assertUvBinary(uvPath, platformKey, installerCjsPath) {
84+
assert.ok(fs.existsSync(uvPath), `expected bundled uv at ${uvPath}`);
85+
if (platformKey !== "win-x64") {
86+
const st = fs.statSync(uvPath);
87+
// Any execute bit on any class is enough; squashfs/HFS+/APFS all
88+
// preserve 0o755 for the source mode set by the CI fetch step.
89+
assert.ok(
90+
(st.mode & 0o111) !== 0,
91+
`uv binary should be executable; mode=${(st.mode & 0o777).toString(8)}`,
92+
);
93+
}
94+
const expected = parseBundledUvSha(installerCjsPath, platformKey);
95+
const actual = crypto
96+
.createHash("sha256")
97+
.update(fs.readFileSync(uvPath))
98+
.digest("hex");
99+
assert.equal(
100+
actual,
101+
expected,
102+
`bundled uv binary SHA256 does not match BUNDLED_UV_SHA256["${platformKey}"]; ` +
103+
`ensureUv() will reject this at runtime. ` +
104+
`To fix: update BUNDLED_UV_SHA256["${platformKey}"] in ` +
105+
`src/gaia/apps/webui/services/backend-installer.cjs to: ${actual}`,
106+
);
107+
}
108+
109+
/**
110+
* Resolve the absolute path to backend-installer.cjs from a smoke test
111+
* file located under tests/electron/. Centralised so a future move of
112+
* either tree only updates one place.
113+
*
114+
* @param {string} testFileUrl pass `import.meta.url` from the caller
115+
* @returns {string}
116+
*/
117+
export function backendInstallerPath(testFileUrl) {
118+
return path.resolve(
119+
path.dirname(fileURLToPath(testFileUrl)),
120+
"..",
121+
"..",
122+
"src",
123+
"gaia",
124+
"apps",
125+
"webui",
126+
"services",
127+
"backend-installer.cjs",
128+
);
129+
}

0 commit comments

Comments
 (0)