commit b8b2cab4b370f637ac99c9af0a9820a1712fb4f4
parent 367a9af95c46af326b2e8635bce0ad7c400f2d36
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:45:48 -0400
publish: deploy all skips only private and project-less sites, fails on every other refusal; an interrupted install is restored first; built.json goes before the swap; a second signal kills the child groups (review MEDIUM 3 + LOWs)
publish deploy all: a private site and (to Pages) a site with no project are
passed over with one line; never built, a bundle problem, a non-main build or
builtAfter fail the run (exit 1) after the rest are tried. A present out.prev
with no out is renamed back before a build or install touches the target.
The old built.json is removed before the install and the new one written last.
A second Ctrl-C / SIGTERM (CLI and stage child) SIGKILLs the detached groups
before exiting. The stage row's usage names --allow-missing-media.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 200 insertions(+), 14 deletions(-)
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -161,7 +161,7 @@ export const COMMANDS: Command[] = [
{
path: ["stage"],
usage:
- "<kind> <target> --run-id <id> [--preview <b>] [--to local] [--runner docker] [--force] [--skip-archives] [--index-after <ms>] [--built-after <ms>] INTERNAL: one publish stage, as the editor's job runs it (exit 0 ran/no-op, 1 failed, 2 usage, 3 precondition not met, 130 cancelled)",
+ "<kind> <target> --run-id <id> [--preview <b>] [--to local] [--runner docker] [--force] [--skip-archives] [--allow-missing-media] [--index-after <ms>] [--built-after <ms>] INTERNAL: one publish stage, as the editor's job runs it (exit 0 ran/no-op, 1 failed, 2 usage, 3 precondition not met, 130 cancelled)",
// publish/stages.ts STAGE_FLAGS, spelled out so this table stays free of
// imports (_cli.test.ts holds the two equal).
flags: {
diff --git a/common/bin/publish.ts b/common/bin/publish.ts
@@ -15,8 +15,9 @@
// index and stats builds want its 8 GB heap); the rest run in this process.
// `publish status` and `publish now` are release 18 S3's (publishState.ts).
-import { runChildIntoLog, setKillChildTrees } from "../jobs/runChild";
+import { killChildTreesNow, runChildIntoLog, setKillChildTrees } from "../jobs/runChild";
import { getPaths, type Paths } from "../lib/paths";
+import { siteDeployProblem } from "../lib/builtExport";
import { listSites, listSiteIds } from "../lib/site";
import { newStampId } from "../publish/stamps";
import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun";
@@ -34,11 +35,22 @@ export function cliRunId(): string {
return `cli-${newStampId()}`;
}
-// Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does.
+// Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does. A
+// SECOND one does not wait for the unwind: it kills the detached process
+// groups this process started (`next build`'s, wrangler's) and exits 130 —
+// never leaving an orphan builder writing export/out. Idempotent: one handler.
function interrupted(): AbortSignal {
const ac = new AbortController();
- process.once("SIGINT", () => ac.abort());
- process.once("SIGTERM", () => ac.abort());
+ const onSignal = () => {
+ if (!ac.signal.aborted) {
+ ac.abort();
+ return;
+ }
+ killChildTreesNow("SIGKILL");
+ process.exit(STAGE_EXIT.cancelled);
+ };
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
return ac.signal;
}
@@ -149,7 +161,18 @@ export async function publishDeploy(a: DeployArgs, out: Out = console): Promise<
if (!all && !knownSite(a.target, "publish deploy", out, a.paths)) return STAGE_EXIT.usage;
const signal = a.signal ?? interrupted();
const runId = a.runId ?? cliRunId();
- const ids = all ? listSites(a.paths).map((s) => s.siteId) : [a.target];
+ // `all` passes over — quietly, one line — only the sites that are never
+ // deployable by their configuration: a private site, and (to Pages) a site
+ // with no Pages project. Every other refusal (never built, a bundle
+ // problem, a build not of main, a build this run has not made) is a
+ // FAILURE: the rest are still tried, and the run exits 1.
+ const ids: string[] = [];
+ for (const site of all ? listSites(a.paths) : []) {
+ const never = siteDeployProblem(site) ?? (a.to === "local" || site.cloudflareProject?.trim() ? null : "no Cloudflare Pages project");
+ if (never) out.log(`[publish] ${site.siteId}: skipped — ${never}`);
+ else ids.push(site.siteId);
+ }
+ if (!all) ids.push(a.target);
let worst = 0;
for (const id of ids) {
if (signal.aborted) return STAGE_EXIT.cancelled;
@@ -166,9 +189,7 @@ export async function publishDeploy(a: DeployArgs, out: Out = console): Promise<
a.paths,
);
if (code === STAGE_EXIT.cancelled) return code;
- // `all` deploys every site it can; a site that cannot be deployed (private,
- // no project, never built) is said and skipped, not a failure of the rest.
- if (code !== 0 && !(all && code === STAGE_EXIT.precondition)) worst = worst || code;
+ if (code !== 0) worst = all ? STAGE_EXIT.failed : worst || code;
}
return worst;
}
diff --git a/common/jobs/runChild.test.ts b/common/jobs/runChild.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
-import { killsChildTrees, runChildIntoLog, setKillChildTrees } from "./runChild";
+import { killChildTreesNow, killsChildTrees, runChildIntoLog, setKillChildTrees } from "./runChild";
// Run with:
// pnpm --filter yt-dlp-transcript-common test
@@ -68,3 +68,31 @@ test("with tree-kill on, a cancel takes the grandchildren too", { skip: process.
assert.notEqual(code, 0);
assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`);
});
+
+test("killChildTreesNow (a second Ctrl-C) takes every live group down at once, no cancel needed", { skip: process.platform === "win32" }, async () => {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "run-child-now-"));
+ const pidFile = path.join(dir, "gc.pid");
+ setKillChildTrees(true);
+ try {
+ const running = runChildIntoLog(() => {}, new AbortController().signal, {
+ command: "sh",
+ args: ["-c", `sleep 30 & echo $! > '${pidFile}'; wait`],
+ cwd: dir,
+ });
+ let grandchild = 0;
+ await waitFor(() => {
+ try {
+ grandchild = Number(readFileSync(pidFile, "utf8").trim());
+ return grandchild > 0;
+ } catch {
+ return false;
+ }
+ });
+ killChildTreesNow("SIGKILL");
+ assert.notEqual(await running, 0);
+ assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`);
+ } finally {
+ setKillChildTrees(false);
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/common/jobs/runChild.ts b/common/jobs/runChild.ts
@@ -28,6 +28,26 @@ export function killsChildTrees(): boolean {
return (globalThis as TreeKillGlobal)[TREE_KILL_KEY] === true;
}
+// The process groups this process leads right now (tree-kill mode), so a
+// second Ctrl-C / SIGTERM — which exits at once, without waiting for the
+// cancel to unwind — can still take them down first (`killChildTreesNow`).
+const LIVE_GROUPS_KEY = Symbol.for("archilyzer.runChild.liveGroups");
+function liveGroups(): Set<number> {
+ const g = globalThis as { [LIVE_GROUPS_KEY]?: Set<number> };
+ return (g[LIVE_GROUPS_KEY] ??= new Set());
+}
+
+/** Signal every live child process group this process started (tree-kill mode). */
+export function killChildTreesNow(sig: NodeJS.Signals = "SIGKILL"): void {
+ for (const pgid of liveGroups()) {
+ try {
+ process.kill(-pgid, sig);
+ } catch {
+ // Already gone.
+ }
+ }
+}
+
// Spawn a child process and stream its combined stdout/stderr into `onLog`,
// resolving with the exit code. Used inside a managed function's `fn` to run a
// sub-command as part of a larger job (build-then-deploy, multi-phase builds)
@@ -52,6 +72,7 @@ export async function runChildIntoLog(
reject: false,
...(tree ? { detached: true } : {}),
});
+ if (tree && child.pid) liveGroups().add(child.pid);
// Signal the child — or, with tree-kill, its whole process group.
const signalChild = (sig: NodeJS.Signals) => {
if (tree && child.pid) {
@@ -108,6 +129,7 @@ export async function runChildIntoLog(
if (killTimer) clearTimeout(killTimer);
// The leader is gone; whatever of its group a cancel left is not wanted.
if (tree && signal.aborted) signalChild("SIGKILL");
+ if (tree && child.pid) liveGroups().delete(child.pid);
signal.removeEventListener("abort", onAbort);
}
}
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -34,6 +34,7 @@ import {
import { getPaths, type Paths } from "../lib/paths";
import { getSettings } from "../lib/settings";
import { getSite, listSites, type Site } from "../lib/site";
+import { builtStampPath } from "./stamps";
// Where the basic (host) build writes the static bundle to deploy: the fixed
// export/out, composed one site at a time. The docker fan-out writes per-site
@@ -1272,6 +1273,22 @@ async function moveDir(src: string, dest: string, fsOps: BundleFs): Promise<"ren
}
/**
+ * A crash between installBundle's two renames leaves `out.prev` and no `out`:
+ * the last bundle is whole, under the wrong name. Put it back. Asked first by
+ * every build and every install, before anything else touches the target.
+ * Answers whether it restored one.
+ */
+export async function recoverInterruptedInstall(
+ destOut: string,
+ fsOps: BundleFs = realBundleFs,
+): Promise<boolean> {
+ const prev = `${destOut}.prev`;
+ if ((await lexists(destOut)) || !(await lexists(prev))) return false;
+ await fsOps.rename(prev, destOut);
+ return true;
+}
+
+/**
* Install the build at `src` as the bundle `destOut` (see the section header):
* `src` is gone afterwards and `destOut` holds it. Answers how it moved.
*/
@@ -1282,6 +1299,7 @@ export async function installBundle(
): Promise<"rename" | "copy"> {
const next = `${destOut}.next`;
const prev = `${destOut}.prev`;
+ await recoverInterruptedInstall(destOut, fsOps);
await fsOps.mkdir(path.dirname(destOut), { recursive: true });
await fsOps.rm(next, { recursive: true, force: true });
const how = await moveDir(src, next, fsOps);
@@ -1384,7 +1402,12 @@ export async function buildSiteBundle(
opts: PublishOpts & { skipArchives?: boolean; allowMissingMedia?: boolean; inPlace?: boolean } = {},
): Promise<{ code: number; archivesStaged: number }> {
const { paths, onLog, signal } = resolved(opts);
- if (!opts.inPlace) await unlinkExportOut(paths);
+ if (!opts.inPlace) {
+ if (await recoverInterruptedInstall(bundleDir(paths, siteId))) {
+ onLog(`[build] ${siteId}: restored the bundle an interrupted install left as out.prev\n`);
+ }
+ await unlinkExportOut(paths);
+ }
const code = await runBuildPhase(onLog, signal, siteId, paths, {
skipData: true,
skipArchives: opts.skipArchives,
@@ -1398,6 +1421,9 @@ export async function buildSiteBundle(
return { code: 1, archivesStaged: 0 };
}
if (opts.inPlace) return { code: 0, archivesStaged: 0 };
+ // The old stamp must never describe the new bundle: it goes first, and the
+ // stage writes the new one as its LAST step, once the bundle is in place.
+ await rm(builtStampPath(paths, siteId), { force: true });
const how = await installBundle(out, bundleDir(paths, siteId));
const archivesStaged = await stageSiteArchives(paths, siteId);
await pointExportOutAt(paths, bundleDir(paths, siteId));
@@ -1415,6 +1441,9 @@ export async function buildSiteBundle(
*/
export async function buildHubBundle(opts: PublishOpts = {}): Promise<number> {
const { paths, onLog, signal } = resolved(opts);
+ if (await recoverInterruptedInstall(bundleDir(paths, "_hub"))) {
+ onLog("[build] hub: restored the bundle an interrupted install left as out.prev\n");
+ }
await unlinkExportOut(paths);
const code = await buildHub({ paths, onLog, signal });
if (code !== 0 || signal.aborted) return code || 1;
@@ -1425,6 +1454,7 @@ export async function buildHubBundle(opts: PublishOpts = {}): Promise<number> {
return 1;
}
const dest = bundleDir(paths, "_hub");
+ await rm(builtStampPath(paths, "_hub"), { force: true });
const how = await installBundle(out, dest);
await pointExportOutAt(paths, dest);
onLog(`[build] hub: bundle installed at ${dest} (${how === "rename" ? "moved" : "copied across filesystems"})\n`);
diff --git a/common/publish/bundle.test.ts b/common/publish/bundle.test.ts
@@ -23,6 +23,7 @@ import {
exportOutPath,
installBundle,
pointExportOutAt,
+ recoverInterruptedInstall,
stageSiteArchives,
unlinkExportOut,
type BundleFs,
@@ -218,3 +219,39 @@ test("bundleCounts and corpusGeneratedAtIn read the bundle; links are not follow
rmSync(root, { recursive: true, force: true });
}
});
+
+test("a crash between the two renames leaves out.prev and no out; the next build or install restores it first", async () => {
+ const { root, paths } = tmp();
+ try {
+ const dest = bundleDir(paths, "jer");
+ writeBuild(dest, "old");
+ const out = exportOutPath(paths);
+ writeBuild(out, "new");
+ // The process dies on the second rename (out.next -> out).
+ const dying: BundleFs = {
+ rename: (async (a: string, b: string) => {
+ if (String(a).endsWith("out.next") && String(b).endsWith(path.join("jer", "out"))) throw new Error("killed");
+ return rename(a, b);
+ }) as typeof rename,
+ cp,
+ rm,
+ mkdir,
+ };
+ await assert.rejects(installBundle(out, dest, dying), /killed/);
+ assert.ok(!existsSync(dest), "no out");
+ assert.ok(existsSync(`${dest}.prev`), "the last bundle, under the wrong name");
+ // Nothing to do when out is there; the old bundle back when it is not.
+ assert.equal(await recoverInterruptedInstall(dest), true);
+ assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "old");
+ assert.equal(await recoverInterruptedInstall(dest), false);
+ // And an install over the same crash recovers before it swaps.
+ rmSync(`${dest}.next`, { recursive: true, force: true });
+ await rename(dest, `${dest}.prev`);
+ writeBuild(out, "newer");
+ await installBundle(out, dest);
+ assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "newer");
+ assert.ok(!existsSync(`${dest}.prev`));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts
@@ -262,3 +262,45 @@ test("stamps' commit/branch: ARCHILYZER_COMMIT / ARCHILYZER_BRANCH win over git,
branch: "main",
});
});
+
+test("publish deploy all: only a private site and a site with no Pages project are skipped; any other refusal fails the run (exit 1) after the rest are tried", async () => {
+ const { publishDeploy } = await import("../bin/publish");
+ writeSite("mine", { audience: "private", cloudflareProject: "w3c-never-real" });
+ writeSite("noproj");
+ writeSite("unbuilt", { cloudflareProject: "w3c-never-real" });
+ const siteOut = path.join(ROOT, "builds", "site-all");
+ process.env.ARCHILYZER_SITE_OUT = siteOut;
+ const built = (await stamps.readBuiltStamp(paths, "jer"))!;
+ await stamps.writeBuiltStamp(paths, { ...built, branch: "main" });
+ try {
+ const lines: string[] = [];
+ const out = { log: (l: string) => lines.push(l), error: (l: string) => lines.push(l) };
+ // Pages: private and no-project are skipped quietly; jer and unbuilt are
+ // TRIED (jer: never deployed there, w3c-never-real is no real project —
+ // its deploy is refused at wrangler or before; unbuilt: exit 3).
+ const local = await publishDeploy({ target: "all", to: "local", force: true, paths, signal: new AbortController().signal }, out);
+ assert.equal(local, 1, lines.join("\n"));
+ assert.ok(lines.includes("[publish] mine: skipped — " + 'Site "mine" is private (audience: private): it is built for reading on this machine and is never deployed. Build it without deploying, or set its audience to public on its Settings tab'));
+ assert.ok(!lines.some((l) => l.startsWith("[publish] noproj: skipped")), "--to local needs no project");
+ assert.ok(existsSync(path.join(siteOut, "index.html")), "jer was still deployed");
+ const local2 = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "unbuilt"), "local");
+ assert.equal(local2, null, "unbuilt was refused");
+ lines.length = 0;
+ // Only never-deployable sites skipped: an all of nothing but those is exit 0.
+ rmSync(path.join(PINNED.SITES_DIR, "unbuilt"), { recursive: true });
+ rmSync(path.join(PINNED.SITES_DIR, "noproj"), { recursive: true });
+ const onlyOk = await publishDeploy({ target: "all", to: "local", paths, signal: new AbortController().signal }, out);
+ assert.equal(onlyOk, 0, lines.join("\n"));
+ // To Pages: a site with no project is skipped too. (jer's build is taken
+ // away first, so its refusal comes before any wrangler.)
+ writeSite("noproj");
+ rmSync(stamps.builtStampPath(paths, "jer"));
+ lines.length = 0;
+ const pages = await publishDeploy({ target: "all", preview: "r18", paths, signal: new AbortController().signal }, out);
+ assert.equal(pages, 1, "jer, never built, is a failure");
+ assert.ok(lines.includes("[publish] noproj: skipped — no Cloudflare Pages project"), lines.join("\n"));
+ } finally {
+ delete process.env.ARCHILYZER_SITE_OUT;
+ for (const id of ["mine", "noproj", "unbuilt"]) rmSync(path.join(PINNED.SITES_DIR, id), { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts
@@ -15,7 +15,7 @@
// Either way the stage takes `<exportBuildsDir>/.publish.lock` first.
import path from "node:path";
-import { setKillChildTrees } from "../jobs/runChild";
+import { killChildTreesNow, setKillChildTrees } from "../jobs/runChild";
import { getPaths, type Paths } from "../lib/paths";
import { StageCancelled, StageFailure } from "./stageBodies";
import { LockWaitCancelled, acquirePublishLock, type LockEnv } from "./stageLock";
@@ -141,10 +141,16 @@ const CANCEL_GRACE_MS = 15_000;
*/
export async function stageMain(req: StageRequest, opts: { paths?: Paths } = {}): Promise<number> {
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) process.exit(STAGE_EXIT.cancelled);
+ if (ac.signal.aborted) exitNow();
ac.abort();
- setTimeout(() => process.exit(STAGE_EXIT.cancelled), CANCEL_GRACE_MS).unref();
+ setTimeout(exitNow, CANCEL_GRACE_MS).unref();
};
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);