Share install and deploy preparation with verified release bundle reuse
This commit is contained in:
@@ -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<ReturnType<typeof prepareGalaxyBuild>> | 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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
|
||||
@@ -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<ReturnType<typeof prepareGalaxyBuild>> | 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();
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>;
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user