commit 6db57f62bd7cd8c2d2fbf5893f7f3197fca25e90
parent 079576e9259ae828de73b3e32467e1a0ff1abb7b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 08:41:43 -0400
publish: tests for the stage core — needs() per row, argv both ways, the bundle install (EXDEV), inputSig, the stages over a scratch corpus, tree-kill, the CLI rows and aliases
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 1097 insertions(+), 0 deletions(-)
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts
@@ -404,3 +404,83 @@ test("every bin in common/bin is reachable as a subcommand", () => {
const paths = COMMANDS.map((c) => c.path.join(" "));
assert.equal(new Set(paths).size, paths.length);
});
+
+// ── publishing as stages (release 18 slice S1) ──────────────────────────────
+
+test("the stage row: kind and target, the flags the child's argv carries, nothing else", async () => {
+ const { STAGE_FLAGS, stageArgv } = await import("../publish/stages");
+ const hit = resolveCommand(COMMANDS, ["stage", "deploy-site", "jer"]);
+ assert.deepEqual(hit?.command.path, ["stage"]);
+ assert.deepEqual(hit?.rest, ["deploy-site", "jer"]);
+ assert.deepEqual(hit!.command.flags, { ...STAGE_FLAGS });
+ // The argv the editor spawns parses through runCli's own parser, cleanly.
+ const argv = stageArgv({
+ kind: "build-site",
+ target: "_all",
+ runId: "run-1",
+ runner: "docker",
+ force: true,
+ skipArchives: true,
+ indexAfter: 17,
+ });
+ assert.deepEqual(argv, [
+ "stage", "build-site", "_all", "--run-id", "run-1", "--runner", "docker", "--force", "--skip-archives", "--index-after", "17",
+ ]);
+ const parsed = parseArgv(argv, booleanFlags(COMMANDS));
+ assert.deepEqual(parsed.positionals, ["stage", "build-site", "_all"]);
+ assert.equal(argumentProblem(hit!.command, parsed.flags, ["build-site", "_all"]), null);
+ assert.match(argumentProblem(hit!.command, {}, ["a", "b", "c"])!, /unexpected argument "c"/);
+ assert.match(argumentProblem(hit!.command, { nodata: true }, [])!, /unknown flag --nodata/);
+});
+
+test("the publish rows and their flags (status and now are S3's)", () => {
+ const row = (...p: string[]) => resolveCommand(COMMANDS, p)?.command;
+ assert.deepEqual(row("publish", "index")?.flags ?? {}, {});
+ assert.deepEqual(row("publish", "build", "jer")?.flags, { runner: "string", force: "boolean", "skip-archives": "boolean" });
+ assert.equal(row("publish", "build", "all")?.maxPositionals, 1);
+ assert.deepEqual(row("publish", "deploy", "all")?.flags, { preview: "string", to: "string", force: "boolean" });
+ assert.deepEqual(row("publish", "hub")?.flags, { deploy: "boolean", preview: "string", force: "boolean" });
+ assert.deepEqual(row("publish", "homepage")?.flags, {
+ deploy: "boolean",
+ preview: "string",
+ to: "string",
+ force: "boolean",
+ });
+ const parsed = parseArgv(["publish", "hub", "--deploy", "--preview", "r18"], booleanFlags(COMMANDS));
+ assert.deepEqual(parsed, { positionals: ["publish", "hub"], flags: { deploy: true, preview: "r18" } });
+ const deploy = row("publish", "deploy")!;
+ assert.match(argumentProblem(deploy, { preveiw: "x" }, ["jer"])!, /unknown flag --preveiw \(accepts --preview, --to, --force\)/);
+ assert.match(argumentProblem(deploy, { to: true }, ["jer"])!, /--to needs a value/);
+ const u = usage(COMMANDS);
+ assert.match(u, /archilyzer publish index\s+update the index/);
+ assert.match(u, /archilyzer publish build\s+<id\|all> \[--runner local\|docker\|auto\] \[--force\] \[--skip-archives\]/);
+ assert.match(u, /archilyzer publish deploy\s+<id\|all> \[--preview <branch>\] \[--to local\] \[--force\]/);
+ assert.match(u, /archilyzer stage\s+<kind> <target> --run-id <id>/);
+});
+
+test("build site, build all and deploy site are printed aliases of the publish rows", async () => {
+ const { ALIASES } = await import("./publish");
+ assert.equal(ALIASES.buildSite("jer", false), "archilyzer publish index && archilyzer publish build jer --force");
+ assert.equal(ALIASES.buildSite("jer", true), "archilyzer publish build jer --force", "--nodata skips the index");
+ assert.equal(ALIASES.buildAll, "archilyzer publish index && archilyzer publish build all --runner auto");
+ assert.equal(ALIASES.deploySite("jer"), "archilyzer publish deploy jer");
+ assert.equal(ALIASES.deploySite("jer", "r18"), "archilyzer publish deploy jer --preview r18");
+ const row = (...p: string[]) => resolveCommand(COMMANDS, p)!.command;
+ // Same flags as ever: a script that called them still parses.
+ assert.deepEqual(row("build", "site").flags, { nodata: "boolean", "skip-archives": "boolean", "allow-missing-media": "boolean" });
+ assert.deepEqual(row("build", "all").flags, { "skip-archives": "boolean" });
+ assert.deepEqual(row("deploy", "site").flags, { preview: "string" });
+ for (const r of [row("build", "site"), row("build", "all"), row("deploy", "site")]) {
+ assert.match(r.usage, /alias: /);
+ }
+});
+
+test("publish hub / homepage refuse a deploy's flag without --deploy (usage, nothing run)", async () => {
+ const { publishHub, publishHomepage } = await import("./publish");
+ const errors: string[] = [];
+ const out = { log: () => {}, error: (s: string) => errors.push(s) };
+ assert.equal(await publishHub({ preview: "r18" }, out), 2);
+ assert.equal(await publishHomepage({ to: "local" }, out), 2);
+ assert.match(errors.join("\n"), /--preview is a deploy — add --deploy/);
+ assert.match(errors.join("\n"), /--preview and --to are a deploy's — add --deploy/);
+});
diff --git a/common/jobs/runChild.test.ts b/common/jobs/runChild.test.ts
@@ -0,0 +1,70 @@
+import { test } from "node:test";
+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";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// Release 18: a publish stage child turns on tree-kill, so a cancel takes the
+// child's whole process group — `next build`'s workers, wrangler — not just
+// the direct child. A shell that starts a background grandchild stands in.
+
+function alive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function waitFor(cond: () => boolean, ms = 5_000): Promise<boolean> {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
+ if (cond()) return true;
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ return cond();
+}
+
+async function runWithGrandchild(tree: boolean): Promise<{ grandchild: number; code: number }> {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "run-child-"));
+ const pidFile = path.join(dir, "gc.pid");
+ setKillChildTrees(tree);
+ try {
+ const ac = new AbortController();
+ const running = runChildIntoLog(() => {}, ac.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;
+ }
+ });
+ ac.abort();
+ const code = await running;
+ return { grandchild, code };
+ } finally {
+ setKillChildTrees(false);
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+test("tree-kill is off by default", () => {
+ assert.equal(killsChildTrees(), false);
+});
+
+test("with tree-kill on, a cancel takes the grandchildren too", { skip: process.platform === "win32" }, async () => {
+ const { grandchild, code } = await runWithGrandchild(true);
+ assert.notEqual(code, 0);
+ assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`);
+});
diff --git a/common/publish/bundle.test.ts b/common/publish/bundle.test.ts
@@ -0,0 +1,220 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readlinkSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { cp, mkdir, rename, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import type { Paths } from "../lib/paths";
+import {
+ bundleCounts,
+ bundleDir,
+ corpusGeneratedAtIn,
+ dockerSiteOutDir,
+ exportOutPath,
+ installBundle,
+ pointExportOutAt,
+ stageSiteArchives,
+ unlinkExportOut,
+ type BundleFs,
+} from "./build";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The per-target bundle layout (release 18): a build in export/out is
+// installed as <exportBuildsDir>/<target>/out by a swap through out.next, the
+// move falls back to a copy across filesystems (an injected EXDEV here), and
+// export/out becomes a link to the bundle built last.
+
+function tmp(): { root: string; paths: Paths } {
+ const root = mkdtempSync(path.join(os.tmpdir(), "bundle-"));
+ const exportDir = path.join(root, "export");
+ mkdirSync(exportDir, { recursive: true });
+ return {
+ root,
+ paths: {
+ exportDir,
+ exportPublicDir: path.join(exportDir, "public"),
+ exportBuildsDir: path.join(exportDir, ".export-builds"),
+ } as Paths,
+ };
+}
+
+function writeBuild(dir: string, marker: string): void {
+ mkdirSync(path.join(dir, "_next"), { recursive: true });
+ writeFileSync(path.join(dir, "index.html"), marker);
+ writeFileSync(path.join(dir, "_next", "app.js"), "x".repeat(10));
+ writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ generatedAt: `at-${marker}` }));
+}
+
+// A filesystem whose renames out of `crossFrom` fail EXDEV, as export/out's do
+// in the container (an image layer) when the builds dir is a volume.
+function exdevFs(crossFrom: string, calls: string[]): BundleFs {
+ return {
+ rename: (async (a: string, b: string) => {
+ calls.push(`rename ${path.basename(String(a))} -> ${path.basename(String(b))}`);
+ if (String(a).startsWith(crossFrom)) {
+ throw Object.assign(new Error("EXDEV: cross-device link not permitted"), { code: "EXDEV" });
+ }
+ return rename(a, b);
+ }) as typeof rename,
+ cp: (async (a: string, b: string, o?: object) => {
+ calls.push(`cp ${path.basename(String(a))} -> ${path.basename(String(b))}`);
+ return cp(a, b, o);
+ }) as typeof cp,
+ rm,
+ mkdir,
+ };
+}
+
+test("bundleDir is <exportBuildsDir>/<target>/out — a site's is dockerSiteOutDir", () => {
+ const p = { exportBuildsDir: "/e/.export-builds", exportDir: "/e" } as Paths;
+ assert.equal(bundleDir(p, "jer"), "/e/.export-builds/jer/out");
+ assert.equal(bundleDir(p, "jer"), dockerSiteOutDir(p, "jer"));
+ assert.equal(bundleDir(p, "_hub"), "/e/.export-builds/_hub/out");
+ assert.equal(exportOutPath(p), "/e/out");
+});
+
+test("installBundle moves the build in through out.next, replacing the last bundle; a leftover out.next goes first", async () => {
+ const { root, paths } = tmp();
+ try {
+ const dest = bundleDir(paths, "jer");
+ writeBuild(dest, "old");
+ writeBuild(`${dest}.next`, "leftover");
+ const out = exportOutPath(paths);
+ writeBuild(out, "new");
+ assert.equal(await installBundle(out, dest), "rename");
+ assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "new");
+ assert.ok(!existsSync(out), "export/out moved away");
+ assert.ok(!existsSync(`${dest}.next`));
+ assert.ok(!existsSync(`${dest}.prev`));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("installBundle copies across filesystems (EXDEV), then removes the source", async () => {
+ const { root, paths } = tmp();
+ try {
+ const dest = bundleDir(paths, "jer");
+ writeBuild(dest, "old");
+ const out = exportOutPath(paths);
+ writeBuild(out, "new");
+ const calls: string[] = [];
+ assert.equal(await installBundle(out, dest, exdevFs(out, calls)), "copy");
+ assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "new");
+ assert.equal(readFileSync(path.join(dest, "_next", "app.js"), "utf8"), "x".repeat(10));
+ assert.ok(!existsSync(out), "the source is removed after the copy");
+ assert.deepEqual(calls, [
+ "rename out -> out.next", // EXDEV
+ "cp out -> out.next",
+ "rename out -> out.prev", // siblings: same filesystem
+ "rename out.next -> out",
+ ]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("export/out becomes a relative link to the bundle; unlinkExportOut removes a link, never a real build", async () => {
+ const { root, paths } = tmp();
+ try {
+ const out = exportOutPath(paths);
+ writeBuild(out, "real");
+ await unlinkExportOut(paths);
+ assert.ok(lstatSync(out).isDirectory(), "a real out/ is left for next build");
+ const dest = bundleDir(paths, "jer");
+ await installBundle(out, dest);
+ await pointExportOutAt(paths, dest);
+ assert.ok(lstatSync(out).isSymbolicLink());
+ assert.equal(readlinkSync(out), path.join(".export-builds", "jer", "out"));
+ assert.equal(readFileSync(path.join(out, "index.html"), "utf8"), "real", "reads through the link");
+ // Pointing it at another bundle replaces the link, not the bundle.
+ const hub = bundleDir(paths, "_hub");
+ writeBuild(hub, "hub");
+ await pointExportOutAt(paths, hub);
+ assert.equal(readFileSync(path.join(out, "index.html"), "utf8"), "hub");
+ assert.ok(existsSync(path.join(dest, "index.html")), "the other bundle is untouched");
+ await unlinkExportOut(paths);
+ assert.ok(!existsSync(out));
+ assert.ok(existsSync(path.join(hub, "index.html")), "the link's target survives");
+ // A real out/ left behind is replaced by the link.
+ writeBuild(out, "stray");
+ await pointExportOutAt(paths, dest);
+ assert.ok(lstatSync(out).isSymbolicLink());
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("stageSiteArchives moves the host staging into the bundle's (dockerSiteStagingDir) and counts it", async () => {
+ const { root, paths } = tmp();
+ try {
+ const host = path.join(paths.exportDir, ".r2-staging", "jer", "archives");
+ mkdirSync(host, { recursive: true });
+ writeFileSync(path.join(host, "a.zip"), "a");
+ writeFileSync(path.join(host, "b.zip"), "b");
+ writeFileSync(path.join(host, ".hidden"), "");
+ const bundleStaging = path.join(paths.exportBuildsDir, "jer", ".r2-staging", "jer", "archives");
+ mkdirSync(bundleStaging, { recursive: true });
+ writeFileSync(path.join(bundleStaging, "last-build.zip"), "old");
+ assert.equal(await stageSiteArchives(paths, "jer"), 2);
+ assert.ok(existsSync(path.join(bundleStaging, "a.zip")));
+ assert.ok(!existsSync(path.join(bundleStaging, "last-build.zip")), "the last build's staging is replaced");
+ assert.ok(!existsSync(host));
+ // Nothing staged this build: the bundle has none either.
+ assert.equal(await stageSiteArchives(paths, "jer"), 0);
+ assert.ok(!existsSync(bundleStaging));
+ // Across filesystems.
+ mkdirSync(host, { recursive: true });
+ writeFileSync(path.join(host, "c.zip"), "c");
+ const calls: string[] = [];
+ assert.equal(await stageSiteArchives(paths, "jer", exdevFs(path.join(paths.exportDir, ".r2-staging"), calls)), 1);
+ assert.ok(calls.some((c) => c.startsWith("cp ")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("stageSiteArchives is a no-op where the two are one place (the build container's compose)", async () => {
+ const root = mkdtempSync(path.join(os.tmpdir(), "bundle-same-"));
+ try {
+ // EXPORT_PUBLIC_DIR=/site/public and a builds dir whose <id> IS /site.
+ const site = path.join(root, "jer");
+ const paths = { exportPublicDir: path.join(site, "public"), exportBuildsDir: root } as Paths;
+ const staged = path.join(site, ".r2-staging", "jer", "archives");
+ mkdirSync(staged, { recursive: true });
+ writeFileSync(path.join(staged, "a.zip"), "a");
+ assert.equal(await stageSiteArchives(paths, "jer"), 1);
+ assert.ok(existsSync(path.join(staged, "a.zip")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("bundleCounts and corpusGeneratedAtIn read the bundle; links are not followed", async () => {
+ const { root } = tmp();
+ try {
+ const dir = path.join(root, "b");
+ writeBuild(dir, "m");
+ symlinkSync("/etc", path.join(dir, "link"));
+ const c = await bundleCounts(dir);
+ assert.equal(c.files, 3);
+ assert.equal(c.bytes, 1 + 10 + JSON.stringify({ generatedAt: "at-m" }).length);
+ assert.equal(await corpusGeneratedAtIn(dir), "at-m");
+ assert.equal(await corpusGeneratedAtIn(path.join(root, "none")), null);
+ assert.deepEqual(await bundleCounts(path.join(root, "none")), { files: 0, bytes: 0 });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/inputSig.test.ts b/common/publish/inputSig.test.ts
@@ -0,0 +1,169 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type { ChannelConfig } from "../lib/channelConfig";
+import type { Paths } from "../lib/paths";
+import type { Site } from "../lib/site";
+import { hubInputSig, siteInputSig } from "./inputSig";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// A site's inputSig moves exactly with what compose reads (release 18): its
+// staged index tree, its PUBLISHED members' shared trees (manifest.json aside,
+// a manifest-only tree signed as one), site.json, the corpus-wide files and the
+// two settings — and not with another site's channel, a churned manifest, or
+// a rewritten-but-identical chart-templates.json.
+
+function setup() {
+ const root = mkdtempSync(path.join(os.tmpdir(), "input-sig-"));
+ const idx = path.join(root, ".export-index");
+ const paths = {
+ transcriptsDir: path.join(root, "transcripts"),
+ sitesDir: path.join(root, "transcripts", "sites"),
+ exportSitesIndexDir: path.join(idx, "sites"),
+ exportSharedTranscriptsDir: path.join(idx, "shared", "transcripts"),
+ exportSharedSubsDir: path.join(idx, "shared", "subs"),
+ exportSharedPostsDir: path.join(idx, "shared", "posts"),
+ exportSharedDigestsDir: path.join(idx, "shared", "digests"),
+ globalAliasesFile: path.join(root, "transcripts", "search-aliases.json"),
+ globalTagsFile: path.join(root, "transcripts", "tags.json"),
+ homepageConfigFile: path.join(root, "transcripts", "sites", "_homepage", "homepage.json"),
+ } as Paths;
+ const site = {
+ siteId: "jer",
+ siteTitle: "Jer",
+ channels: [{ slug: "a" }, { slug: "x" }],
+ } as unknown as Site;
+ const write = (file: string, body: string) => {
+ mkdirSync(path.dirname(file), { recursive: true });
+ writeFileSync(file, body);
+ };
+ write(path.join(paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer" }));
+ write(path.join(paths.exportSitesIndexDir, "jer", "summaries", "page-0000.json"), "[]");
+ write(path.join(paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":1}');
+ write(path.join(paths.exportSharedTranscriptsDir, "a", "page-0000.json"), "[1]");
+ write(path.join(paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"1"}');
+ write(path.join(paths.exportSharedPostsDir, "x", "page-0000.json"), "[2]");
+ write(path.join(paths.exportSharedTranscriptsDir, "other", "page-0000.json"), "[3]");
+ const configs: Record<string, Partial<ChannelConfig>> = {
+ a: {},
+ x: { sourceKind: "social", platform: "twitter" } as Partial<ChannelConfig>,
+ };
+ let settings: { social?: { x?: { visibility?: string } } } = {};
+ const sig = () =>
+ siteInputSig({
+ paths,
+ site,
+ settings: settings as never,
+ configOf: (slug) => configs[slug] as ChannelConfig,
+ });
+ const bump = (file: string) => {
+ const t = new Date(Date.now() + 5_000);
+ utimesSync(file, t, t);
+ };
+ return {
+ root,
+ paths,
+ write,
+ sig,
+ bump,
+ setXPrivate: () => {
+ settings = { social: { x: { visibility: "private" } } };
+ },
+ };
+}
+
+test("inputSig is stable, and ignores what compose ignores", async () => {
+ const s = setup();
+ try {
+ const base = await s.sig();
+ assert.match(base, /^[0-9a-f]{40}$/);
+ assert.equal(await s.sig(), base, "nothing changed");
+ // A churned shared manifest (generatedAt) does not count…
+ s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"2"}');
+ assert.equal(await s.sig(), base);
+ // …nor another site's channel…
+ s.write(path.join(s.paths.exportSharedTranscriptsDir, "other", "page-0001.json"), "[4]");
+ assert.equal(await s.sig(), base);
+ // …nor chart-templates.json rewritten with the same bytes.
+ const tpl = path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json");
+ s.bump(tpl);
+ assert.equal(await s.sig(), base);
+ } finally {
+ rmSync(s.root, { recursive: true, force: true });
+ }
+});
+
+test("inputSig moves with each input compose reads", async () => {
+ const s = setup();
+ try {
+ let last = await s.sig();
+ const moved = async (what: string) => {
+ const now = await s.sig();
+ assert.notEqual(now, last, what);
+ last = now;
+ };
+ s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "page-0001.json"), "[5]");
+ await moved("a member's shared transcripts");
+ s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0001.json"), "[6]");
+ await moved("a member's shared posts");
+ s.write(path.join(s.paths.exportSitesIndexDir, "jer", "summaries", "page-0001.json"), "[]");
+ await moved("the site's staged summaries");
+ s.write(path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":2}');
+ await moved("the chart templates' bytes");
+ s.write(path.join(s.paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer", t: 1 }));
+ await moved("site.json");
+ s.write(path.join(s.paths.sitesDir, "jer", "tags.json"), "{}");
+ await moved("the site's config dir");
+ s.write(s.paths.globalAliasesFile, "{}");
+ await moved("the global search aliases");
+ s.write(s.paths.globalTagsFile, "{}");
+ await moved("the curated tags");
+ s.write(path.join(s.paths.transcriptsDir, "duplicates.json"), "{}");
+ await moved("the duplicates report");
+ // X posts private: the X member is withheld from a public site.
+ s.setXPrivate();
+ await moved("social.x.visibility");
+ s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0002.json"), "[7]");
+ assert.equal(await s.sig(), last, "a withheld member's tree is not this site's input");
+ } finally {
+ rmSync(s.root, { recursive: true, force: true });
+ }
+});
+
+test("a manifest-only member tree is signed as one, not as absent", async () => {
+ const s = setup();
+ try {
+ const before = await s.sig();
+ s.write(path.join(s.paths.exportSharedSubsDir, "a", "manifest.json"), "{}");
+ assert.notEqual(await s.sig(), before);
+ } finally {
+ rmSync(s.root, { recursive: true, force: true });
+ }
+});
+
+test("hubSig: the stamp, homepage.json, and each listed site's id + url + title", async () => {
+ const s = setup();
+ try {
+ const sites = [
+ { siteId: "jer", siteTitle: "Jer", siteUrl: "https://jer.pages.dev", channels: [] },
+ { siteId: "mine", siteTitle: "Mine", siteUrl: "https://m.pages.dev", audience: "private", channels: [] },
+ { siteId: "nourl", siteTitle: "No", channels: [] },
+ ] as unknown as Site[];
+ const a = await hubInputSig(s.paths, "s1", sites);
+ assert.equal(await hubInputSig(s.paths, "s1", sites), a);
+ assert.notEqual(await hubInputSig(s.paths, "s2", sites), a, "a new index stamp");
+ const retitled = sites.map((x) => (x.siteId === "jer" ? { ...x, siteTitle: "Jer 2" } : x));
+ assert.notEqual(await hubInputSig(s.paths, "s1", retitled), a);
+ // A private site or one with no url is not listed: changing it moves nothing.
+ const privRetitled = sites.map((x) => (x.siteId === "mine" ? { ...x, siteTitle: "M2" } : x));
+ assert.equal(await hubInputSig(s.paths, "s1", privRetitled), a);
+ s.write(s.paths.homepageConfigFile, '{"title":"hub"}');
+ assert.notEqual(await hubInputSig(s.paths, "s1", sites), a);
+ } finally {
+ rmSync(s.root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts
@@ -0,0 +1,239 @@
+import { after, test } from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import os from "node:os";
+import path from "node:path";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The publish stages run for real over a scratch corpus (release 18): the
+// update-index body writes the stamp and keeps its id when nothing changed;
+// every other stage asks its precondition ON DISK first (exit 3), a fresh
+// target is a no-op, `--to local` copies the bundle and records it; the lock
+// is waited for and a wait is cancellable (130). No `next build` and no
+// wrangler run here: the build and Pages paths are the bundle and build tests'.
+//
+// Every path getPaths() can resolve to a place this file may write is pinned
+// under ROOT before anything calls it (the buildStats.test.ts pattern).
+const ROOT = mkdtempSync(path.join(tmpdir(), "stage-run-"));
+const PINNED: Record<string, string> = {
+ TRANSCRIPTS_DIR: path.join(ROOT, "transcripts"),
+ SAVED_VIDEOS_DIR: path.join(ROOT, "saved-videos"),
+ SITES_DIR: path.join(ROOT, "transcripts", "sites"),
+ SETTINGS_FILE: path.join(ROOT, "settings.json"),
+ EXPORT_PUBLIC_DIR: path.join(ROOT, "export", "public"),
+ EXPORT_INDEX_DIR: path.join(ROOT, "export", ".export-index"),
+ EXPORT_BUILDS_DIR: path.join(ROOT, "export", ".export-builds"),
+ EDITOR_CHANGELOG_FILE: path.join(ROOT, "editor-CHANGELOG.md"),
+ EXPORT_CHANGELOG_FILE: path.join(ROOT, "export-CHANGELOG.md"),
+ CHARTS_CONFIG_FILE: path.join(ROOT, "chart-templates.json"),
+ SEARCH_ALIASES_FILE: path.join(ROOT, "transcripts", "search-aliases.json"),
+ CURATED_TAGS_FILE: path.join(ROOT, "transcripts", "tags.json"),
+ ARCHILYZER_CONFIG_DIR: path.join(ROOT, "config"),
+ ARCHILYZER_SOURCE_SCRATCH: path.join(ROOT, "source-scratch"),
+};
+Object.assign(process.env, PINNED);
+delete process.env.ARCHILYZER_SITE_OUT;
+delete process.env.ARCHIVES_READONLY;
+after(() => rmSync(ROOT, { recursive: true, force: true }));
+
+mkdirSync(path.join(ROOT, "transcripts", "channels"), { recursive: true });
+writeFileSync(PINNED.SETTINGS_FILE, "{}");
+function writeSite(id: string, extra: Record<string, unknown> = {}): void {
+ mkdirSync(path.join(PINNED.SITES_DIR, id), { recursive: true });
+ writeFileSync(
+ path.join(PINNED.SITES_DIR, id, "site.json"),
+ JSON.stringify({ siteId: id, siteTitle: id, channels: [], ...extra }),
+ );
+}
+writeSite("jer", { cloudflareProject: "w3c-never-real" });
+
+const { getPaths } = await import("../lib/paths");
+const { runStage } = await import("./stageRun");
+const stamps = await import("./stamps");
+const { bundleDir } = await import("./build");
+const { publishLockPath } = await import("./stageLock");
+const paths = getPaths();
+
+const quiet = () => {};
+async function stage(kind: string, target: string, extra: Record<string, unknown> = {}, logs?: string[]) {
+ return runStage(
+ { kind: kind as never, target, runId: "t", ...extra },
+ { paths, onLog: (l) => logs?.push(l) ?? quiet() },
+ );
+}
+
+function plantBundle(siteId: string): void {
+ const out = bundleDir(paths, siteId);
+ mkdirSync(out, { recursive: true });
+ writeFileSync(path.join(out, "index.html"), "<!doctype html>");
+ writeFileSync(path.join(out, "site.json"), JSON.stringify({ siteId }));
+ writeFileSync(path.join(out, "corpus.json"), JSON.stringify({ site: { id: siteId }, generatedAt: "g1" }));
+}
+
+test("before any index: a build is refused 'update the index first' (exit 3), and the lock is released", async () => {
+ const logs: string[] = [];
+ const r = await stage("build-site", "jer", {}, logs);
+ assert.equal(r.code, 3);
+ assert.equal(r.message, "update the index first");
+ assert.match(logs.join(""), /\[stage\] build-site jer: REFUSED — update the index first/);
+ assert.ok(!existsSync(publishLockPath(paths)));
+ assert.equal((await stage("build-hub", "_hub")).code, 3);
+ assert.equal((await stage("build-homepage", "_homepage")).code, 3);
+});
+
+test("update-index writes the stamp; a rerun with nothing changed keeps its id; a site.json edit makes a new one", async () => {
+ const first = await stage("update-index", "_index");
+ assert.equal(first.code, 0, first.message ?? "");
+ assert.equal(first.outcome?.status, "ran");
+ const stamp = await stamps.readIndexStamp(paths);
+ assert.ok(stamp);
+ assert.equal(stamp.stampId, first.outcome?.stamp);
+ assert.deepEqual(Object.keys(stamp.sites), ["jer"]);
+ assert.match(stamp.sites.jer.inputSig, /^[0-9a-f]{40}$/);
+ assert.equal(typeof stamp.scannedAt, "number");
+
+ const again = await stage("update-index", "_index");
+ assert.equal(again.code, 0);
+ assert.equal(again.outcome?.status, "noop");
+ const same = await stamps.readIndexStamp(paths);
+ assert.equal(same?.stampId, stamp.stampId);
+ assert.equal(same?.sites.jer.inputSig, stamp.sites.jer.inputSig);
+ assert.ok(same!.builtAt >= stamp.builtAt);
+
+ writeSite("jer", { cloudflareProject: "w3c-never-real", siteDescription: "edited" });
+ const edited = await stage("update-index", "_index");
+ assert.equal(edited.outcome?.status, "ran");
+ const next = await stamps.readIndexStamp(paths);
+ assert.notEqual(next?.stampId, stamp.stampId);
+ assert.notEqual(next?.sites.jer.inputSig, stamp.sites.jer.inputSig, "site.json is in the signature");
+ assert.notEqual(next?.hubSig, stamp.hubSig, "the hub follows the stamp");
+});
+
+test("a site the index has not seen is blocked; a fresh site's build is a no-op; deploys judge the built stamp", async () => {
+ writeSite("ani");
+ assert.match((await stage("build-site", "ani")).message!, /the index has not seen site "ani"/);
+ rmSync(path.join(PINNED.SITES_DIR, "ani"), { recursive: true });
+
+ // Never built: a deploy is refused with the command that fixes it.
+ const none = await stage("deploy-site", "jer");
+ assert.equal(none.code, 3);
+ assert.equal(none.message, "no build of jer — archilyzer publish build jer");
+
+ // A bundle stamped from the current index: fresh, so building is a no-op.
+ const stamp = (await stamps.readIndexStamp(paths))!;
+ plantBundle("jer");
+ await stamps.writeBuiltStamp(paths, {
+ v: 1,
+ stampId: "b-jer",
+ target: "jer",
+ kind: "site",
+ indexStampId: stamp.stampId,
+ inputSig: stamp.sites.jer.inputSig,
+ builtAt: Date.now(),
+ commit: null,
+ branch: "main",
+ runner: "local",
+ audience: "public",
+ corpusGeneratedAt: "g1",
+ files: 3,
+ bytes: 1,
+ archivesStaged: 0,
+ });
+ const fresh = await stage("build-site", "jer");
+ assert.equal(fresh.code, 0);
+ assert.equal(fresh.outcome?.status, "noop");
+ assert.equal(fresh.outcome?.stamp, "b-jer");
+
+ // A bad preview name is usage (2), before anything else is asked.
+ assert.equal((await stage("deploy-site", "jer", { preview: "main" })).code, 2);
+ assert.equal((await stage("deploy-site", "jer", { preview: "x", to: "local" })).code, 2);
+
+ // --to local needs the site service's directory.
+ const noOut = await stage("deploy-site", "jer", { to: "local" });
+ assert.equal(noOut.code, 3);
+ assert.match(noOut.message!, /--to local needs ARCHILYZER_SITE_OUT/);
+
+ // …and with it, copies the bundle into it (its CONTENTS replaced) and records the deploy.
+ const siteOut = path.join(ROOT, "builds", "site");
+ mkdirSync(siteOut, { recursive: true });
+ writeFileSync(path.join(siteOut, "stale.html"), "old");
+ process.env.ARCHILYZER_SITE_OUT = siteOut;
+ try {
+ const local = await stage("deploy-site", "jer", { to: "local" });
+ assert.equal(local.code, 0, local.message ?? "");
+ assert.equal(local.outcome?.status, "ran");
+ assert.ok(existsSync(path.join(siteOut, "index.html")));
+ assert.ok(!existsSync(path.join(siteOut, "stale.html")));
+ const rec = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "jer"), "local");
+ assert.equal(rec?.builtStampId, "b-jer");
+ assert.equal(rec?.liveCheck, null);
+ // Deployed already: a no-op, until --force.
+ assert.equal((await stage("deploy-site", "jer", { to: "local" })).outcome?.status, "noop");
+ assert.equal((await stage("deploy-site", "jer", { to: "local", force: true })).outcome?.status, "ran");
+ // builtAfter: the build this run waits on has not happened.
+ const waiting = await stage("deploy-site", "jer", { to: "local", builtAfter: Date.now() + 60_000 });
+ assert.equal(waiting.code, 3);
+ assert.match(waiting.message!, /waiting for the build of jer/);
+ } finally {
+ delete process.env.ARCHILYZER_SITE_OUT;
+ }
+
+ // Production refuses a build made on another branch.
+ const built = (await stamps.readBuiltStamp(paths, "jer"))!;
+ await stamps.writeBuiltStamp(paths, { ...built, branch: "r18/stage-core" });
+ const off = await stage("deploy-site", "jer");
+ assert.equal(off.code, 3);
+ assert.match(off.message!, /production ships only a build of main/);
+});
+
+test("a private site is never deployed, here either (exit 3)", async () => {
+ writeSite("mine", { audience: "private", cloudflareProject: "w3c-never-real" });
+ try {
+ const r = await stage("deploy-site", "mine", { to: "local" });
+ assert.equal(r.code, 3);
+ assert.match(r.message!, /Site "mine" is private/);
+ } finally {
+ rmSync(path.join(PINNED.SITES_DIR, "mine"), { recursive: true });
+ }
+});
+
+test("a live holder of the publish lock is waited for; a cancel during the wait exits 130", async () => {
+ mkdirSync(path.dirname(publishLockPath(paths)), { recursive: true });
+ writeFileSync(
+ publishLockPath(paths),
+ JSON.stringify({ pid: process.pid, host: os.hostname(), kind: "build-site", target: "x", since: 1 }),
+ );
+ try {
+ const ac = new AbortController();
+ const logs: string[] = [];
+ const running = runStage(
+ { kind: "build-site", target: "jer", runId: "t" },
+ { paths, signal: ac.signal, onLog: (l) => logs.push(l), lockEnv: { pollMs: 5 } },
+ );
+ setTimeout(() => ac.abort(), 50);
+ const r = await running;
+ assert.equal(r.code, 130);
+ assert.match(logs.join(""), /waiting for the publish lock — held by build-site x/);
+ assert.match(logs.join(""), /cancelled while waiting/);
+ } finally {
+ rmSync(publishLockPath(paths), { force: true });
+ }
+});
+
+test("a lock left by a dead process on this host is taken over", async () => {
+ writeFileSync(
+ publishLockPath(paths),
+ JSON.stringify({ pid: 2 ** 30, host: os.hostname(), kind: "update-index", target: "_index", since: 1 }),
+ );
+ const old = new Date(Date.now() - 1000);
+ utimesSync(publishLockPath(paths), old, old);
+ const logs: string[] = [];
+ const r = await stage("build-site", "jer", {}, logs);
+ assert.equal(r.code, 0, r.message ?? "");
+ assert.match(logs.join(""), /taking over a stale publish lock/);
+ assert.ok(!existsSync(publishLockPath(paths)));
+ assert.equal(readFileSync(path.join(bundleDir(paths, "jer"), "index.html"), "utf8"), "<!doctype html>");
+});
diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts
@@ -0,0 +1,319 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseArgv } from "../bin/_parseFlags";
+import type { Paths } from "../lib/paths";
+import { builtStamp, indexStamp } from "./__fixtures__/stamps";
+import type { BuiltStamp, DeployedFile, DeployRecord } from "./stamps";
+import { STAGE_EXIT, stageCommand } from "./stageRun";
+import {
+ STAGES,
+ STAGE_FLAGS,
+ STAGE_KINDS,
+ deployKindOf,
+ parseStageArgs,
+ stageArgv,
+ type Freshness,
+ type NeedsInput,
+ type StageRequest,
+ type TargetState,
+} from "./stages";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The publish stages' needs() — one case (at least) per row of the plan's
+// "Stages" table (plans/release-18.md), plus `changedChannels`, `--force` and
+// "code newer is not stale" — and the child's argv, both ways.
+
+function target(over: Partial<TargetState> = {}): TargetState {
+ return {
+ built: null,
+ deployed: null,
+ changedChannels: [],
+ configChangedAt: null,
+ bundleProblem: null,
+ ...over,
+ };
+}
+
+function input(over: Partial<NeedsInput> = {}): NeedsInput {
+ return {
+ index: { stamp: indexStamp(), lastIngestDoneAt: null, configChangedAt: null },
+ sites: { jer: target({ built: builtStamp() }) },
+ hub: target({ built: builtStamp({ target: "_hub", kind: "hub", inputSig: "hub-1" }) }),
+ homepage: {
+ ...target({
+ built: builtStamp({ target: "_homepage", kind: "homepage", inputSig: "s1", sourceCommit: "main-1" }),
+ }),
+ mainHead: "main-1",
+ },
+ ...over,
+ };
+}
+
+const req = (kind: StageRequest["kind"], tgt: string, over: Partial<StageRequest> = {}): StageRequest => ({
+ kind,
+ target: tgt,
+ runId: "run-1",
+ ...over,
+});
+
+const needs = (s: NeedsInput, r: StageRequest): Freshness => STAGES[r.kind].needs(s, r);
+const state = (f: Freshness) => f.state;
+const reason = (f: Freshness) => (f.state === "fresh" ? "" : f.reason);
+
+function deployed(over: Partial<DeployedFile> = {}, rec: Partial<DeployRecord> = {}): DeployedFile {
+ const r: DeployRecord = {
+ builtStampId: "b1",
+ builtAt: 3_000,
+ kind: "production",
+ url: null,
+ at: 4_000,
+ liveCheck: null,
+ ...rec,
+ };
+ return { v: 1, target: "jer", production: r, previews: {}, ...over };
+}
+
+test("the table: seven stages, each a publish-<kind> job on the publish queue", () => {
+ assert.deepEqual(Object.keys(STAGES), [...STAGE_KINDS]);
+ for (const k of STAGE_KINDS) {
+ assert.equal(STAGES[k].kind, k);
+ assert.equal(STAGES[k].jobKind, `publish-${k}`);
+ assert.equal(STAGES[k].queueKey, "publish");
+ }
+});
+
+// --- update-index -------------------------------------------------------------
+
+test("update-index: fresh when a stamp exists and nothing is newer than its scan", () => {
+ assert.equal(state(needs(input(), req("update-index", "_index"))), "fresh");
+ const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } });
+ assert.equal(reason(needs(none, req("update-index", "_index"))), "no index stamp yet");
+ const data = input({ index: { stamp: indexStamp(), lastIngestDoneAt: 1_001, configChangedAt: null } });
+ assert.equal(reason(needs(data, req("update-index", "_index"))), "new data since the last index");
+ const older = input({ index: { stamp: indexStamp(), lastIngestDoneAt: 999, configChangedAt: 1_000 } });
+ assert.equal(state(needs(older, req("update-index", "_index"))), "fresh", "not NEWER than scannedAt");
+ const cfg = input({ index: { stamp: indexStamp(), lastIngestDoneAt: null, configChangedAt: 5_000 } });
+ assert.match(reason(needs(cfg, req("update-index", "_index"))), /a config file changed/);
+ assert.equal(reason(needs(input(), req("update-index", "_index", { force: true }))), "forced");
+});
+
+// --- build-site <id> ----------------------------------------------------------
+
+test("build-site: blocked 'update the index first' with no stamp — even forced", () => {
+ const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } });
+ assert.deepEqual(needs(none, req("build-site", "jer")), { state: "blocked", reason: "update the index first" });
+ assert.equal(state(needs(none, req("build-site", "jer", { force: true }))), "blocked");
+});
+
+test("build-site: fresh when the built inputSig is the stamp's, nothing changed and the bundle is sound", () => {
+ assert.equal(state(needs(input(), req("build-site", "jer"))), "fresh");
+});
+
+test("build-site: changed channels make it stale before any index runs, named", () => {
+ const s = input({ sites: { jer: target({ built: builtStamp(), changedChannels: ["a", "b", "c", "d", "e"] }) } });
+ assert.equal(reason(needs(s, req("build-site", "jer"))), "5 channels changed (a, b, c, d, …)");
+ const one = input({ sites: { jer: target({ built: builtStamp(), changedChannels: ["a"] }) } });
+ assert.equal(reason(needs(one, req("build-site", "jer"))), "1 channel changed (a)");
+});
+
+test("build-site: an inputSig mismatch is 'data changed'; a config change, a bad bundle, no build are stale", () => {
+ const sig = input({ sites: { jer: target({ built: builtStamp({ inputSig: "older" }) }) } });
+ assert.equal(reason(needs(sig, req("build-site", "jer"))), "data changed");
+ const cfg = input({ sites: { jer: target({ built: builtStamp(), configChangedAt: 3_001 }) } });
+ assert.equal(reason(needs(cfg, req("build-site", "jer"))), "config changed");
+ const bad = input({ sites: { jer: target({ built: builtStamp(), bundleProblem: "holds a build of x" }) } });
+ assert.equal(reason(needs(bad, req("build-site", "jer"))), "holds a build of x");
+ const never = input({ sites: { jer: target() } });
+ assert.equal(reason(needs(never, req("build-site", "jer"))), "never built");
+});
+
+test("build-site: --force builds a fresh site; a code change alone is NOT stale", () => {
+ assert.equal(reason(needs(input(), req("build-site", "jer", { force: true }))), "forced");
+ const code = input({ sites: { jer: target({ built: builtStamp({ commit: "an-older-commit", branch: "x" }) }) } });
+ assert.equal(state(needs(code, req("build-site", "jer"))), "fresh");
+});
+
+test("build-site: indexAfter waits for this run's index; an unknown site or one the index never saw is blocked", () => {
+ assert.equal(state(needs(input(), req("build-site", "jer", { indexAfter: 2_000 }))), "fresh");
+ assert.match(reason(needs(input(), req("build-site", "jer", { indexAfter: 2_001 }))), /waiting for the index update/);
+ assert.equal(reason(needs(input(), req("build-site", "nope"))), 'no site "nope"');
+ const unseen = input({ sites: { jer: target({ built: builtStamp() }), ani: target() } });
+ assert.match(reason(needs(unseen, req("build-site", "ani"))), /the index has not seen site "ani" — update the index first/);
+});
+
+// --- build-site _all --runner docker ------------------------------------------
+
+test("build-site _all: per site, as above", () => {
+ const s = input({
+ index: {
+ stamp: indexStamp({ sites: { jer: indexStamp().sites.jer, ani: { siteFp: null, statsFp: null, inputSig: "x" } } }),
+ lastIngestDoneAt: null,
+ configChangedAt: null,
+ },
+ sites: { jer: target({ built: builtStamp() }), ani: target() },
+ });
+ assert.equal(reason(needs(s, req("build-site", "_all", { runner: "docker" }))), "1 of 2 sites to build (ani)");
+ const allFresh = input();
+ assert.equal(state(needs(allFresh, req("build-site", "_all", { runner: "docker" }))), "fresh");
+ const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } });
+ assert.equal(state(needs(none, req("build-site", "_all"))), "blocked");
+});
+
+// --- deploy-site --------------------------------------------------------------
+
+test("deploy-site: fresh exactly when the record for that kind/branch names the built stamp", () => {
+ const prod = input({ sites: { jer: target({ built: builtStamp(), deployed: deployed() }) } });
+ assert.equal(state(needs(prod, req("deploy-site", "jer"))), "fresh");
+ const newer = input({ sites: { jer: target({ built: builtStamp({ stampId: "b2" }), deployed: deployed() }) } });
+ assert.equal(reason(needs(newer, req("deploy-site", "jer"))), "a newer build is not deployed");
+ // The production record says nothing about a preview, or the local copy.
+ assert.equal(reason(needs(prod, req("deploy-site", "jer", { preview: "r18" }))), 'never deployed to preview "r18"');
+ assert.equal(reason(needs(prod, req("deploy-site", "jer", { to: "local" }))), "never deployed (local)");
+ const pv = input({
+ sites: { jer: target({ built: builtStamp(), deployed: deployed({ previews: { r18: deployed().production! } }) }) },
+ });
+ assert.equal(state(needs(pv, req("deploy-site", "jer", { preview: "r18" }))), "fresh");
+ assert.equal(reason(needs(prod, req("deploy-site", "jer", { force: true }))), "forced");
+});
+
+test("deploy-site: refused with no build, a private site, no project (pages only), a bad bundle, builtAfter", () => {
+ const never = input({ sites: { jer: target() } });
+ assert.equal(reason(needs(never, req("deploy-site", "jer"))), "no build of jer — archilyzer publish build jer");
+ const priv = input({ sites: { jer: target({ built: builtStamp(), deployProblem: "Site jer is private" }) } });
+ assert.equal(reason(needs(priv, req("deploy-site", "jer", { to: "local" }))), "Site jer is private");
+ const noProject = input({ sites: { jer: target({ built: builtStamp(), pagesProblem: "no project" }) } });
+ assert.equal(reason(needs(noProject, req("deploy-site", "jer"))), "no project");
+ assert.equal(state(needs(noProject, req("deploy-site", "jer", { to: "local" }))), "stale", "local needs no project");
+ const bad = input({ sites: { jer: target({ built: builtStamp(), bundleProblem: "torn" }) } });
+ assert.deepEqual(needs(bad, req("deploy-site", "jer")), { state: "blocked", reason: "torn" });
+ assert.match(reason(needs(input(), req("deploy-site", "jer", { builtAfter: 3_001 }))), /waiting for the build of jer/);
+ assert.equal(state(needs(never, req("deploy-site", "jer", { force: true }))), "blocked", "force never deploys nothing");
+});
+
+test("deploy-site: production refuses a build made on another branch; a preview of it is fine", () => {
+ const off = input({ sites: { jer: target({ built: builtStamp({ branch: "r18/stage-core" }) }) } });
+ assert.match(reason(needs(off, req("deploy-site", "jer"))), /built from branch "r18\/stage-core"; production ships only a build of main/);
+ assert.equal(state(needs(off, req("deploy-site", "jer", { preview: "r18" }))), "stale");
+ const detached = input({ sites: { jer: target({ built: builtStamp({ branch: null }) }) } });
+ assert.equal(state(needs(detached, req("deploy-site", "jer"))), "stale", "no branch (a container): allowed");
+});
+
+// --- the hub ------------------------------------------------------------------
+
+test("build-hub: fresh when built.inputSig is the stamp's hubSig", () => {
+ assert.equal(state(needs(input(), req("build-hub", "_hub"))), "fresh");
+ const moved = input({ index: { stamp: indexStamp({ hubSig: "hub-2" }), lastIngestDoneAt: null, configChangedAt: null } });
+ assert.equal(reason(needs(moved, req("build-hub", "_hub"))), "the index or the pool it lists changed");
+ const ch = input({ hub: target({ built: builtStamp({ target: "_hub", kind: "hub", inputSig: "hub-1" }), changedChannels: ["x"] }) });
+ assert.equal(reason(needs(ch, req("build-hub", "_hub"))), "1 channel changed (x)");
+ const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } });
+ assert.equal(reason(needs(none, req("build-hub", "_hub"))), "update the index first");
+});
+
+test("deploy-hub: as deploy-site", () => {
+ const s = input({ hub: target({ built: builtStamp({ target: "_hub", kind: "hub" }), deployed: deployed({ target: "_hub" }) }) });
+ assert.equal(state(needs(s, req("deploy-hub", "_hub"))), "fresh");
+ assert.equal(reason(needs(input({ hub: target() }), req("deploy-hub", "_hub"))), "no build of the hub — archilyzer publish hub");
+ const noProject = input({ hub: target({ built: builtStamp(), pagesProblem: "The hub has no Cloudflare Pages project" }) });
+ assert.match(reason(needs(noProject, req("deploy-hub", "_hub"))), /no Cloudflare Pages project/);
+});
+
+// --- the homepage -------------------------------------------------------------
+
+test("build-homepage: fresh when built from the current index stamp and main has not moved", () => {
+ assert.equal(state(needs(input(), req("build-homepage", "_homepage"))), "fresh");
+ const idx = input({ index: { stamp: indexStamp({ stampId: "s2" }), lastIngestDoneAt: null, configChangedAt: null } });
+ assert.equal(reason(needs(idx, req("build-homepage", "_homepage"))), "the index was updated");
+ const moved = input();
+ moved.homepage.mainHead = "main-2";
+ assert.equal(reason(needs(moved, req("build-homepage", "_homepage"))), "main has moved since the source was published");
+ const noRepo = input();
+ noRepo.homepage.mainHead = null;
+ noRepo.homepage.built = { ...(noRepo.homepage.built as BuiltStamp), sourceCommit: null };
+ assert.equal(state(needs(noRepo, req("build-homepage", "_homepage"))), "fresh", "no repository: the index decides");
+});
+
+test("deploy-homepage: as deploy-site", () => {
+ const s = input();
+ s.homepage.deployed = deployed({ target: "_homepage" });
+ assert.equal(state(needs(s, req("deploy-homepage", "_homepage"))), "fresh");
+ s.homepage.built = null;
+ assert.equal(reason(needs(s, req("deploy-homepage", "_homepage"))), "no build of the homepage — archilyzer publish homepage");
+});
+
+test("deployKindOf: --to local, --preview, else production", () => {
+ assert.equal(deployKindOf({}), "production");
+ assert.equal(deployKindOf({ preview: "x" }), "preview");
+ assert.equal(deployKindOf({ to: "local" }), "local");
+ assert.equal(deployKindOf({ to: "pages" }), "production");
+});
+
+// --- argv -------------------------------------------------------------------
+
+test("argv: the child's command line, pinned, and parsed back to the same request", () => {
+ const r: StageRequest = {
+ kind: "deploy-site",
+ target: "jer",
+ runId: "run-9",
+ preview: "r18",
+ force: true,
+ builtAfter: 1_700_000_000_000,
+ };
+ assert.deepEqual(STAGES["deploy-site"].argv(r), [
+ "stage", "deploy-site", "jer", "--run-id", "run-9", "--preview", "r18", "--force", "--built-after", "1700000000000",
+ ]);
+ const booleans = Object.entries(STAGE_FLAGS).filter(([, k]) => k === "boolean").map(([n]) => n);
+ for (const sample of [
+ r,
+ { kind: "update-index", target: "_index", runId: "a" },
+ { kind: "build-site", target: "_all", runId: "b", runner: "docker", skipArchives: true, indexAfter: 5 },
+ { kind: "build-site", target: "ani", runId: "c", allowMissingMedia: true },
+ { kind: "deploy-homepage", target: "_homepage", runId: "d", to: "local" },
+ ] as StageRequest[]) {
+ const { positionals, flags } = parseArgv(stageArgv(sample), booleans);
+ assert.equal(positionals[0], "stage");
+ assert.deepEqual(parseStageArgs(positionals.slice(1), flags), sample);
+ }
+});
+
+test("parseStageArgs refuses what the child must never guess", () => {
+ const err = (pos: string[], flags: Record<string, string | boolean> = { "run-id": "r" }) => {
+ const out = parseStageArgs(pos, flags);
+ return "error" in out ? out.error : "";
+ };
+ assert.match(err(["build"]), /which stage\?/);
+ assert.match(err(["build-site"]), /which target\?/);
+ assert.match(err(["build-site", "jer"], {}), /--run-id is required/);
+ assert.match(err(["update-index", "jer"]), /the target is _index/);
+ assert.match(err(["build-hub", "_index"]), /the target is _hub/);
+ assert.match(err(["deploy-site", "_all"]), /the target is a site id/);
+ assert.match(err(["deploy-site", "jer"], { "run-id": "r", to: "ftp" }), /--to is pages or local/);
+ assert.match(err(["build-site", "_all"], { "run-id": "r", runner: "podman" }), /--runner is local or docker/);
+ assert.match(err(["build-site", "jer"], { "run-id": "r", "index-after": "soon" }), /--index-after is a time/);
+ assert.match(err(["deploy-site", "jer"], { "run-id": "r", preview: "x", to: "local" }), /two different deploys/);
+ assert.match(err(["build-site", "../etc"]), /which target\?/);
+});
+
+// --- the spawned command ------------------------------------------------------
+
+test("stageCommand: common's tsx running bin/archilyzer.ts stage …, cwd common/, the heap cap for update-index only", () => {
+ const paths = { monorepoRoot: "/repo" } as Paths;
+ const idx = stageCommand(paths, { kind: "update-index", target: "_index", runId: "r1" }, { PATH: "/bin" });
+ assert.deepEqual(idx, {
+ command: "/repo/common/node_modules/.bin/tsx",
+ args: ["bin/archilyzer.ts", "stage", "update-index", "_index", "--run-id", "r1"],
+ cwd: "/repo/common",
+ env: { PATH: "/bin", NODE_OPTIONS: "--max-old-space-size=8192" },
+ });
+ const kept = stageCommand(paths, { kind: "update-index", target: "_index", runId: "r1" }, { NODE_OPTIONS: "--trace-warnings" });
+ assert.equal(kept.env.NODE_OPTIONS, "--trace-warnings --max-old-space-size=8192");
+ const build = stageCommand(paths, { kind: "build-site", target: "jer", runId: "r1" }, { PATH: "/bin" });
+ assert.deepEqual(build.env, { PATH: "/bin" });
+ assert.deepEqual(build.args, ["bin/archilyzer.ts", "stage", "build-site", "jer", "--run-id", "r1"]);
+});
+
+test("the exit codes", () => {
+ assert.deepEqual(STAGE_EXIT, { ok: 0, failed: 1, usage: 2, precondition: 3, cancelled: 130 });
+});