From 01f838b6eb74fd47a6e49dc24833ecc61d06b8df Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Sun, 25 Oct 2026 23:15:32 -0500 Subject: [PATCH] Share install and deploy preparation with verified release bundle reuse --- script/build-and-deploy-hermes/src/index.tsx | 44 +-- .../src/index.tsx | 42 +-- script/lib/README.md | 46 +++ script/lib/prepare-galaxy-build.d.mts | 23 ++ script/lib/prepare-galaxy-build.mjs | 248 ++++++++++++++ script/lib/prepare-galaxy-build.test.mjs | 316 ++++++++++++++++++ 6 files changed, 648 insertions(+), 71 deletions(-) create mode 100644 script/lib/README.md create mode 100644 script/lib/prepare-galaxy-build.d.mts create mode 100644 script/lib/prepare-galaxy-build.mjs create mode 100644 script/lib/prepare-galaxy-build.test.mjs diff --git a/script/build-and-deploy-hermes/src/index.tsx b/script/build-and-deploy-hermes/src/index.tsx index 592c027f..05d46e23 100644 --- a/script/build-and-deploy-hermes/src/index.tsx +++ b/script/build-and-deploy-hermes/src/index.tsx @@ -6,6 +6,7 @@ import { spawn, execSync } from "child_process"; import { statSync, readFileSync } from "fs"; import { createHash } from "crypto"; import path from "path"; +import { prepareGalaxyBuild } from "../../lib/prepare-galaxy-build.mjs"; // ─── Configuration ─────────────────────────────────────────────────────────── @@ -307,47 +308,17 @@ function App() { useEffect(() => { (async () => { + let prepared: Awaited> | undefined; try { - // ─── Step 0: Check working directory ─────────────────────────── - updateStep(0, { status: "running" }); - 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" }); + prepared = await prepareGalaxyBuild(WORKSPACE_ROOT, { updateStep, appendLog }); + const { appPath } = prepared; // ─── Step 3: Sign and zip ─────────────────────────────────────── updateStep(3, { status: "running" }); // 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. - const appDir = path.join(WORKSPACE_ROOT, "target/release/bundle/osx"); - const appPath = path.join(appDir, "Galaxy.app"); - const zipPath = path.join(WORKSPACE_ROOT, "Galaxy.zip"); + const zipPath = path.join(path.dirname(appPath), "Galaxy.zip"); await runCommandStreaming( `/usr/bin/codesign --force --deep --sign - "${appPath}"`, @@ -546,6 +517,7 @@ function App() { // Cleanup zip execSync(`rm -f "${zipPath}"`, { stdio: "pipe" }); } catch (err: any) { + process.exitCode = 1; // Mark current running step as error setSteps((prev) => prev.map((s) => @@ -554,12 +526,14 @@ function App() { : 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 allDone = hasError || steps.every((s) => s.status === "done"); return ( diff --git a/script/build-and-install-to-applications/src/index.tsx b/script/build-and-install-to-applications/src/index.tsx index fa6a382c..463b9ceb 100644 --- a/script/build-and-install-to-applications/src/index.tsx +++ b/script/build-and-install-to-applications/src/index.tsx @@ -4,6 +4,7 @@ import { render, Text, Box } from "ink"; import Spinner from "ink-spinner"; import { spawn, execFileSync, execSync } from "child_process"; import path from "path"; +import { prepareGalaxyBuild } from "../../lib/prepare-galaxy-build.mjs"; import fs from "fs"; // ─── Configuration ─────────────────────────────────────────────────────────── @@ -187,46 +188,13 @@ function App() { useEffect(() => { (async () => { + let prepared: Awaited> | undefined; try { 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); - // ─── Step 0: Check working directory ─────────────────────────── - updateStep(0, { status: "running" }); - 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" }); + prepared = await prepareGalaxyBuild(WORKSPACE_ROOT, { updateStep, appendLog }); + const { appPath } = prepared; // ─── Step 3: Copy to /Applications ───────────────────────────── updateStep(3, { status: "running" }); @@ -286,6 +254,8 @@ function App() { : s ) ); + } finally { + await prepared?.release(); } })(); }, []); diff --git a/script/lib/README.md b/script/lib/README.md new file mode 100644 index 00000000..f64c21b6 --- /dev/null +++ b/script/lib/README.md @@ -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 +``` diff --git a/script/lib/prepare-galaxy-build.d.mts b/script/lib/prepare-galaxy-build.d.mts new file mode 100644 index 00000000..c3a8bb8a --- /dev/null +++ b/script/lib/prepare-galaxy-build.d.mts @@ -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; + /** Call in a finally block after the app has been copied or packaged. */ + release(): Promise; +} + +export function prepareGalaxyBuild( + workspaceRoot: string, + observer?: BuildObserver | ((index: number, update: BuildStepUpdate) => void), +): Promise; diff --git a/script/lib/prepare-galaxy-build.mjs b/script/lib/prepare-galaxy-build.mjs new file mode 100644 index 00000000..6a460d7e --- /dev/null +++ b/script/lib/prepare-galaxy-build.mjs @@ -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(); } +} diff --git a/script/lib/prepare-galaxy-build.test.mjs b/script/lib/prepare-galaxy-build.test.mjs new file mode 100644 index 00000000..9b8e9a5a --- /dev/null +++ b/script/lib/prepare-galaxy-build.test.mjs @@ -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(); +});