Share install and deploy preparation with verified release bundle reuse

This commit is contained in:
2026-10-25 23:15:32 -05:00
parent b5a8393c4a
commit 01f838b6eb
6 changed files with 648 additions and 71 deletions
+9 -35
View File
@@ -6,6 +6,7 @@ import { spawn, execSync } from "child_process";
import { statSync, readFileSync } from "fs"; import { statSync, readFileSync } from "fs";
import { createHash } from "crypto"; import { createHash } from "crypto";
import path from "path"; import path from "path";
import { prepareGalaxyBuild } from "../../lib/prepare-galaxy-build.mjs";
// ─── Configuration ─────────────────────────────────────────────────────────── // ─── Configuration ───────────────────────────────────────────────────────────
@@ -307,47 +308,17 @@ function App() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let prepared: Awaited<ReturnType<typeof prepareGalaxyBuild>> | undefined;
try { try {
// ─── Step 0: Check working directory ─────────────────────────── prepared = await prepareGalaxyBuild(WORKSPACE_ROOT, { updateStep, appendLog });
updateStep(0, { status: "running" }); const { appPath } = prepared;
await runCommandStreaming(
`if [ -n "$(git status --porcelain)" ]; then git status --short; exit 1; fi`,
WORKSPACE_ROOT,
(line) => appendLog(0, line)
);
updateStep(0, { status: "done" });
// ─── Step 1: Pull current branch ────────────────────────────────
updateStep(1, { status: "running" });
const branch = execSync("git branch --show-current", {
cwd: WORKSPACE_ROOT,
encoding: "utf8",
}).trim();
if (!branch) throw new Error("Cannot deploy from a detached HEAD");
await runCommandStreaming(
`git pull --ff-only origin "${branch}"`,
WORKSPACE_ROOT,
(line) => appendLog(1, line)
);
updateStep(1, { status: "done", detail: branch });
// ─── Step 2: Bundle ─────────────────────────────────────────────
updateStep(2, { status: "running" });
await runCommandStreaming(
"cargo bundle --release --bin galaxy-oss --package galaxy --features release_bundle,extern_plist",
WORKSPACE_ROOT,
(line) => appendLog(2, line)
);
updateStep(2, { status: "done" });
// ─── Step 3: Sign and zip ─────────────────────────────────────── // ─── Step 3: Sign and zip ───────────────────────────────────────
updateStep(3, { status: "running" }); updateStep(3, { status: "running" });
// Distribution bundles must use a release profile so rust-embed includes assets in the // Distribution bundles must use a release profile so rust-embed includes assets in the
// executable instead of resolving them from the build machine's checkout at runtime. // executable instead of resolving them from the build machine's checkout at runtime.
const appDir = path.join(WORKSPACE_ROOT, "target/release/bundle/osx"); const zipPath = path.join(path.dirname(appPath), "Galaxy.zip");
const appPath = path.join(appDir, "Galaxy.app");
const zipPath = path.join(WORKSPACE_ROOT, "Galaxy.zip");
await runCommandStreaming( await runCommandStreaming(
`/usr/bin/codesign --force --deep --sign - "${appPath}"`, `/usr/bin/codesign --force --deep --sign - "${appPath}"`,
@@ -546,6 +517,7 @@ function App() {
// Cleanup zip // Cleanup zip
execSync(`rm -f "${zipPath}"`, { stdio: "pipe" }); execSync(`rm -f "${zipPath}"`, { stdio: "pipe" });
} catch (err: any) { } catch (err: any) {
process.exitCode = 1;
// Mark current running step as error // Mark current running step as error
setSteps((prev) => setSteps((prev) =>
prev.map((s) => prev.map((s) =>
@@ -554,12 +526,14 @@ function App() {
: s : s
) )
); );
} finally {
await prepared?.release();
} }
})(); })();
}, []); }, []);
const allDone = steps.every((s) => s.status === "done" || s.status === "error");
const hasError = steps.some((s) => s.status === "error"); const hasError = steps.some((s) => s.status === "error");
const allDone = hasError || steps.every((s) => s.status === "done");
return ( return (
<Box flexDirection="column" padding={1}> <Box flexDirection="column" padding={1}>
@@ -4,6 +4,7 @@ import { render, Text, Box } from "ink";
import Spinner from "ink-spinner"; import Spinner from "ink-spinner";
import { spawn, execFileSync, execSync } from "child_process"; import { spawn, execFileSync, execSync } from "child_process";
import path from "path"; import path from "path";
import { prepareGalaxyBuild } from "../../lib/prepare-galaxy-build.mjs";
import fs from "fs"; import fs from "fs";
// ─── Configuration ─────────────────────────────────────────────────────────── // ─── Configuration ───────────────────────────────────────────────────────────
@@ -187,46 +188,13 @@ function App() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let prepared: Awaited<ReturnType<typeof prepareGalaxyBuild>> | undefined;
try { try {
const appName = "Galaxy.app"; const appName = "Galaxy.app";
const bundleDir = path.join(WORKSPACE_ROOT, "target/release/bundle/osx");
const appPath = path.join(bundleDir, appName);
const destPath = path.join("/Applications", appName); const destPath = path.join("/Applications", appName);
// ─── Step 0: Check working directory ─────────────────────────── prepared = await prepareGalaxyBuild(WORKSPACE_ROOT, { updateStep, appendLog });
updateStep(0, { status: "running" }); const { appPath } = prepared;
const changes = execFileSync("git", ["status", "--short"], {
cwd: WORKSPACE_ROOT,
encoding: "utf8",
}).trimEnd();
if (changes) {
for (const line of changes.split("\n")) appendLog(0, line);
throw new Error("Working directory has uncommitted changes. Commit or stash them before installing.");
}
updateStep(0, { status: "done" });
// ─── Step 1: Pull current branch ────────────────────────────────
updateStep(1, { status: "running" });
const branch = execSync("git branch --show-current", {
cwd: WORKSPACE_ROOT,
encoding: "utf8",
}).trim();
if (!branch) throw new Error("Cannot install from a detached HEAD");
await runCommandStreaming(
`git pull --ff-only origin "${branch}"`,
WORKSPACE_ROOT,
(line) => appendLog(1, line)
);
updateStep(1, { status: "done", detail: branch });
// ─── Step 2: Bundle ─────────────────────────────────────────────
updateStep(2, { status: "running" });
await runCommandStreaming(
"cargo bundle --release --format osx --bin galaxy-oss --package galaxy --features release_bundle,extern_plist",
WORKSPACE_ROOT,
(line) => appendLog(2, line)
);
updateStep(2, { status: "done" });
// ─── Step 3: Copy to /Applications ───────────────────────────── // ─── Step 3: Copy to /Applications ─────────────────────────────
updateStep(3, { status: "running" }); updateStep(3, { status: "running" });
@@ -286,6 +254,8 @@ function App() {
: s : s
) )
); );
} finally {
await prepared?.release();
} }
})(); })();
}, []); }, []);
+46
View File
@@ -0,0 +1,46 @@
# Shared install/deploy build preparation
`make install` and `make deploy` both call `prepareGalaxyBuild` before their
separate copy/launch or sign/archive/upload steps. Both require a clean checkout
on a branch and pull that branch from `origin` with `--ff-only`.
The shared builder keeps the existing optimized release profile, binary, and
`release_bundle,extern_plist` features. It does not run `cargo clean`.
After pulling, it compares the source commit, build command, toolchain/SDK,
architecture, environment, and Cargo configuration with the last successful
build. An exact match with an intact cached app skips Cargo and bundling.
Otherwise it runs the release bundle command, allowing Cargo to reuse its normal
compilation artifacts. The first invocation builds rather than trusting an
unidentified app already in `target/release`.
The cache lives in `target/galaxy-build-cache`. Bundle contents, modes, and
symlinks are checked before reuse. Each caller receives a private copy, so
signing for deployment cannot modify the shared cached app. Preparation is
serialized; copying, installing, and uploading use independent private copies.
The lock atomically records process identity and waits for live builds without
a fixed cold-build timeout. If a command is killed and leaves an abandoned lock,
the next command reports the lock path for manual removal rather than risk
stealing a live build's lock. Callers must release their copy in a `finally`
block.
Cargo metadata supplies the effective build output directory, including custom
`CARGO_TARGET_DIR` and `build.target-dir` settings. The shared cache remains
checkout-local so both commands use the same lock and cache. Both commands
explicitly request the Rust host target, overriding cross-target configuration;
these commands prepare native macOS bundles.
Unverifiable tool identity, external Cargo configuration, or mutable build
input overrides conservatively disable reuse rather than risk shipping stale
output. Upload credentials and Yarn invocation metadata do not identify the
application build. No credential values are stored in the manifest.
To force a fresh bundle while retaining compiled dependencies, remove
`target/galaxy-build-cache` when no install/deploy command is running.
`make clean` removes both Cargo output and this cache.
Run focused cache tests without installing or deploying anything:
```sh
node --test script/lib/prepare-galaxy-build.test.mjs
```
+23
View File
@@ -0,0 +1,23 @@
export interface BuildStepUpdate {
status?: 'pending' | 'running' | 'done' | 'error';
detail?: string;
}
export interface BuildObserver {
updateStep(index: number, update: BuildStepUpdate): void;
appendLog(index: number, line: string): void;
}
export interface PreparedGalaxyBuild {
/** Private Galaxy.app copy; safe for consumers to sign without changing the cache. */
appPath: string;
reused: boolean;
identity?: Record<string, unknown>;
/** Call in a finally block after the app has been copied or packaged. */
release(): Promise<void>;
}
export function prepareGalaxyBuild(
workspaceRoot: string,
observer?: BuildObserver | ((index: number, update: BuildStepUpdate) => void),
): Promise<PreparedGalaxyBuild>;
+248
View File
@@ -0,0 +1,248 @@
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const BUNDLE_ARGS = ['bundle', '--release', '--format', 'osx', '--bin', 'galaxy-oss', '--package', 'galaxy', '--features', 'release_bundle,extern_plist'];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const sha = (value) => createHash('sha256').update(value).digest('hex');
async function runCommand(command, args, { cwd, env, onLog, capture = true }) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
const consume = (stream, collect, emit) => {
let pending = '';
stream.on('data', (chunk) => {
const text = chunk.toString();
if (collect) output += text;
const lines = (pending + text).split(/\r\n|\r|\n/);
pending = lines.pop();
for (const line of lines) if (emit && line.trim()) onLog?.(line);
});
stream.on('end', () => { if (emit && pending.trim()) onLog?.(pending); });
};
const metadata = command === 'cargo' && args[0] === 'metadata';
consume(child.stdout, capture, !metadata);
consume(child.stderr, false, true);
child.on('error', reject);
child.on('close', (code) => code === 0 ? resolve(output.trim()) : reject(new Error(`${command} ${args.join(' ')} failed (exit ${code})`)));
});
}
async function processStart(pid) {
try { return await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { capture: true }); }
catch { return null; }
}
async function acquireLock(cacheDir) {
await fs.mkdir(cacheDir, { recursive: true });
const lock = path.join(cacheDir, 'prepare.lock');
const start = await processStart(process.pid);
if (!start) throw new Error('Cannot establish process identity for Galaxy build lock');
const owner = JSON.stringify({ pid: process.pid, start, token: randomUUID() });
while (true) {
try {
// A symlink publishes the complete owner record in a single atomic operation.
await fs.symlink(owner, lock);
return async () => {
if (await fs.readlink(lock) !== owner) throw new Error('Galaxy build lock ownership changed');
await fs.unlink(lock);
};
} catch (error) { if (error.code !== 'EEXIST') throw error; }
let other;
try { other = JSON.parse(await fs.readlink(lock)); }
catch (error) {
if (error.code === 'ENOENT') continue;
throw new Error(`Invalid Galaxy build lock at ${lock}; remove it manually after confirming no build is running`, { cause: error });
}
if (!Number.isSafeInteger(other.pid) || other.pid <= 0 || typeof other.start !== 'string') {
throw new Error(`Invalid Galaxy build lock at ${lock}; remove it manually after confirming no build is running`);
}
const current = await processStart(other.pid);
if (current === other.start) { await sleep(1000); continue; }
if (!current) {
try { process.kill(other.pid, 0); await sleep(1000); continue; }
catch (error) { if (error.code !== 'ESRCH') throw error; }
}
// Never reclaim a lock automatically: another waiter could race a new owner.
throw new Error(`Stale Galaxy build lock at ${lock}; remove it manually after confirming no build is running`);
}
}
async function optionalHash(file) {
try { return sha(await fs.readFile(file)); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
async function cargoConfigs(root, env, home) {
const result = {};
let dir = path.resolve(root);
while (true) {
for (const name of ['config', 'config.toml']) {
const file = path.join(dir, '.cargo', name);
result[file] = await optionalHash(file);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
const cargoHome = env.CARGO_HOME || path.join(home, '.cargo');
for (const name of ['config', 'config.toml']) {
const file = path.resolve(cargoHome, name);
result[file] = await optionalHash(file);
}
return result;
}
// These two UI packages contribute only their own launch shims, not Cargo build inputs.
// Strip their bin directories from both the key and subprocess environment.
function buildEnvironment(root, env) {
const bins = new Set(['build-and-install-to-applications', 'build-and-deploy-hermes'].map((name) => path.join(root, 'script', name, 'node_modules', '.bin')));
return { ...env, PATH: (env.PATH || '').split(path.delimiter).filter((entry) => !bins.has(path.resolve(entry)) && !(entry.startsWith('/var/folders/') && /^yarn--[0-9]+-[0-9]+\.[0-9]+$/.test(path.basename(entry)))).join(path.delimiter) };
}
// Hash build-affecting values without persisting secrets. Yarn/npm package metadata,
// lifecycle context, and upload credentials differ between the two entrypoints.
function environmentDigest(env) {
const ignored = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'INIT_CWD', 'HERMES_PASS', 'HERMES_USER', 'npm_config_argv', 'npm_lifecycle_event', 'npm_lifecycle_script', 'npm_execpath', 'npm_node_execpath']);
return sha(JSON.stringify(Object.entries(env).filter(([key]) => !ignored.has(key) && !key.startsWith('npm_package_')).sort(([a], [b]) => a.localeCompare(b))));
}
async function tree(root) {
const entries = [];
async function visit(relative) {
const current = path.join(root, relative);
const stat = await fs.lstat(current);
const base = { path: relative, mode: stat.mode & 0o7777 };
if (stat.isSymbolicLink()) entries.push({ ...base, type: 'link', target: await fs.readlink(current) });
else if (stat.isFile()) entries.push({ ...base, type: 'file', size: stat.size, hash: sha(await fs.readFile(current)) });
else if (stat.isDirectory()) {
entries.push({ ...base, type: 'dir' });
for (const name of (await fs.readdir(current)).sort()) await visit(path.join(relative, name));
} else throw new Error(`Unsupported bundle entry: ${current}`);
}
await visit('');
return sha(JSON.stringify(entries));
}
async function identity(root, env, run, home, platform, arch, bundleArgs) {
const cmd = (name, args) => run(name, args, { cwd: root, env });
const [commit, rustc, cargo, bundle, clang, sdkPath, sdkVersion, sdkBuild, developerDir, ignoredResources, configs] = await Promise.all([
cmd('git', ['rev-parse', '--verify', 'HEAD']),
cmd('rustc', ['--version', '--verbose']),
cmd('cargo', ['--version', '--verbose']),
cmd('cargo', ['bundle', '--version']),
cmd('clang', ['--version']),
cmd('xcrun', ['--show-sdk-path']),
cmd('xcrun', ['--show-sdk-version']),
cmd('xcrun', ['--show-sdk-build-version']),
cmd('xcode-select', ['-p']),
cmd('git', ['ls-files', '--others', '--ignored', '--exclude-standard', '--', 'app/assets/onboarding', 'app/channels/oss/icon/no-padding']),
cargoConfigs(root, env, home),
]);
// Overrides and generated resource caches may refer to mutable inputs outside Git.
// A build using these inputs is safe to perform, but is not reusable.
const external = Object.keys(env).some((key) => /^(GALAXY_WORMHOLE_HELPERS_DIR|SETTINGS_SCHEMA_CACHE|CARGO_TARGET_DIR|RUSTC_WRAPPER|RUSTC_WORKSPACE_WRAPPER|CC|CXX|AR|RUSTFLAGS|CARGO_ENCODED_RUSTFLAGS|GIT_RELEASE_TAG|GALAXY_GIT_COMMIT|GALAXY_GIT_DIRTY|GITHUB_SHA|CI_COMMIT_SHA|SDKROOT|DEVELOPER_DIR)$/.test(key));
const externalConfig = Object.entries(configs).some(([file, hash]) => hash && !file.startsWith(path.join(root, '.cargo') + path.sep));
return { commit: commit.trim(), rustc, cargo, bundle, clang, sdkPath, sdkVersion, sdkBuild, developerDir, platform, arch, command: bundleArgs, environment: environmentDigest(env), configs, external: external || externalConfig || Boolean(ignoredResources.trim()) };
}
/**
* Prepare a release bundle. observer receives (stepIndex, {status, detail?}) and
* log(stepIndex, line), or may be { updateStep, appendLog } as in the Ink apps.
* Returns { appPath, reused, identity, release }. appPath is a private copy:
* callers may sign or install it, and must call release() in a finally block.
* options.run is for standalone tests; normal callers should omit options.
*/
export async function prepareGalaxyBuild(workspaceRoot, observer = {}, options = {}) {
const root = path.resolve(workspaceRoot);
const cache = path.join(root, 'target', 'galaxy-build-cache');
const run = options.run || runCommand;
const env = buildEnvironment(root, options.env || process.env);
const update = typeof observer === 'function' ? observer : observer.updateStep?.bind(observer) || (() => {});
const log = typeof observer === 'function' ? (() => {}) : observer.appendLog?.bind(observer) || observer.log?.bind(observer) || (() => {});
const command = (name, args, step) => run(name, args, { cwd: root, env, capture: !(name === 'cargo' && args[0] === 'bundle' && args.includes('--release')), onLog: (line) => log(step, line) });
const unlock = await acquireLock(cache);
try {
update(0, { status: 'running' });
const changes = await command('git', ['status', '--porcelain', '--untracked-files=all'], 0);
if (changes.trim()) {
for (const line of changes.split('\n')) log(0, line);
throw new Error('Working directory has uncommitted changes. Commit or stash them before building.');
}
update(0, { status: 'done' });
update(1, { status: 'running' });
const branch = (await command('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], 1)).trim();
if (!branch) throw new Error('Cannot build from a detached HEAD');
await command('git', ['pull', '--ff-only', 'origin', branch], 1);
if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 1)).trim()) throw new Error('Working directory changed after pull');
const pulledCommit = (await command('git', ['rev-parse', '--verify', 'HEAD'], 1)).trim();
if (!/^[0-9a-f]{40,64}$/i.test(pulledCommit)) throw new Error('Cannot verify pulled Git commit');
update(1, { status: 'done', detail: branch });
update(2, { status: 'running' });
const metadata = JSON.parse(await command('cargo', ['metadata', '--format-version', '1', '--no-deps'], 2));
if (!path.isAbsolute(metadata.target_directory)) throw new Error('Cargo metadata did not supply an absolute target directory');
const targetDir = metadata.target_directory;
// Explicit --target takes precedence over Cargo build.target and CARGO_BUILD_TARGET.
const rustcVersion = await command('rustc', ['--version', '--verbose'], 2);
const host = rustcVersion.match(/^host:\s*(\S+)\s*$/m)?.[1];
if (!host || !/^[a-zA-Z0-9_-]+-apple-darwin$/.test(host)) throw new Error('Cannot determine native macOS Rust host');
const bundleArgs = [...BUNDLE_ARGS, '--target', host];
// Unknown tool versions fail closed for reuse, while still permitting a normal build.
let key;
try { key = await identity(root, env, run, options.home || os.homedir(), options.platform || process.platform, options.arch || process.arch, bundleArgs); }
catch (error) { log(2, `Cannot verify cache identity: ${error.message}`); }
const snapshot = path.join(cache, 'Galaxy.app');
const manifestPath = path.join(cache, 'manifest.json');
let reused = false;
if (key && key.commit !== pulledCommit) key = undefined;
if (key && !key.external) {
key.targetDir = targetDir;
try {
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
reused = manifest.version === 1 && JSON.stringify(manifest.identity) === JSON.stringify(key) && manifest.tree === await tree(snapshot);
} catch { /* Missing or damaged cache is a miss. */ }
}
if (!reused) {
const builtApp = path.join(targetDir, host, 'release', 'bundle', 'osx', 'Galaxy.app');
// Prevent cargo-bundle from leaving us with an old app after an incomplete build.
await fs.rm(builtApp, { recursive: true, force: true });
await command('cargo', bundleArgs, 2);
const digest = await tree(builtApp);
if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 2)).trim() ||
(await command('git', ['rev-parse', '--verify', 'HEAD'], 2)).trim() !== pulledCommit) {
throw new Error('Checkout changed during bundle preparation');
}
const staged = path.join(cache, `stage-${randomUUID()}`);
try {
await fs.cp(builtApp, staged, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, force: false });
if (await tree(staged) !== digest) throw new Error('Bundled app changed while caching');
await fs.rm(manifestPath, { force: true });
await fs.rm(snapshot, { recursive: true, force: true });
await fs.rename(staged, snapshot);
if (key && !key.external) {
const tempManifest = path.join(cache, `manifest-${randomUUID()}.json`);
await fs.writeFile(tempManifest, JSON.stringify({ version: 1, identity: key, tree: digest }));
await fs.rename(tempManifest, manifestPath);
}
} finally { await fs.rm(staged, { recursive: true, force: true }); }
}
const leaseDir = path.join(cache, `lease-${randomUUID()}`);
const appPath = path.join(leaseDir, 'Galaxy.app');
try {
await fs.mkdir(leaseDir);
await fs.cp(snapshot, appPath, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, force: false });
if (await tree(appPath) !== await tree(snapshot)) throw new Error('Bundled app changed while copying');
if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 2)).trim() ||
(await command('git', ['rev-parse', '--verify', 'HEAD'], 2)).trim() !== pulledCommit) {
throw new Error('Checkout changed during bundle preparation');
}
} catch (error) { await fs.rm(leaseDir, { recursive: true, force: true }); throw error; }
update(2, { status: 'done', detail: reused ? 'Reused verified bundle' : 'Built release bundle' });
return { appPath, reused, identity: key, release: async () => fs.rm(leaseDir, { recursive: true, force: true }) };
} finally { await unlock(); }
}
+316
View File
@@ -0,0 +1,316 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { prepareGalaxyBuild } from './prepare-galaxy-build.mjs';
const roots = [];
afterEach(async () => {
for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true });
});
async function fixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'galaxy-prep-test-'));
roots.push(root);
const state = { commit: 'a'.repeat(40), branch: 'main', status: '', rustc: 'rustc 1\nhost: aarch64-apple-darwin', cargo: 'cargo 1', bundle: 'cargo-bundle 1', clang: 'clang 1', sdkPath: '/sdk/one', sdkVersion: '14.0', sdkBuild: '22A', developerDir: '/Library/Developer/CommandLineTools', ignoredResources: '', builds: 0, fail: false, commands: [] };
const stateTarget = path.join(root, 'target');
const app = () => path.join(state.targetDir, 'aarch64-apple-darwin/release/bundle/osx/Galaxy.app');
state.targetDir = stateTarget;
const run = async (name, args, { onLog, env } = {}) => {
state.commands.push([name, ...args]);
if (env && name === 'cargo' && args[0] === 'bundle' && args.includes('--release')) state.buildPath = env.PATH;
const key = [name, ...args].join(' ');
if (key === 'git status --porcelain --untracked-files=all') return state.status;
if (key === 'git symbolic-ref --quiet --short HEAD') {
if (!state.branch) throw new Error('detached HEAD');
return state.branch;
}
if (key.startsWith('git pull --ff-only origin ')) { onLog?.('Already up to date.'); return ''; }
if (key === 'git rev-parse --verify HEAD') return state.commit;
if (key === 'rustc --version --verbose') return state.rustc;
if (key === 'cargo --version --verbose') return state.cargo;
if (key === 'cargo bundle --version') return state.bundle;
if (key === 'cargo metadata --format-version 1 --no-deps') return JSON.stringify({ target_directory: state.targetDir });
if (key === 'clang --version') return state.clang;
if (key === 'xcrun --show-sdk-path') { if (state.failSdk) throw new Error('SDK probe failed'); return state.sdkPath; }
if (key === 'xcrun --show-sdk-version') return state.sdkVersion;
if (key === 'xcrun --show-sdk-build-version') return state.sdkBuild;
if (key === 'xcode-select -p') return state.developerDir;
if (key.startsWith('git ls-files --others --ignored --exclude-standard --')) return state.ignoredResources;
if (key.startsWith('cargo bundle --release ')) {
state.builds++;
if (state.changeCommitOnBuild) state.commit = 'c'.repeat(40);
if (state.fail) throw new Error('bundle failed');
await fs.mkdir(path.join(app(), 'Contents/MacOS'), { recursive: true });
await fs.writeFile(path.join(app(), 'Contents/MacOS/galaxy-oss'), `built ${state.builds}`);
await fs.chmod(path.join(app(), 'Contents/MacOS/galaxy-oss'), 0o755);
await fs.symlink('MacOS/galaxy-oss', path.join(app(), 'Contents/link'));
return '';
}
throw new Error(`Unexpected command: ${key}`);
};
const options = { run, env: { PATH: '/usr/bin' }, home: path.join(root, 'home'), platform: 'darwin', arch: 'arm64' };
const events = [];
const observer = { updateStep: (index, event) => events.push([index, event]), appendLog: (index, line) => events.push([index, line]) };
async function prepare() { return prepareGalaxyBuild(root, observer, options); }
return { root, state, get app() { return app(); }, options, observer, events, prepare };
}
async function withRelease(result) {
const text = await fs.readFile(path.join(result.appPath, 'Contents/MacOS/galaxy-oss'), 'utf8');
await result.release();
await assert.rejects(fs.access(result.appPath));
return text;
}
test('first run distrusts an existing bundle; exact identity reuses verified snapshot, not signed output', async () => {
const f = await fixture();
await fs.mkdir(path.join(f.app, 'Contents/MacOS'), { recursive: true });
await fs.writeFile(path.join(f.app, 'Contents/MacOS/galaxy-oss'), 'old');
const first = await f.prepare();
assert.equal(first.reused, false);
assert.equal(await withRelease(first), 'built 1');
await fs.writeFile(path.join(f.app, 'Contents/MacOS/galaxy-oss'), 'signed and mutated');
const second = await f.prepare();
assert.equal(second.reused, true);
assert.equal(await withRelease(second), 'built 1');
assert.equal(f.state.builds, 1);
assert.deepEqual(f.state.commands.find((cmd) => cmd[0] === 'cargo' && cmd[1] === 'bundle' && cmd.includes('--release')), ['cargo', 'bundle', '--release', '--format', 'osx', '--bin', 'galaxy-oss', '--package', 'galaxy', '--features', 'release_bundle,extern_plist', '--target', 'aarch64-apple-darwin']);
assert.deepEqual(f.events.filter(([index, event]) => index === 2 && event.status === 'done').map(([, event]) => event.detail), ['Built release bundle', 'Reused verified bundle']);
});
test('commit, environment, config and toolchain changes each invalidate reuse', async () => {
const f = await fixture();
await withRelease(await f.prepare());
const mutations = [
() => { f.state.commit = 'b'.repeat(40); },
() => { f.options.env.BUILD_PROFILE = 'other'; },
async () => { await fs.mkdir(path.join(f.root, '.cargo')); await fs.writeFile(path.join(f.root, '.cargo/config.toml'), '[env]\nEXAMPLE = "1"\n'); },
() => { f.state.rustc = 'rustc 2\nhost: aarch64-apple-darwin'; },
() => { f.state.cargo = 'cargo 2'; },
() => { f.state.bundle = 'cargo-bundle 2'; },
() => { f.state.sdkVersion = '15.0'; },
() => { f.state.sdkBuild = '23B'; },
() => { f.state.developerDir = '/Applications/Xcode.app/Contents/Developer'; },
() => { f.options.arch = 'x64'; },
];
for (const mutate of mutations) {
await mutate();
const result = await f.prepare();
assert.equal(result.reused, false);
await result.release();
}
assert.equal(f.state.builds, mutations.length + 1);
});
test('missing, changed, or extra snapshot entries force a rebuild', async () => {
const f = await fixture();
await withRelease(await f.prepare());
const cached = path.join(f.root, 'target/galaxy-build-cache/Galaxy.app/Contents');
for (const mutation of [
() => fs.rm(path.join(cached, 'MacOS/galaxy-oss')),
() => fs.writeFile(path.join(cached, 'MacOS/galaxy-oss'), 'tampered'),
() => fs.writeFile(path.join(cached, 'extra'), 'added'),
]) {
await mutation();
const result = await f.prepare();
assert.equal(result.reused, false);
await result.release();
}
assert.equal(f.state.builds, 4);
});
test('failed build does not record a successful cache identity or trust stale output', async () => {
const f = await fixture();
f.state.fail = true;
await assert.rejects(f.prepare(), /bundle failed/);
await assert.rejects(fs.access(path.join(f.root, 'target/galaxy-build-cache/manifest.json')));
f.state.fail = false;
const result = await f.prepare();
assert.equal(result.reused, false);
await result.release();
assert.equal(f.state.builds, 2);
});
test('dirty and detached checkouts abort before pulling or bundling', async () => {
const f = await fixture();
f.state.status = '?? change';
await assert.rejects(f.prepare(), /uncommitted changes/);
assert.equal(f.state.commands.some((cmd) => cmd[1] === 'pull'), false);
f.state.status = '';
f.state.branch = '';
await assert.rejects(f.prepare(), /detached HEAD/);
assert.equal(f.state.builds, 0);
});
test('external config and mutable overrides conservatively disable reuse', async () => {
const f = await fixture();
await fs.mkdir(path.join(f.options.home, '.cargo'), { recursive: true });
await fs.writeFile(path.join(f.options.home, '.cargo/config.toml'), '[build]\nrustflags = []\n');
await withRelease(await f.prepare());
const second = await f.prepare();
assert.equal(second.reused, false);
await second.release();
f.options.env.GALAXY_WORMHOLE_HELPERS_DIR = '/tmp/helpers';
const third = await f.prepare();
assert.equal(third.reused, false);
await third.release();
});
test('install and deploy Yarn metadata and upload credentials share the cache', async () => {
const f = await fixture();
const installerBin = path.join(f.root, 'script/build-and-install-to-applications/node_modules/.bin');
const deployBin = path.join(f.root, 'script/build-and-deploy-hermes/node_modules/.bin');
const yarnOne = '/var/folders/ab/cdef/T/yarn--1772886695123-0.1948674944443123';
const yarnTwo = '/var/folders/ab/cdef/T/yarn--1772886702989-0.9834001432421421';
Object.assign(f.options.env, { PATH: `${yarnOne}${path.delimiter}${installerBin}${path.delimiter}/usr/bin`, npm_package_name: 'build-and-install-to-applications', npm_package_scripts_start: 'tsx src/index.tsx', npm_lifecycle_script: 'tsx src/index.tsx', npm_lifecycle_event: 'start', INIT_CWD: '/install', npm_config_argv: '{\"original\":[\"install\"]}' });
await withRelease(await f.prepare());
assert.equal(f.state.buildPath, '/usr/bin');
Object.assign(f.options.env, { PATH: `${yarnTwo}${path.delimiter}${deployBin}${path.delimiter}/usr/bin`, npm_package_name: 'build-and-deploy-hermes', npm_package_scripts_start: 'tsx src/index.tsx', INIT_CWD: '/deploy', npm_config_argv: '{\"original\":[\"deploy\"]}', HERMES_PASS: 'secret', HERMES_USER: 'tester' });
const result = await f.prepare();
assert.equal(result.reused, true);
await result.release();
assert.equal(f.state.builds, 1);
f.options.env.PATH = `${yarnTwo}${path.delimiter}${deployBin}${path.delimiter}/usr/local/custom-tools${path.delimiter}/usr/bin`;
const changedTools = await f.prepare();
assert.equal(changedTools.reused, false);
assert.equal(f.state.buildPath, '/usr/local/custom-tools:/usr/bin');
await changedTools.release();
});
test('spawned Cargo metadata excludes warning stderr and hides JSON stdout from logs', async () => {
const f = await fixture();
const bin = path.join(f.root, 'fake-bin');
await fs.mkdir(bin);
const program = `#!/usr/bin/env node
import * as fs from 'node:fs';
import path from 'node:path';
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
if (command === 'git') {
if (args[0] === 'symbolic-ref') console.log('main');
if (args[0] === 'rev-parse') console.log('a'.repeat(40));
} else if (command === 'cargo' && args[0] === 'metadata') {
console.error('warning: metadata stderr');
console.log(JSON.stringify({ target_directory: path.join(process.cwd(), 'target') }));
} else if (command === 'cargo' && args[0] === 'bundle' && args.includes('--release')) {
const app = path.join(process.cwd(), 'target/aarch64-apple-darwin/release/bundle/osx/Galaxy.app');
fs.mkdirSync(app, { recursive: true });
fs.writeFileSync(path.join(app, 'binary'), 'fake');
} else if (command === 'rustc') console.log('rustc 1\\nhost: aarch64-apple-darwin');
else console.log('fixed version');
`;
for (const name of ['git', 'cargo', 'rustc', 'clang', 'xcrun', 'xcode-select']) {
const executable = path.join(bin, name);
await fs.writeFile(executable, program);
await fs.chmod(executable, 0o755);
}
const events = [];
const observer = { updateStep: () => {}, appendLog: (_, line) => events.push(line) };
const result = await prepareGalaxyBuild(f.root, observer, { env: { PATH: `${bin}${path.delimiter}${process.env.PATH}` }, home: f.options.home });
assert.equal(result.reused, false);
assert.ok(events.includes('warning: metadata stderr'));
assert.equal(events.some((line) => line.includes('target_directory')), false);
await result.release();
});
test('Cargo metadata target directory controls bundle path and cache identity', async () => {
const f = await fixture();
await withRelease(await f.prepare());
f.state.targetDir = path.join(f.root, 'custom-target');
const result = await f.prepare();
assert.equal(result.reused, false);
assert.equal(await withRelease(result), 'built 2');
assert.equal(f.state.builds, 2);
});
test('Cargo target configuration and environment are overridden with native host', async () => {
const f = await fixture();
await fs.mkdir(path.join(f.root, '.cargo'));
await fs.writeFile(path.join(f.root, '.cargo/config.toml'), 'build.target = "x86_64-apple-darwin"\n');
f.options.env.CARGO_BUILD_TARGET = 'x86_64-apple-darwin';
const result = await f.prepare();
assert.equal(result.reused, false);
assert.ok(f.state.commands.some((args) => args[0] === 'cargo' && args.includes('--release') && args.at(-2) === '--target' && args.at(-1) === 'aarch64-apple-darwin'));
await result.release();
});
test('cached app is rejected and private lease removed if clean HEAD changes at final verification', async () => {
const f = await fixture();
await withRelease(await f.prepare());
const originalRun = f.options.run;
let headReads = 0;
f.options.run = async (name, args, options) => {
if (name === 'git' && args[0] === 'rev-parse') {
headReads++;
if (headReads === 3) f.state.commit = 'b'.repeat(40);
}
return originalRun(name, args, options);
};
await assert.rejects(f.prepare(), /Checkout changed during bundle preparation/);
assert.equal(f.state.builds, 1);
const cache = path.join(f.root, 'target/galaxy-build-cache');
assert.equal((await fs.readdir(cache)).some((entry) => entry.startsWith('lease-')), false);
});
test('failed SDK identity probe still rejects changed HEAD after bundling', async () => {
const f = await fixture();
f.state.failSdk = true;
f.state.changeCommitOnBuild = true;
await assert.rejects(f.prepare(), /Checkout changed during bundle preparation/);
await assert.rejects(fs.access(path.join(f.root, 'target/galaxy-build-cache/manifest.json')));
});
test('dead owner lock fails safely with manual removal guidance', async () => {
const f = await fixture();
const lock = path.join(f.root, 'target/galaxy-build-cache/prepare.lock');
await fs.mkdir(path.dirname(lock), { recursive: true });
await fs.symlink(JSON.stringify({ pid: 99999999, start: 'old process', token: 'orphan' }), lock);
await assert.rejects(f.prepare(), /Stale Galaxy build lock.*remove it manually/);
assert.equal(f.state.builds, 0);
assert.ok((await fs.lstat(lock)).isSymbolicLink());
});
test('healthy lock owner survives longer than a short wait', async () => {
const f = await fixture();
const lock = path.join(f.root, 'target/galaxy-build-cache/prepare.lock');
const firstRun = f.options.run;
let finish;
const pending = new Promise((resolve) => { finish = resolve; });
let started;
const building = new Promise((resolve) => { started = resolve; });
f.options.run = async (name, args, options) => {
if (name === 'cargo' && args[0] === 'bundle' && args.includes('--release')) { started(); await pending; }
return firstRun(name, args, options);
};
const first = f.prepare();
await building;
const second = f.prepare();
await new Promise((resolve) => setTimeout(resolve, 1200));
assert.equal(f.state.builds, 0);
assert.ok((await fs.lstat(lock)).isSymbolicLink());
finish();
const [a, b] = await Promise.all([first, second]);
assert.equal(f.state.builds, 1);
await a.release();
await b.release();
});
test('ignored mutable bundle resources disable reuse', async () => {
const f = await fixture();
f.state.ignoredResources = 'app/assets/onboarding/ignored-file';
await withRelease(await f.prepare());
const result = await f.prepare();
assert.equal(result.reused, false);
await result.release();
});
test('concurrent requests serialize preparation and preserve private signed leases', async () => {
const f = await fixture();
const [a, b] = await Promise.all([f.prepare(), f.prepare()]);
assert.equal(f.state.builds, 1);
await fs.writeFile(path.join(a.appPath, 'Contents/MacOS/galaxy-oss'), 'signed');
assert.equal(await withRelease(b), 'built 1');
await a.release();
});