mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] build: per-arch bundled python-env; x64 DMG shipped arm64 python, bricking Intel Macs
This commit is contained in:
+16
-7
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user