[eric] build: per-arch bundled python-env; x64 DMG shipped arm64 python, bricking Intel Macs

This commit is contained in:
ciregenz
2026-07-06 14:52:26 -07:00
parent 1b96bc597c
commit 243207e9b9
4 changed files with 112 additions and 62 deletions
+16 -7
View File
@@ -72,6 +72,13 @@
"filter": [
"**/*"
]
},
{
"from": "build-staging/python-env/${arch}",
"to": "python-env",
"filter": [
"**/*"
]
}
]
},
@@ -94,6 +101,15 @@
},
"win": {
"icon": "build/icon.ico",
"extraResources": [
{
"from": "python-env",
"to": "python-env",
"filter": [
"**/*"
]
}
],
"target": [
{
"target": "squirrel",
@@ -148,13 +164,6 @@
"**/*"
]
},
{
"from": "python-env",
"to": "python-env",
"filter": [
"**/*"
]
},
{
"from": "build-staging/router",
"to": "router",
+42 -43
View File
@@ -26,6 +26,22 @@ elif [[ "${1:-}" == "--sign" ]]; then
SIGN_MODE=true
fi
# Arch targets for this run. Publish always builds both DMGs; otherwise
# OSW_BUILD_ARCH (arm64|x64|both) overrides, defaulting to the host. Node,
# python-env, and the electron-builder flags below all derive from this ONE
# list, so a staged-arch vs packed-arch mismatch can't happen (the class of
# bug that shipped arm64 python inside the x64 DMG).
if $PUBLISH_MODE; then
BUILD_ARCHS=(arm64 x64)
else
case "${OSW_BUILD_ARCH:-host}" in
both) BUILD_ARCHS=(arm64 x64) ;;
x64) BUILD_ARCHS=(x64) ;;
arm64) BUILD_ARCHS=(arm64) ;;
*) if [[ "$(uname -m)" == "x86_64" ]]; then BUILD_ARCHS=(x64); else BUILD_ARCHS=(arm64); fi ;;
esac
fi
# Defensive: detach any leftover OpenSwarm DMG volumes from prior failed builds.
# hdiutil's "Resource busy" / volume-name-collision errors almost always trace
# back to a stale mount in /Volumes (e.g. after a build crash or a still-open
@@ -265,17 +281,6 @@ fi
echo "Frontend build complete."
echo ""
# Step 2: Build Python environment
echo "[2/4] Building Python environment..."
bash "$SCRIPT_DIR/build-python-env.sh"
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
echo "ERROR: Python environment not found at electron/python-env/"
exit 1
fi
echo "Python environment ready."
echo ""
# Step 3: Fetch Router from npm
# The 9router Next.js server is published as an npm package with a pre-built
# standalone output. We install it into a scratch dir and stage it directly
@@ -293,6 +298,19 @@ fi
echo "Router staged."
echo ""
# Step 3a: Bundled Python env, one per target arch (must run AFTER the
# build-staging reset above or the freshly staged envs get wiped).
echo "[3a] Building bundled Python env(s): ${BUILD_ARCHS[*]}"
for A in "${BUILD_ARCHS[@]}"; do
bash "$SCRIPT_DIR/build-python-env.sh" "$A"
if [[ ! -f "$STAGING_DIR/python-env/$A/bin/python3.13" ]]; then
echo "ERROR: python-env ($A) missing at $STAGING_DIR/python-env/$A"
exit 1
fi
done
echo "Python environment(s) ready."
echo ""
# Step 3b: Bundle a real Node.js binary so 9Router and MCP servers don't
# fall back to ELECTRON_RUN_AS_NODE on user machines without system node.
# Two wins:
@@ -351,21 +369,10 @@ NPMSH
echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))"
}
# Publish mode builds both DMGs from one invocation, so always stage both.
# Single-arch local/sign builds only need the host arch.
if $PUBLISH_MODE; then
download_node_for_arch arm64
download_node_for_arch x64
else
HOST_ARCH=$(uname -m)
if [[ "$HOST_ARCH" == "arm64" ]]; then
download_node_for_arch arm64
elif [[ "$HOST_ARCH" == "x86_64" ]]; then
download_node_for_arch x64
else
echo "WARNING: unknown host arch $HOST_ARCH — skipping node bundle (will fall back to ELECTRON_RUN_AS_NODE)"
fi
fi
# Stage node for every arch this run packs (BUILD_ARCHS decides, top of file).
for A in "${BUILD_ARCHS[@]}"; do
download_node_for_arch "$A"
done
echo ""
# Step 3c: Pre-build the webapp-template node_modules archive so first-app
@@ -488,27 +495,19 @@ fi
# Caller's NODE_OPTIONS is respected if already set.
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=12288}"
# Pack exactly the arches we staged for (BUILD_ARCHS, top of file).
EB_ARCH_FLAGS=()
for A in "${BUILD_ARCHS[@]}"; do
EB_ARCH_FLAGS+=("--$A")
done
if $PUBLISH_MODE; then
npx electron-builder --mac --arm64 --x64 --publish always
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish always
elif $SIGN_MODE; then
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
npx electron-builder --mac --arm64 --publish never
elif [[ "$ARCH" == "x86_64" ]]; then
npx electron-builder --mac --x64 --publish never
else
npx electron-builder --mac --publish never
fi
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
npx electron-builder --mac --arm64 --publish never
elif [[ "$ARCH" == "x86_64" ]]; then
npx electron-builder --mac --x64 --publish never
else
npx electron-builder --mac --publish never
fi
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
fi
rm -rf "$PROJECT_ROOT/electron/build-staging"
+27 -12
View File
@@ -1,28 +1,42 @@
#!/bin/bash
set -euo pipefail
# Build an embedded Python environment for the Electron app.
# Build an embedded Python environment for the Electron app (macOS).
#
# Downloads a standalone Python build from python-build-standalone,
# creates a venv, and installs all backend dependencies.
# The resulting python-env/ directory is bundled into the Electron app.
#
# Usage: build-python-env.sh [arm64|x64] (default: host arch)
#
# The env stages under electron/build-staging/python-env/<arch> so
# electron-builder's ${arch} macro bundles the MATCHING env per pack, same
# pattern as node/${arch}. Never bundle one host-arch env into both DMGs:
# that shipped arm64 python inside the x64 app and killed every Intel Mac.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ELECTRON_DIR="$PROJECT_ROOT/electron"
PYTHON_ENV_DIR="$ELECTRON_DIR/python-env"
PYTHON_VERSION="3.13"
PYTHON_FULL_VERSION="3.13.2"
ARCH="$(uname -m)"
ARCH="${1:-$(uname -m)}"
if [[ "$ARCH" == "arm64" ]]; then
PLATFORM_TAG="aarch64-apple-darwin"
elif [[ "$ARCH" == "x86_64" ]]; then
PLATFORM_TAG="x86_64-apple-darwin"
else
echo "Unsupported architecture: $ARCH"
exit 1
case "$ARCH" in
arm64|aarch64) ARCH="arm64"; PLATFORM_TAG="aarch64-apple-darwin" ;;
x64|x86_64) ARCH="x64"; PLATFORM_TAG="x86_64-apple-darwin" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
PYTHON_ENV_DIR="$ELECTRON_DIR/build-staging/python-env/$ARCH"
# Cross-building x64 on Apple Silicon runs the x64 python under Rosetta
# (pip then resolves x86_64 wheels, including the SDK's bundled claude CLI).
if [[ "$ARCH" == "x64" && "$(uname -m)" == "arm64" ]]; then
if ! arch -x86_64 /usr/bin/true 2>/dev/null; then
echo "ERROR: building the x64 python-env on Apple Silicon requires Rosetta 2."
echo " Install it with: softwareupdate --install-rosetta --agree-to-license"
exit 1
fi
fi
RELEASE_TAG="20250212"
@@ -44,6 +58,7 @@ if [[ -d "$PYTHON_ENV_DIR" ]]; then
echo "Removing old python-env..."
rm -rf "$PYTHON_ENV_DIR"
fi
mkdir -p "$(dirname "$PYTHON_ENV_DIR")"
# Download standalone Python
echo "Downloading standalone Python from python-build-standalone..."
@@ -301,7 +316,7 @@ PLIST
# python-env/ via realpath, and libpython loads via the rewritten
# @executable_path path.
if ! "$PY_APP/Contents/MacOS/python3" -c \
"import sys; assert sys.prefix.endswith('python-env'), sys.prefix" 2>/dev/null; then
"import sys, os; assert os.path.realpath(sys.prefix) == os.path.realpath('$PYTHON_ENV_DIR'), sys.prefix" 2>/dev/null; then
echo "ERROR: Python.app wrapper failed self-test (libpython or stdlib not findable)" >&2
echo " Try: $PY_APP/Contents/MacOS/python3 -c 'import sys; print(sys.prefix)'" >&2
exit 1
+27
View File
@@ -54,6 +54,33 @@ function main() {
}
process.stdout.write(` ok python ${versionLine}\n`);
// 1b) macOS: the bundled python's arch slices must cover the app's. An arm64
// python inside the x64 app RUNS on an arm64 build host (native, not Rosetta),
// so --version alone can never catch the cross-arch bundle bug that bricked
// every Intel Mac. lipo compares what the file IS, not what the host can run.
if (process.platform === 'darwin') {
const i = appExe.indexOf('.app');
const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4);
const mainBin = path.join(appRoot, 'Contents', 'MacOS', path.basename(appRoot, '.app'));
const archsOf = (bin) => {
const r = spawnSync('lipo', ['-archs', bin], { encoding: 'utf8', timeout: 15000 });
if (r.status !== 0) return null;
return (r.stdout || '').trim().split(/\s+/).filter(Boolean);
};
const appArchs = archsOf(mainBin);
const pyArchs = archsOf(fs.realpathSync(py));
if (!appArchs || !pyArchs) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: lipo could not read archs (app=${appArchs}, python=${pyArchs})\n`);
process.exit(1);
}
const missing = appArchs.filter((a) => !pyArchs.includes(a));
if (missing.length > 0) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: app is [${appArchs}] but bundled python is [${pyArchs}] (missing ${missing}). This build would brick ${missing.join('/')} Macs.\n`);
process.exit(1);
}
process.stdout.write(` ok arch match (app [${appArchs}] / python [${pyArchs}])\n`);
}
// 2) Import smoke: load the heaviest deps to catch a half-extracted site-packages tree (rare but lethal).
const smoke = spawnSync(py, ['-c', 'import sys, fastapi, anthropic, pydantic, httpx, jsonschema; print(sys.version_info[:3])'], { encoding: 'utf8', timeout: 30000 });
if (smoke.status !== 0) {