// Running one publish stage (release 18): under the publish lock, with the exit // codes every caller reads. // // 0 ran, or a no-op (the target was fresh) // 1 failed // 2 usage (a bad flag, an unknown site, a bad preview name) // 3 precondition not met ("update the index first", "no build of X", …) // 130 cancelled (SIGTERM / SIGINT, before or during the stage) // // Two ways in. The editor spawns `stageCommand(paths, req)` — the CLI's // internal `stage` row — as a `runManagedCommand` job on the `publish` queue: // that child (`stageMain`) traps SIGTERM so a Cancel unwinds the stage, and // turns on runChildIntoLog's tree-kill so `next build`, wrangler and docker // go with it. `archilyzer publish …` calls `runStage` in its own process. // Either way the stage takes `/.publish.lock` first. import path from "node:path"; import { killChildTreesNow, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; import { DeployStageError } from "./deployStage"; import { StageCancelled, StageFailure } from "./stageBodies"; import { LockWaitCancelled, acquirePublishLock, type LockEnv } from "./stageLock"; import { STAGES, type StageOutcome, type StageRequest } from "./stages"; export const STAGE_EXIT = { ok: 0, failed: 1, usage: 2, precondition: 3, cancelled: 130, } as const; // The update-index child's heap: the index and stats builds of a large corpus // (what export's `build:index` / `build:stats` scripts have always set). export const INDEX_HEAP_MB = 8192; export type StageCommand = { command: string; args: string[]; cwd: string; env: Record; }; /** * The child the editor spawns for `req`: `/node_modules/.bin/tsx * bin/archilyzer.ts stage [flags]`, cwd `common/`, the * editor's environment (+ the heap cap for update-index). S3's * `enqueueStage` hands this to `runManagedCommand` as it is. */ export function stageCommand( paths: Pick, req: StageRequest, baseEnv: NodeJS.ProcessEnv = process.env, ): StageCommand { const commonDir = path.join(paths.monorepoRoot, "common"); const env: Record = { ...baseEnv }; if (req.kind === "update-index") { env.NODE_OPTIONS = [baseEnv.NODE_OPTIONS?.trim(), `--max-old-space-size=${INDEX_HEAP_MB}`] .filter(Boolean) .join(" "); } return { command: path.join(commonDir, "node_modules", ".bin", "tsx"), args: ["bin/archilyzer.ts", ...STAGES[req.kind].argv(req)], cwd: commonDir, env, }; } export type StageRunResult = { code: number; outcome: StageOutcome | null; message: string | null }; function terminal(line: string): void { process.stdout.write(line.endsWith("\n") ? line : `${line}\n`); } /** * Run `req` in this process under the publish lock (waiting for a live * holder). Never throws: the result carries the exit code, the outcome on * 0, and the one sentence a failure or refusal ended on. */ export async function runStage( req: StageRequest, opts: { paths?: Paths; onLog?: (line: string) => void; signal?: AbortSignal; lockEnv?: LockEnv & { pollMs?: number }; } = {}, ): Promise { const paths = opts.paths ?? getPaths(); const onLog = opts.onLog ?? terminal; const signal = opts.signal ?? new AbortController().signal; const name = `${req.kind} ${req.target}`; let lock; try { lock = await acquirePublishLock(paths, { kind: req.kind, target: req.target }, { ...opts.lockEnv, signal, onLog, }); } catch (err) { if (err instanceof LockWaitCancelled) { onLog(`[stage] ${name}: cancelled while waiting for the publish lock\n`); return { code: STAGE_EXIT.cancelled, outcome: null, message: err.message }; } const message = (err as Error).message; onLog(`[stage] ${name}: FAILED — ${message}\n`); return { code: STAGE_EXIT.failed, outcome: null, message }; } try { const outcome = await STAGES[req.kind].run({ paths, onLog, signal }, req); if (signal.aborted) throw new StageCancelled(); // "Done" is what a console's reader waits for (the e2e buildIndex helper // reads "Build index output" for it, as it did before the stages). onLog(`[stage] ${name}: ${outcome.status === "noop" ? "Done (no-op)" : "Done"} — ${outcome.summary}\n`); return { code: STAGE_EXIT.ok, outcome, message: null }; } catch (err) { if (err instanceof StageCancelled || signal.aborted) { onLog(`[stage] ${name}: cancelled\n`); return { code: STAGE_EXIT.cancelled, outcome: null, message: "cancelled" }; } const message = (err as Error).message; // The deploy stage logged its own sentence (deployStage.ts refuse()): // the exit code is its, and the sentence is not said twice. if (err instanceof DeployStageError) { if (err.exitCode === STAGE_EXIT.cancelled) { onLog(`[stage] ${name}: cancelled\n`); return { code: STAGE_EXIT.cancelled, outcome: null, message }; } const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; onLog(`[stage] ${name}: ${word} (exit ${err.exitCode})\n`); return { code: err.exitCode, outcome: null, message }; } if (err instanceof StageFailure) { const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; onLog(`[stage] ${name}: ${word} — ${message}\n`); return { code: err.exitCode, outcome: null, message }; } onLog(`[stage] ${name}: FAILED — ${(err as Error).stack ?? message}\n`); return { code: STAGE_EXIT.failed, outcome: null, message }; } finally { await lock.release(); } } // After a first SIGTERM the stage unwinds (its children are signalled); a // stage still running this long after is left to the next taker's stale-lock // check, and the process exits. const CANCEL_GRACE_MS = 15_000; /** * The stage child's entry (the `stage` row): SIGTERM / SIGINT cancel the stage * — a second one exits at once — and every child it runs is killed as a tree. * Returns the exit code. */ export async function stageMain(req: StageRequest, opts: { paths?: Paths } = {}): Promise { const ac = new AbortController(); // Exiting without the unwind (a second signal, or the grace run out) takes // the detached process groups down first: no orphan `next build`. const exitNow = () => { killChildTreesNow("SIGKILL"); process.exit(STAGE_EXIT.cancelled); }; const onSignal = () => { if (ac.signal.aborted) exitNow(); ac.abort(); setTimeout(exitNow, CANCEL_GRACE_MS).unref(); }; process.on("SIGTERM", onSignal); process.on("SIGINT", onSignal); setKillChildTrees(true); try { return (await runStage(req, { paths: opts.paths, signal: ac.signal })).code; } finally { process.off("SIGTERM", onSignal); process.off("SIGINT", onSignal); } }