Files
openswarm/scripts/ci/verify-python-health.js
T

98 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
// Asserts the bundled python interpreter actually runs. Missing VCRedist DLL on Windows, a broken framework bundle on mac, or a truncated python-env mid-build all give "renderer up, backend never connects" with no clear cause. Running --version + a tiny import smoke catches it before the build ships.
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const h = require('./lib/app-harness');
function parseArgs(argv) {
const out = { app: null };
for (let i = 0; i < argv.length; i++) if (argv[i] === '--app') out.app = argv[++i];
return out;
}
// Resolve the bundled python binary relative to the packaged app root.
function pythonBin(appExe) {
if (process.platform === 'win32') {
return path.join(path.dirname(appExe), 'resources', 'python-env', 'python.exe');
}
if (process.platform === 'darwin') {
// Inside the .app bundle: Contents/Resources/python-env/bin/python3
const i = appExe.indexOf('.app');
const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4);
return path.join(appRoot, 'Contents', 'Resources', 'python-env', 'bin', 'python3');
}
return path.join(path.dirname(appExe), 'resources', 'python-env', 'bin', 'python3');
}
function main() {
const args = parseArgs(process.argv.slice(2));
const appExe = h.packagedAppPath(args.app);
const py = pythonBin(appExe);
process.stdout.write(`Bundled python: ${py}\n`);
if (!fs.existsSync(py)) { process.stderr.write(`\nPYTHON-HEALTH FAIL: ${py} not found (build did not stage python-env, or wrong path)\n`); process.exit(1); }
// 1) --version: catches DLL/dyld load failures instantly.
const v = spawnSync(py, ['--version'], { encoding: 'utf8', timeout: 30000 });
if (v.status !== 0) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: python --version exited ${v.status}\n`);
process.stderr.write(` stdout: ${(v.stdout || '').trim()}\n`);
process.stderr.write(` stderr: ${(v.stderr || '').trim()}\n`);
process.exit(1);
}
// Output goes to stdout or stderr depending on python version; treat both.
const versionLine = ((v.stdout || '') + (v.stderr || '')).trim();
const m = versionLine.match(/Python (\d+)\.(\d+)\.(\d+)/);
if (!m) { process.stderr.write(`\nPYTHON-HEALTH FAIL: could not parse version from "${versionLine}"\n`); process.exit(1); }
const [, maj, min] = m;
if (Number(maj) !== 3 || Number(min) < 13) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: bundled python is ${maj}.${min}, expected 3.13+ (backend requires 3.13 features)\n`);
process.exit(1);
}
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) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: import smoke exited ${smoke.status}\n`);
process.stderr.write(` stderr: ${(smoke.stderr || '').trim()}\n`);
process.exit(1);
}
process.stdout.write(` ok backend deps importable (${(smoke.stdout || '').trim()})\n`);
process.stdout.write('\nPYTHON-HEALTH PASS: bundled interpreter runs and the backends heaviest deps import cleanly.\n');
process.exit(0);
}
main();