commit 079576e9259ae828de73b3e32467e1a0ff1abb7b
parent b6729c15dee46b093947d057ad56fcbf91f6beb4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 08:34:03 -0400
publish: the seven stages, their runner and the CLI rows (stage, publish index|build|deploy|hub|homepage)
stages.ts: the StageKind / StageRequest / Freshness / Stage / StageOutcome
table, a pure needs() per stage over NeedsInput (the PublishStatus-shaped
input S3's view satisfies), argv and its parser. stageBodies.ts: the bodies,
each judging its precondition on disk first (blocked = exit 3). The
update-index body runs buildIndex, buildStats and the chart templates in one
process and writes <exportIndexDir>/stamp.json with each site's inputSig
(inputSig.ts, compose's dirSignature) and the hubSig; an unchanged index keeps
its stamp id. stageRun.ts: the lock, the exit codes, the child entry and
stageCommand (the argv + env S3 spawns). build site / build all / deploy site
become printed aliases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 1828 insertions(+), 45 deletions(-)
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -76,60 +76,133 @@ export const COMMANDS: Command[] = [
return 0;
},
},
+ // --- publishing as stages (release 18; bin/publish.ts) ----------------------
+ {
+ path: ["publish", "index"],
+ usage:
+ "update the index: the LMDB index, the stats datasets and the chart templates in one child (8 GB heap), then the index stamp every build reads",
+ run: async () => (await import("./publish")).publishIndex(),
+ },
+ {
+ path: ["publish", "build"],
+ usage:
+ "<id|all> [--runner local|docker|auto] [--force] [--skip-archives] build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force",
+ flags: { runner: "string", force: "boolean", "skip-archives": "boolean" },
+ maxPositionals: 1,
+ run: async ({ positionals, flags }) => {
+ const [target] = positionals;
+ const runner = flags.runner;
+ if (!target || (runner !== undefined && runner !== "local" && runner !== "docker" && runner !== "auto")) {
+ console.error("publish build: give <id|all> [--runner local|docker|auto]");
+ return 2;
+ }
+ return (await import("./publish")).publishBuild({
+ target,
+ runner: runner as "local" | "docker" | "auto" | undefined,
+ force: flags.force === true,
+ skipArchives: flags["skip-archives"] === true,
+ });
+ },
+ },
+ {
+ path: ["publish", "deploy"],
+ usage:
+ "<id|all> [--preview <branch>] [--to local] [--force] ship a site's bundle to its Pages project (a preview with --preview), or with --to local into ARCHILYZER_SITE_OUT; a bundle already deployed there is a no-op without --force",
+ flags: { preview: "string", to: "string", force: "boolean" },
+ maxPositionals: 1,
+ run: async ({ positionals, flags }) => {
+ const [target] = positionals;
+ const to = flags.to;
+ if (!target || (to !== undefined && to !== "pages" && to !== "local")) {
+ console.error("publish deploy: give <id|all> [--preview <branch>] [--to local]");
+ return 2;
+ }
+ return (await import("./publish")).publishDeploy({
+ target,
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ to: to as "pages" | "local" | undefined,
+ force: flags.force === true,
+ });
+ },
+ },
+ {
+ path: ["publish", "hub"],
+ usage:
+ "[--deploy] [--preview <branch>] [--force] build the hub into its bundle <exportBuildsDir>/_hub/out, then (--deploy) ship it",
+ flags: { deploy: "boolean", preview: "string", force: "boolean" },
+ run: async ({ flags }) =>
+ (await import("./publish")).publishHub({
+ deploy: flags.deploy === true,
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ force: flags.force === true,
+ }),
+ },
+ {
+ path: ["publish", "homepage"],
+ usage:
+ "[--deploy] [--preview <branch>] [--to local] [--force] build homepage/out (source mirror included), then (--deploy) ship it",
+ flags: { deploy: "boolean", preview: "string", to: "string", force: "boolean" },
+ run: async ({ flags }) => {
+ const to = flags.to;
+ if (to !== undefined && to !== "pages" && to !== "local") {
+ console.error("publish homepage: --to is pages or local");
+ return 2;
+ }
+ return (await import("./publish")).publishHomepage({
+ deploy: flags.deploy === true,
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ to: to as "pages" | "local" | undefined,
+ force: flags.force === true,
+ });
+ },
+ },
+ // `publish status` and `publish now` read the publish state view — release 18
+ // slice S3 adds their rows here.
+ {
+ 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)",
+ // publish/stages.ts STAGE_FLAGS, spelled out so this table stays free of
+ // imports (_cli.test.ts holds the two equal).
+ flags: {
+ "run-id": "string",
+ preview: "string",
+ to: "string",
+ runner: "string",
+ force: "boolean",
+ "skip-archives": "boolean",
+ "allow-missing-media": "boolean",
+ "index-after": "string",
+ "built-after": "string",
+ },
+ maxPositionals: 2,
+ run: async (ctx) => (await import("./publish")).stageRow(ctx),
+ },
+ // The rows the stages replaced, kept as printed aliases.
{
path: ["build", "site"],
usage:
- "<id> [--nodata] [--skip-archives] [--allow-missing-media] data phase + compose + next build into export/out (default id: SITE_ID)",
+ "<id> [--nodata] [--skip-archives] [--allow-missing-media] alias: publish index (not with --nodata) + publish build <id> --force (default id: SITE_ID)",
flags: { nodata: "boolean", "skip-archives": "boolean", "allow-missing-media": "boolean" },
maxPositionals: 1,
run: async ({ positionals, flags, env }) => {
const siteId = siteIdFrom(positionals, env, "build site");
if (!siteId) return 2;
- // A missing site.json reads as a site of defaults, so a typo would build
- // the whole data phase before compose noticed. Refuse it up front.
- const { listSiteIds } = await import("../lib/site");
- const known = listSiteIds();
- if (!known.includes(siteId)) {
- console.error(
- `build site: no site "${siteId}" (configured: ${known.join(", ") || "none"})`,
- );
- return 2;
- }
- const { buildSite } = await import("../publish/build");
- const code = await buildSite(siteId, {
- signal: interrupted(),
- skipData: flags.nodata === true,
+ return (await import("./publish")).buildSiteAlias({
+ siteId,
+ nodata: flags.nodata === true,
skipArchives: flags["skip-archives"] === true,
allowMissingMedia: flags["allow-missing-media"] === true,
});
- if (code !== 0) console.error(`build site ${siteId}: failed (exit ${code})`);
- return code;
},
},
{
path: ["build", "all"],
usage:
- "[--skip-archives] build every site: docker fan-out when an engine answers, else serially on the host",
+ "[--skip-archives] alias: publish index + publish build all --runner auto (containers when an engine answers, else serially on the host)",
flags: { "skip-archives": "boolean" },
- run: async ({ flags }) => {
- const { buildAll, dockerAvailable } = await import("../publish/build");
- const signal = interrupted();
- const useDocker = await dockerAvailable(signal);
- if (!useDocker) {
- console.log(
- "[notice] No container engine available — building sites serially on the host.",
- );
- }
- const outcomes = await buildAll({
- signal,
- mode: useDocker ? "docker" : "basic",
- skipArchives: flags["skip-archives"] === true,
- });
- const failed = outcomes.filter((o) => o.code !== 0);
- console.log(`\n=== Summary: ${outcomes.length - failed.length}/${outcomes.length} built ===`);
- for (const f of failed) console.error(` ${f.siteId}: exit ${f.code}`);
- return failed.length || signal.aborted ? 1 : 0;
- },
+ run: async ({ flags }) =>
+ (await import("./publish")).buildAllAlias({ skipArchives: flags["skip-archives"] === true }),
},
{
path: ["build", "hub"],
@@ -263,19 +336,16 @@ export const COMMANDS: Command[] = [
{
path: ["deploy", "site"],
usage:
- "<id> [--preview <branch>] ship the site built in export/out to its Pages project (default id: SITE_ID)",
+ "<id> [--preview <branch>] alias: publish deploy <id> [--preview <branch>] — ship the site's bundle to its Pages project (default id: SITE_ID)",
flags: { preview: "string" },
maxPositionals: 1,
run: async ({ positionals, flags, env }) => {
const siteId = siteIdFrom(positionals, env, "deploy site");
if (!siteId) return 2;
- const { deploySite } = await import("../publish/build");
- return refusalsExit(() =>
- deploySite(siteId, {
- signal: interrupted(),
- previewBranch: typeof flags.preview === "string" ? flags.preview : undefined,
- }),
- );
+ return (await import("./publish")).deploySiteAlias({
+ siteId,
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ });
},
},
{
diff --git a/common/bin/publish.ts b/common/bin/publish.ts
@@ -0,0 +1,278 @@
+// `archilyzer publish …` and `archilyzer stage …` (release 18): the publish
+// stages from the command line. The table rows are in archilyzer.ts; this is
+// what they run.
+//
+// publish index update-index (as a child: its heap cap)
+// publish build <id|all> [--runner local|docker|auto] [--force] [--skip-archives]
+// publish deploy <id|all> [--preview <b>] [--to local] [--force]
+// publish hub [--deploy] [--preview <b>] [--force]
+// publish homepage [--deploy] [--preview <b>] [--to local] [--force]
+// stage <kind> <target> [flags] the child the editor spawns
+//
+// Every stage runs under the publish lock (publish/stageLock.ts), so a CLI run
+// beside the editor waits for the editor's stage — and the editor's for it.
+// `publish index` runs the SAME child the editor spawns (stageCommand: the
+// 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 { getPaths, type Paths } from "../lib/paths";
+import { listSites, listSiteIds } from "../lib/site";
+import { newStampId } from "../publish/stamps";
+import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun";
+import { parseStageArgs, type StageRequest } from "../publish/stages";
+import type { CommandContext } from "./_cli";
+
+type Out = { log: (s: string) => void; error: (s: string) => void };
+
+function terminalLog(line: string): void {
+ process.stdout.write(line.endsWith("\n") ? line : `${line}\n`);
+}
+
+/** A run id for the stages one CLI command runs (the editor's are its own). */
+export function cliRunId(): string {
+ return `cli-${newStampId()}`;
+}
+
+// Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does.
+function interrupted(): AbortSignal {
+ const ac = new AbortController();
+ process.once("SIGINT", () => ac.abort());
+ process.once("SIGTERM", () => ac.abort());
+ return ac.signal;
+}
+
+// --- stage (the child) --------------------------------------------------------
+
+export async function stageRow(ctx: CommandContext, out: Out = console): Promise<number> {
+ const req = parseStageArgs(ctx.positionals, ctx.flags);
+ if ("error" in req) {
+ out.error(req.error);
+ return STAGE_EXIT.usage;
+ }
+ return stageMain(req);
+}
+
+// --- publish index ------------------------------------------------------------
+
+/**
+ * Run update-index as the editor does: a child with the index heap cap. A
+ * Ctrl-C reaches the child from the terminal (it unwinds and exits 130); a
+ * SIGTERM to this process is passed on to it.
+ */
+export async function publishIndex(opts: { paths?: Paths; runId?: string } = {}): Promise<number> {
+ const paths = opts.paths ?? getPaths();
+ const req: StageRequest = { kind: "update-index", target: "_index", runId: opts.runId ?? cliRunId() };
+ const cmd = stageCommand(paths, req);
+ const ac = new AbortController();
+ const onInt = () => {};
+ const onTerm = () => ac.abort();
+ process.on("SIGINT", onInt);
+ process.on("SIGTERM", onTerm);
+ try {
+ return await runChildIntoLog(terminalLog, ac.signal, cmd);
+ } finally {
+ process.off("SIGINT", onInt);
+ process.off("SIGTERM", onTerm);
+ }
+}
+
+// --- publish build / deploy / hub / homepage ----------------------------------
+
+function knownSite(id: string, name: string, out: Out, paths?: Paths): boolean {
+ const known = listSiteIds(paths);
+ if (known.includes(id)) return true;
+ out.error(`${name}: no site "${id}" (configured: ${known.join(", ") || "none"})`);
+ return false;
+}
+
+async function run(req: StageRequest, signal: AbortSignal, paths?: Paths): Promise<number> {
+ setKillChildTrees(true);
+ return (await runStage(req, { paths, signal })).code;
+}
+
+export type BuildArgs = {
+ target: string; // a site id or "all"
+ runner?: "local" | "docker" | "auto";
+ force?: boolean;
+ skipArchives?: boolean;
+ allowMissingMedia?: boolean;
+ runId?: string;
+ paths?: Paths;
+ signal?: AbortSignal;
+};
+
+export async function publishBuild(a: BuildArgs, out: Out = console): Promise<number> {
+ const all = a.target === "all";
+ if (!all && !knownSite(a.target, "publish build", out, a.paths)) return STAGE_EXIT.usage;
+ const signal = a.signal ?? interrupted();
+ let runner: "local" | "docker" = "local";
+ if (a.runner === "docker" || a.runner === "auto") {
+ if (!all) {
+ out.error("publish build: --runner docker builds every site in containers — publish build all --runner docker");
+ return STAGE_EXIT.usage;
+ }
+ if (a.runner === "docker") runner = "docker";
+ else {
+ const { dockerAvailable } = await import("../publish/build");
+ runner = (await dockerAvailable(signal)) ? "docker" : "local";
+ if (runner === "local") out.log("[notice] No container engine available — building sites serially on the host.");
+ }
+ }
+ return run(
+ {
+ kind: "build-site",
+ target: all ? "_all" : a.target,
+ runId: a.runId ?? cliRunId(),
+ ...(all ? { runner } : {}),
+ ...(a.force ? { force: true } : {}),
+ ...(a.skipArchives ? { skipArchives: true } : {}),
+ ...(a.allowMissingMedia ? { allowMissingMedia: true } : {}),
+ },
+ signal,
+ a.paths,
+ );
+}
+
+export type DeployArgs = {
+ target: string; // a site id or "all"
+ preview?: string;
+ to?: "pages" | "local";
+ force?: boolean;
+ runId?: string;
+ paths?: Paths;
+ signal?: AbortSignal;
+};
+
+export async function publishDeploy(a: DeployArgs, out: Out = console): Promise<number> {
+ const all = a.target === "all";
+ 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];
+ let worst = 0;
+ for (const id of ids) {
+ if (signal.aborted) return STAGE_EXIT.cancelled;
+ const code = await run(
+ {
+ kind: "deploy-site",
+ target: id,
+ runId,
+ ...(a.preview ? { preview: a.preview } : {}),
+ ...(a.to ? { to: a.to } : {}),
+ ...(a.force ? { force: true } : {}),
+ },
+ signal,
+ 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;
+ }
+ return worst;
+}
+
+export async function publishHub(
+ a: { deploy?: boolean; preview?: string; force?: boolean; runId?: string; paths?: Paths; signal?: AbortSignal },
+ out: Out = console,
+): Promise<number> {
+ if (a.preview && !a.deploy) {
+ out.error("publish hub: --preview is a deploy — add --deploy");
+ return STAGE_EXIT.usage;
+ }
+ const signal = a.signal ?? interrupted();
+ const runId = a.runId ?? cliRunId();
+ const built = await run({ kind: "build-hub", target: "_hub", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths);
+ if (built !== 0 || !a.deploy) return built;
+ return run({ kind: "deploy-hub", target: "_hub", runId, ...(a.preview ? { preview: a.preview } : {}) }, signal, a.paths);
+}
+
+export async function publishHomepage(
+ a: {
+ deploy?: boolean;
+ preview?: string;
+ to?: "pages" | "local";
+ force?: boolean;
+ runId?: string;
+ paths?: Paths;
+ signal?: AbortSignal;
+ },
+ out: Out = console,
+): Promise<number> {
+ if ((a.preview || a.to) && !a.deploy) {
+ out.error("publish homepage: --preview and --to are a deploy's — add --deploy");
+ return STAGE_EXIT.usage;
+ }
+ const signal = a.signal ?? interrupted();
+ const runId = a.runId ?? cliRunId();
+ const built = await run(
+ { kind: "build-homepage", target: "_homepage", runId, ...(a.force ? { force: true } : {}) },
+ signal,
+ a.paths,
+ );
+ if (built !== 0 || !a.deploy) return built;
+ return run(
+ {
+ kind: "deploy-homepage",
+ target: "_homepage",
+ runId,
+ ...(a.preview ? { preview: a.preview } : {}),
+ ...(a.to ? { to: a.to } : {}),
+ },
+ signal,
+ a.paths,
+ );
+}
+
+// --- the old rows, as printed aliases -------------------------------------------
+
+export const ALIASES = {
+ buildSite: (id: string, nodata: boolean) =>
+ `${nodata ? "" : "archilyzer publish index && "}archilyzer publish build ${id} --force`,
+ buildAll: "archilyzer publish index && archilyzer publish build all --runner auto",
+ deploySite: (id: string, preview?: string) =>
+ `archilyzer publish deploy ${id}${preview ? ` --preview ${preview}` : ""}`,
+} as const;
+
+/** `build site <id> [--nodata]` = `publish index` (skipped by --nodata) + `publish build <id> --force`. */
+export async function buildSiteAlias(
+ a: { siteId: string; nodata: boolean; skipArchives: boolean; allowMissingMedia: boolean },
+ out: Out = console,
+): Promise<number> {
+ if (!knownSite(a.siteId, "build site", out)) return STAGE_EXIT.usage;
+ out.log(`[alias] build site is now: ${ALIASES.buildSite(a.siteId, a.nodata)}`);
+ const runId = cliRunId();
+ if (!a.nodata) {
+ const code = await publishIndex({ runId });
+ if (code !== 0) return code;
+ }
+ return publishBuild(
+ {
+ target: a.siteId,
+ force: true,
+ skipArchives: a.skipArchives,
+ allowMissingMedia: a.allowMissingMedia,
+ runId,
+ },
+ out,
+ );
+}
+
+/** `build all` = `publish index` + `publish build all --runner auto`. */
+export async function buildAllAlias(a: { skipArchives: boolean }, out: Out = console): Promise<number> {
+ out.log(`[alias] build all is now: ${ALIASES.buildAll}`);
+ const runId = cliRunId();
+ const code = await publishIndex({ runId });
+ if (code !== 0) return code;
+ return publishBuild({ target: "all", runner: "auto", skipArchives: a.skipArchives, runId }, out);
+}
+
+/** `deploy site <id>` = `publish deploy <id>`. */
+export async function deploySiteAlias(
+ a: { siteId: string; preview?: string },
+ out: Out = console,
+): Promise<number> {
+ out.log(`[alias] deploy site is now: ${ALIASES.deploySite(a.siteId, a.preview)}`);
+ return publishDeploy({ target: a.siteId, preview: a.preview }, out);
+}
diff --git a/common/publish/inputSig.ts b/common/publish/inputSig.ts
@@ -0,0 +1,201 @@
+// What a site's (and the hub's) build reads, as one sha1 — computed by the
+// update-index stage and stored in the IndexStamp (release 18).
+//
+// THE RULE: a site is fresh exactly when compose would skip everything. So
+// the signature is made of what compose-site reads, signed with compose's own
+// `dirSignature` (lib/dirSignature.ts) and compose's own manifest-only rule:
+//
+// - the site's whole `.export-index/sites/<id>/` tree (summaries, stats,
+// chart templates, tag counts, the subs/posts/digests manifests, the
+// report media and exports);
+// - each PUBLISHED member channel's shared transcripts / subs / posts /
+// digests tree, `manifest.json` ignored (its `generatedAt` churns);
+// - the bytes of `site.json`, and the signature of the site's config dir
+// (`sites/<id>/`: tags, aliases, reports);
+// - the corpus-wide files compose reads at compose time (search aliases,
+// curated tags, the duplicates report and its overrides), by size + mtime;
+// - the `archiveStorage` and `social.x.visibility` settings.
+//
+// A superset of compose's skip inputs is CONSERVATIVE: a needless rebuild,
+// never a wrong skip. `hubSig` = sha1(stampId, homepage.json, each listed
+// site's id + siteUrl + title).
+
+import { createHash } from "node:crypto";
+import { existsSync } from "node:fs";
+import { readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import { open } from "lmdb";
+import { dirSignature } from "../lib/dirSignature";
+import { DUPLICATES_FILENAME, DUPLICATE_OVERRIDES_FILENAME } from "../lib/duplicates";
+import type { Paths } from "../lib/paths";
+import { publishedMemberSlugs } from "../lib/postsVisibility";
+import type { SiteSettings } from "../lib/settings";
+import { siteConfigFile, siteDir, siteIndexDir, type Site } from "../lib/site";
+import { isListedSite } from "../lib/siteSchema";
+import { INDEX_SCANNED_AT_KEY } from "../lib/stats";
+import { readChannelConfig } from "../controller/channels";
+import type { ChannelConfig } from "../lib/channelConfig";
+
+// compose-site.ts MANIFEST_ONLY_SIGNATURE — a tree holding only its manifest is
+// a channel with nothing in it, signed by a constant (compose's rule).
+const MANIFEST_ONLY = "manifest-only";
+// lib/chartsStore.ts siteTemplatesStagingPath's basename.
+const CHART_TEMPLATES = "chart-templates.json";
+
+const sha1 = (s: string | Buffer) => createHash("sha1").update(s).digest("hex");
+
+async function fileBytesSig(file: string): Promise<string> {
+ try {
+ return sha1(await readFile(file));
+ } catch {
+ return "";
+ }
+}
+
+async function fileStatSig(file: string): Promise<string> {
+ try {
+ const s = await stat(file);
+ return `${s.size}\t${s.mtimeMs}`;
+ } catch {
+ return "";
+ }
+}
+
+/** A memo over the shared per-channel trees: each is walked once per stamp. */
+export type TreeSigCache = Map<string, string>;
+
+async function channelTreeSig(root: string, slug: string, cache: TreeSigCache): Promise<string> {
+ const dir = path.join(root, slug);
+ const hit = cache.get(dir);
+ if (hit !== undefined) return hit;
+ let sig = await dirSignature(dir, "manifest.json");
+ if (sig === "" && existsSync(path.join(dir, "manifest.json"))) sig = MANIFEST_ONLY;
+ cache.set(dir, sig);
+ return sig;
+}
+
+export type SiteSigInputs = {
+ paths: Paths;
+ site: Site;
+ settings: Pick<SiteSettings, "archiveStorage" | "social">;
+ // The site's members' configs (the visibility rule reads their platform).
+ configOf: (slug: string) => ChannelConfig | null | undefined;
+ cache?: TreeSigCache;
+};
+
+/** The site's inputSig (see the header). */
+export async function siteInputSig(i: SiteSigInputs): Promise<string> {
+ const { paths, site, settings } = i;
+ const cache = i.cache ?? new Map();
+ const members = [...publishedMemberSlugs(site, i.configOf, settings)].sort();
+ const lines: string[] = ["inputSig v1"];
+ lines.push(`members\t${members.join(",")}`);
+ // `build templates` rewrites chart-templates.json on every run (and compose
+ // copies it on every run): signed by its bytes, not its mtime.
+ const indexDir = siteIndexDir(paths, site.siteId);
+ lines.push(`site-index\t${await dirSignature(indexDir, CHART_TEMPLATES)}`);
+ lines.push(`${CHART_TEMPLATES}\t${await fileBytesSig(path.join(indexDir, CHART_TEMPLATES))}`);
+ for (const slug of members) {
+ for (const [tree, root] of [
+ ["transcripts", paths.exportSharedTranscriptsDir],
+ ["subs", paths.exportSharedSubsDir],
+ ["posts", paths.exportSharedPostsDir],
+ ["digests", paths.exportSharedDigestsDir],
+ ] as const) {
+ lines.push(`${tree}/${slug}\t${await channelTreeSig(root, slug, cache)}`);
+ }
+ }
+ lines.push(`site.json\t${await fileBytesSig(siteConfigFile(paths, site.siteId))}`);
+ lines.push(`site-dir\t${await dirSignature(siteDir(paths, site.siteId))}`);
+ for (const file of [
+ paths.globalAliasesFile,
+ paths.globalTagsFile,
+ path.join(paths.transcriptsDir, DUPLICATES_FILENAME),
+ path.join(paths.transcriptsDir, DUPLICATE_OVERRIDES_FILENAME),
+ ]) {
+ lines.push(`${path.basename(file)}\t${await fileStatSig(file)}`);
+ }
+ lines.push(
+ `settings\t${JSON.stringify({
+ archiveStorage: settings.archiveStorage ?? null,
+ xVisibility: settings.social?.x?.visibility ?? null,
+ })}`,
+ );
+ return sha1(lines.join("\n"));
+}
+
+/** Every member's channel config, read once (unreadable = null). */
+export async function readMemberConfigs(
+ paths: Paths,
+ sites: Site[],
+): Promise<Map<string, ChannelConfig | null>> {
+ const slugs = new Set(sites.flatMap((s) => s.channels.map((c) => c.slug)));
+ const out = new Map<string, ChannelConfig | null>();
+ await Promise.all(
+ [...slugs].map(async (slug) => {
+ out.set(slug, await readChannelConfig(paths, slug).catch(() => null));
+ }),
+ );
+ return out;
+}
+
+/** The hub's signature: the index it was built from, its config, the pool it lists. */
+export async function hubInputSig(
+ paths: Paths,
+ stampId: string,
+ sites: Site[],
+): Promise<string> {
+ const lines = [`hubSig v1`, `stamp\t${stampId}`];
+ lines.push(`homepage.json\t${await fileBytesSig(paths.homepageConfigFile)}`);
+ for (const s of [...sites].sort((a, b) => a.siteId.localeCompare(b.siteId))) {
+ if (!isListedSite(s) || !s.siteUrl) continue;
+ lines.push(`site\t${s.siteId}\t${s.siteUrl}\t${s.siteTitle}`);
+ }
+ return sha1(lines.join("\n"));
+}
+
+export type IndexMeta = {
+ generation: number;
+ scannedAt: number | null;
+ // sha1 of each site's stored fingerprints, null when absent.
+ siteFp: Record<string, string | null>;
+ statsFp: Record<string, string | null>;
+};
+
+/**
+ * The LMDB index's own bookkeeping, read-only, after the index and stats
+ * builds have closed it: `generation`, INDEX_SCANNED_AT_KEY and each site's
+ * `siteFp:<id>` / `statsFp:<id>`. An absent index reads as zeros.
+ */
+export function readIndexMeta(paths: Paths, siteIds: string[]): IndexMeta {
+ const out: IndexMeta = { generation: 0, scannedAt: null, siteFp: {}, statsFp: {} };
+ for (const id of siteIds) {
+ out.siteFp[id] = null;
+ out.statsFp[id] = null;
+ }
+ if (!existsSync(paths.lmdbPath)) return out;
+ const root = open({ path: paths.lmdbPath, readOnly: true, maxDbs: 18, compression: true });
+ try {
+ const meta = root.openDB<unknown, string>({ name: "meta", encoding: "msgpack" });
+ // An index that stats never ran over has no statsMeta sub-DB.
+ let statsMeta: typeof meta | null = null;
+ try {
+ statsMeta = root.openDB<unknown, string>({ name: "statsMeta", encoding: "msgpack" });
+ } catch {
+ statsMeta = null;
+ }
+ const gen = meta.get("generation");
+ out.generation = typeof gen === "number" ? gen : 0;
+ const scanned = meta.get(INDEX_SCANNED_AT_KEY);
+ out.scannedAt = typeof scanned === "number" ? scanned : null;
+ for (const id of siteIds) {
+ const fp = meta.get(`siteFp:${id}`);
+ const sfp = statsMeta?.get(`statsFp:${id}`);
+ out.siteFp[id] = typeof fp === "string" ? sha1(fp) : null;
+ out.statsFp[id] = typeof sfp === "string" ? sha1(sfp) : null;
+ }
+ } finally {
+ root.close();
+ }
+ return out;
+}
diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts
@@ -0,0 +1,693 @@
+// The publish stages' bodies (release 18): what `run()` does, in the stage
+// child the editor spawns (`archilyzer stage <kind> <target> …`) or in the
+// CLI's own process (`archilyzer publish …`), always under the publish lock
+// (stageRun.ts takes it).
+//
+// Every body but update-index first asks its stage's `needs()` over the state
+// ON DISK (`readNeedsInput`): blocked → StageFailure exit 3 (the precondition
+// is not met — "update the index first", "no build of X"), fresh and not
+// forced → a no-op. Ordering between the stages of one run is enforced here,
+// not in anybody's memory.
+//
+// The three deploy bodies call today's deploy functions in build.ts with the
+// target's own bundle; release 18 S2 rewires them to its deploy stage
+// (pinned wrangler, credential preflight, the live check).
+
+import { execFile } from "node:child_process";
+import { cp, mkdir, readdir, rm } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import {
+ builtAudienceProblem,
+ builtBundleProblem,
+ builtHomepageProblem,
+ builtHubProblem,
+ siteDeployProblem,
+} from "../lib/builtExport";
+import { getHomepageConfig } from "../lib/homepage";
+import { previewAliasUrl, previewBranchProblem } from "../lib/pagesDeploy";
+import type { Paths } from "../lib/paths";
+import { getSettings } from "../lib/settings";
+import { getSite, listSites, type Site } from "../lib/site";
+import {
+ ALL_TARGET,
+ HOMEPAGE_TARGET,
+ HUB_TARGET,
+ newStampId,
+ readBuiltStamp,
+ readDeployedFile,
+ readIndexStamp,
+ recordDeploy,
+ writeBuiltStamp,
+ type BuiltKind,
+ type BuiltStamp,
+ type DeployRecord,
+ type IndexStamp,
+ type Runner,
+} from "./stamps";
+import {
+ STAGES,
+ deployKindOf,
+ type NeedsInput,
+ type StageContext,
+ type StageOutcome,
+ type StageRequest,
+ type TargetState,
+} from "./stages";
+
+/** A stage that did not run to the end: its exit code says why (stageRun.ts). */
+export class StageFailure extends Error {
+ constructor(
+ message: string,
+ readonly exitCode: 1 | 2 | 3,
+ ) {
+ super(message);
+ this.name = "StageFailure";
+ }
+}
+
+export class StageCancelled extends Error {
+ constructor() {
+ super("cancelled");
+ this.name = "StageCancelled";
+ }
+}
+
+function checkCancel(signal: AbortSignal): void {
+ if (signal.aborted) throw new StageCancelled();
+}
+
+// ---------------------------------------------------------------------------
+// git facts for the stamps
+// ---------------------------------------------------------------------------
+
+const exec = promisify(execFile);
+
+async function git(cwd: string, args: string[]): Promise<string | null> {
+ try {
+ const { stdout } = await exec("git", args, { cwd, timeout: 10_000 });
+ const out = stdout.trim();
+ return out || null;
+ } catch {
+ return null;
+ }
+}
+
+/** The checkout's commit and branch (null where there is no repository). */
+export async function checkoutInfo(paths: Pick<Paths, "monorepoRoot">): Promise<{
+ commit: string | null;
+ branch: string | null;
+}> {
+ const commit = await git(paths.monorepoRoot, ["rev-parse", "HEAD"]);
+ const branch = await git(paths.monorepoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
+ return { commit, branch: branch === "HEAD" ? null : branch };
+}
+
+/** `main`'s HEAD where a repository is reachable, else null. */
+export async function mainHeadOf(paths: Pick<Paths, "monorepoRoot">): Promise<string | null> {
+ return git(paths.monorepoRoot, ["rev-parse", "--verify", "--quiet", "refs/heads/main^{commit}"]);
+}
+
+// ---------------------------------------------------------------------------
+// The state needs() reads, from disk alone
+// ---------------------------------------------------------------------------
+
+function sitePagesProblem(site: Site): string | null {
+ return site.cloudflareProject?.trim()
+ ? null
+ : `Site "${site.siteId}" has no Cloudflare Pages project configured`;
+}
+
+/**
+ * The NeedsInput a stage child can build from disk alone: the stamps and the
+ * bundles. It does not read job metas or config mtimes (`changedChannels` is
+ * empty, `configChangedAt` null): a child judges by signatures, and the
+ * signature in the index stamp is the authority once the index has run.
+ */
+export async function readNeedsInput(
+ paths: Paths,
+ opts: { mainHead?: boolean } = {},
+): Promise<NeedsInput> {
+ const { bundleDir, homepageOutDir, hubProjectProblem } = await import("./build");
+ const stamp = await readIndexStamp(paths);
+ const sites: Record<string, TargetState> = {};
+ for (const site of listSites(paths)) {
+ const built = await readBuiltStamp(paths, site.siteId);
+ sites[site.siteId] = {
+ built,
+ deployed: await readDeployedFile(paths, site.siteId),
+ changedChannels: [],
+ configChangedAt: null,
+ bundleProblem: built ? builtBundleProblem(bundleDir(paths, site.siteId), site.siteId) : null,
+ deployProblem: siteDeployProblem(site),
+ pagesProblem: sitePagesProblem(site),
+ };
+ }
+ const hubBuilt = await readBuiltStamp(paths, HUB_TARGET);
+ const homeBuilt = await readBuiltStamp(paths, HOMEPAGE_TARGET);
+ return {
+ index: { stamp, lastIngestDoneAt: null, configChangedAt: null },
+ sites,
+ hub: {
+ built: hubBuilt,
+ deployed: await readDeployedFile(paths, HUB_TARGET),
+ changedChannels: [],
+ configChangedAt: null,
+ bundleProblem: hubBuilt ? builtHubProblem(bundleDir(paths, HUB_TARGET)) : null,
+ pagesProblem: hubProjectProblem(getHomepageConfig(paths).cloudflareProject),
+ },
+ homepage: {
+ built: homeBuilt,
+ deployed: await readDeployedFile(paths, HOMEPAGE_TARGET),
+ changedChannels: [],
+ configChangedAt: null,
+ bundleProblem: homeBuilt ? builtHomepageProblem(homepageOutDir(paths)) : null,
+ mainHead: opts.mainHead ? await mainHeadOf(paths) : null,
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Stamping a build
+// ---------------------------------------------------------------------------
+
+async function stampBuilt(
+ paths: Paths,
+ outDir: string,
+ s: {
+ target: string;
+ kind: BuiltKind;
+ indexStampId: string | null;
+ inputSig: string;
+ runner: Runner;
+ archivesStaged: number;
+ sourceCommit?: string | null;
+ },
+): Promise<BuiltStamp> {
+ const { bundleCounts, corpusGeneratedAtIn } = await import("./build");
+ const { commit, branch } = await checkoutInfo(paths);
+ const counts = await bundleCounts(outDir);
+ const built: BuiltStamp = {
+ v: 1,
+ stampId: newStampId(),
+ target: s.target,
+ kind: s.kind,
+ indexStampId: s.indexStampId,
+ inputSig: s.inputSig,
+ builtAt: Date.now(),
+ commit,
+ branch,
+ runner: s.runner,
+ audience: builtAudienceProblem(outDir) ? "private" : "public",
+ corpusGeneratedAt: await corpusGeneratedAtIn(outDir),
+ files: counts.files,
+ bytes: counts.bytes,
+ archivesStaged: s.archivesStaged,
+ ...(s.sourceCommit !== undefined ? { sourceCommit: s.sourceCommit } : {}),
+ };
+ await writeBuiltStamp(paths, built);
+ return built;
+}
+
+function plural(n: number, word: string): string {
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
+}
+
+// ---------------------------------------------------------------------------
+// update-index
+// ---------------------------------------------------------------------------
+
+async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
+ const { paths, onLog, signal } = ctx;
+ const { settingsFromFile } = await import("../lib/settings");
+ const { applyHealthTimings } = await import("../lib/storageHealth");
+ const { buildIndex } = await import("../controller/buildIndex");
+ const { buildStats } = await import("../controller/buildStats");
+ const { syncTemplatesToExport } = await import("../lib/chartsStore");
+ const { hubInputSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig");
+
+ // A CLI process has no health pass: the drive-health timings the builds'
+ // watchdog runs on are applied here, once (bin/build-index.ts does the same).
+ applyHealthTimings(settingsFromFile(paths.settingsFile).storage.health);
+ const log = (m: string) => onLog(m.endsWith("\n") ? m : `${m}\n`);
+
+ onLog("=== update-index: the LMDB index ===\n");
+ const idx = await buildIndex({ paths, onLog: log });
+ checkCancel(signal);
+ onLog("=== update-index: the stats datasets ===\n");
+ const st = await buildStats({ paths, onLog: log, signal });
+ checkCancel(signal);
+ onLog("=== update-index: chart templates ===\n");
+ const sites = listSites(paths);
+ for (const site of sites) syncTemplatesToExport(paths, site.siteId);
+ const templatesAt = Date.now();
+ checkCancel(signal);
+
+ onLog("=== update-index: signatures ===\n");
+ const meta = readIndexMeta(
+ paths,
+ sites.map((s) => s.siteId),
+ );
+ const settings = getSettings();
+ const configs = await readMemberConfigs(paths, sites);
+ const cache = new Map<string, string>();
+ const siteEntries: IndexStamp["sites"] = {};
+ for (const site of sites) {
+ siteEntries[site.siteId] = {
+ siteFp: meta.siteFp[site.siteId] ?? null,
+ statsFp: meta.statsFp[site.siteId] ?? null,
+ inputSig: await siteInputSig({ paths, site, settings, configOf: (slug) => configs.get(slug), cache }),
+ };
+ }
+
+ // The same index as the last stamp (nothing rebuilt, every signature the
+ // same) keeps its stamp id, so the builds made from it stay current.
+ const prev = await readIndexStamp(paths);
+ let stampId = newStampId();
+ let reused = false;
+ if (
+ prev &&
+ idx.shortCircuited &&
+ st.shortCircuited &&
+ prev.generation === meta.generation &&
+ sameSites(prev.sites, siteEntries) &&
+ (await hubInputSig(paths, prev.stampId, sites)) === prev.hubSig
+ ) {
+ stampId = prev.stampId;
+ reused = true;
+ }
+ const { commit } = await checkoutInfo(paths);
+ const stamp: IndexStamp = {
+ v: 1,
+ stampId,
+ generation: meta.generation,
+ scannedAt: meta.scannedAt ?? Date.now(),
+ builtAt: Date.now(),
+ templatesAt,
+ commit,
+ index: {
+ shortCircuited: idx.shortCircuited,
+ added: idx.added,
+ changed: idx.changed,
+ removed: idx.removed,
+ heldChannels: idx.heldChannels,
+ },
+ stats: {
+ shortCircuited: st.shortCircuited,
+ notIndexedYet: st.notIndexedYet,
+ notIndexable: st.notIndexable,
+ },
+ sites: siteEntries,
+ hubSig: await hubInputSig(paths, stampId, sites),
+ };
+ const { writeIndexStamp } = await import("./stamps");
+ await writeIndexStamp(paths, stamp);
+ const summary =
+ `index +${idx.added} ~${idx.changed} -${idx.removed}` +
+ (idx.heldChannels.length ? ` (held: ${idx.heldChannels.join(", ")})` : "") +
+ `; ${plural(sites.length, "site")} signed` +
+ (reused ? "; nothing changed — the stamp stands" : "");
+ return { status: reused ? "noop" : "ran", stamp: stampId, summary };
+}
+
+function sameSites(a: IndexStamp["sites"], b: IndexStamp["sites"]): boolean {
+ const ka = Object.keys(a).sort();
+ const kb = Object.keys(b).sort();
+ if (ka.join("\n") !== kb.join("\n")) return false;
+ return ka.every((k) => a[k].inputSig === b[k].inputSig);
+}
+
+// ---------------------------------------------------------------------------
+// build-site (one site, every site locally, every site in containers)
+// ---------------------------------------------------------------------------
+
+// Inside the docker per-site build container (docker/build-site.sh sets
+// ARCHIVES_READONLY=1): the build stays in export/out for the container to hand
+// back, and the HOST stamps it — nothing is installed or stamped here.
+function inBuildContainer(): boolean {
+ return process.env.ARCHIVES_READONLY === "1";
+}
+
+// What a container build reads where the host wrote no stamp (the editor's
+// Build all before release 18's surfaces): nothing is stamped in a container.
+const CONTAINER_NO_STAMP: IndexStamp = {
+ v: 1,
+ stampId: "",
+ generation: 0,
+ scannedAt: 0,
+ builtAt: 0,
+ templatesAt: 0,
+ commit: null,
+ index: { shortCircuited: true, added: 0, changed: 0, removed: 0, heldChannels: [] },
+ stats: { shortCircuited: true, notIndexedYet: 0, notIndexable: 0 },
+ sites: {},
+ hubSig: "",
+};
+
+async function buildOneSite(ctx: StageContext, r: StageRequest, stamp: IndexStamp): Promise<StageOutcome> {
+ const { paths, onLog, signal } = ctx;
+ const { buildSiteBundle, bundleDir } = await import("./build");
+ const siteId = r.target;
+ const inPlace = inBuildContainer();
+ const res = await buildSiteBundle(siteId, {
+ paths,
+ onLog,
+ signal,
+ skipArchives: r.skipArchives,
+ allowMissingMedia: r.allowMissingMedia,
+ inPlace,
+ });
+ checkCancel(signal);
+ if (res.code !== 0) throw new StageFailure(`build of ${siteId} failed (exit ${res.code})`, 1);
+ if (inPlace) return { status: "ran", stamp: "", summary: `${siteId} built in place (build container)` };
+ const built = await stampBuilt(paths, bundleDir(paths, siteId), {
+ target: siteId,
+ kind: "site",
+ indexStampId: stamp.stampId,
+ inputSig: stamp.sites[siteId].inputSig,
+ runner: "local",
+ archivesStaged: res.archivesStaged,
+ });
+ return {
+ status: "ran",
+ stamp: built.stampId,
+ summary: `${siteId} built: ${plural(built.files, "file")}, ${(built.bytes / 1e6).toFixed(1)} MB`,
+ };
+}
+
+// The sites `_all` builds: each stale one (every one with --force).
+function sitesToBuild(input: NeedsInput, r: StageRequest, onLog: (l: string) => void): string[] {
+ const ids: string[] = [];
+ for (const id of Object.keys(input.sites)) {
+ const f = STAGES["build-site"].needs(input, { ...r, target: id });
+ if (f.state === "fresh") onLog(`[publish] ${id}: fresh — skipped\n`);
+ else if (f.state === "blocked") onLog(`[publish] ${id}: blocked — ${f.reason}\n`);
+ else ids.push(id);
+ }
+ return ids;
+}
+
+async function buildAllLocal(ctx: StageContext, r: StageRequest, input: NeedsInput): Promise<StageOutcome> {
+ const stamp = input.index.stamp!;
+ const ids = sitesToBuild(input, r, ctx.onLog);
+ const failed: string[] = [];
+ let built = 0;
+ for (const [i, id] of ids.entries()) {
+ checkCancel(ctx.signal);
+ ctx.onLog(`\n=== Build ${id} (${i + 1}/${ids.length}) ===\n`);
+ try {
+ await buildOneSite(ctx, { ...r, target: id }, stamp);
+ built++;
+ } catch (err) {
+ if (err instanceof StageCancelled) throw err;
+ failed.push(id);
+ ctx.onLog(`[publish] ${id}: ${(err as Error).message}\n`);
+ }
+ }
+ const summary = `${built}/${ids.length} built` + (failed.length ? `; failed: ${failed.join(", ")}` : "");
+ if (failed.length) throw new StageFailure(summary, 1);
+ return { status: built ? "ran" : "noop", stamp: stamp.stampId, summary };
+}
+
+export const NO_ENGINE = "the docker runner needs an engine on this host";
+
+async function buildAllDocker(ctx: StageContext, r: StageRequest, input: NeedsInput): Promise<StageOutcome> {
+ const { paths, onLog, signal } = ctx;
+ const b = await import("./build");
+ if (!(await b.dockerAvailable(signal))) throw new StageFailure(NO_ENGINE, 3);
+ const stamp = input.index.stamp!;
+ const ids = sitesToBuild(input, r, onLog);
+ if (ids.length === 0) return { status: "noop", stamp: stamp.stampId, summary: "every site is fresh" };
+ if (!r.skipArchives) {
+ onLog("=== the archive cache (host) ===\n");
+ const code = await b.runHostScript(onLog, signal, paths, "build:archives");
+ checkCancel(signal);
+ if (code !== 0) throw new StageFailure(`warming the archive cache failed (exit ${code})`, 1);
+ }
+ const img = await b.ensureBuildImage(onLog, signal, paths);
+ checkCancel(signal);
+ if (img !== 0) throw new StageFailure(`the build image failed (exit ${img})`, 1);
+ const { maxParallelBuilds } = getSettings().buildPipeline;
+ onLog(`=== building ${plural(ids.length, "site")} in containers, up to ${maxParallelBuilds} at once ===\n`);
+ const outcomes = await b.runWithConcurrency(ids, maxParallelBuilds, async (id) => {
+ if (signal.aborted) return { id, ok: false };
+ const code = await b.runDockerBuildOne(onLog, signal, id, paths, { skipArchives: r.skipArchives });
+ const out = b.bundleDir(paths, id);
+ const problem = code === 0 ? builtBundleProblem(out, id) : null;
+ if (code !== 0 || problem) {
+ onLog(`[${id}] build FAILED — ${problem ?? `exit ${code}`}\n`);
+ return { id, ok: false };
+ }
+ const staged = await readdir(b.dockerSiteStagingDir(paths, id)).catch(() => [] as string[]);
+ await stampBuilt(paths, out, {
+ target: id,
+ kind: "site",
+ indexStampId: stamp.stampId,
+ inputSig: stamp.sites[id].inputSig,
+ runner: "docker",
+ archivesStaged: staged.filter((f) => !f.startsWith(".")).length,
+ });
+ onLog(`[${id}] build ok\n`);
+ return { id, ok: true };
+ });
+ checkCancel(signal);
+ const failed = outcomes.filter((o) => !o.ok).map((o) => o.id);
+ const summary = `${ids.length - failed.length}/${ids.length} built in containers` + (failed.length ? `; failed: ${failed.join(", ")}` : "");
+ if (failed.length) throw new StageFailure(summary, 1);
+ return { status: "ran", stamp: stamp.stampId, summary };
+}
+
+// ---------------------------------------------------------------------------
+// hub and homepage builds
+// ---------------------------------------------------------------------------
+
+async function buildHubStage(ctx: StageContext, stamp: IndexStamp): Promise<StageOutcome> {
+ const { buildHubBundle, bundleDir } = await import("./build");
+ const code = await buildHubBundle(ctx);
+ checkCancel(ctx.signal);
+ if (code !== 0) throw new StageFailure(`the hub build failed (exit ${code})`, 1);
+ const built = await stampBuilt(ctx.paths, bundleDir(ctx.paths, HUB_TARGET), {
+ target: HUB_TARGET,
+ kind: "hub",
+ indexStampId: stamp.stampId,
+ inputSig: stamp.hubSig,
+ runner: "local",
+ archivesStaged: 0,
+ });
+ return { status: "ran", stamp: built.stampId, summary: `hub built: ${plural(built.files, "file")}` };
+}
+
+async function buildHomepageStage(ctx: StageContext, stamp: IndexStamp): Promise<StageOutcome> {
+ const { buildHomepage, homepageOutDir } = await import("./build");
+ const { readPublishedManifest } = await import("./source");
+ const code = await buildHomepage(ctx);
+ checkCancel(ctx.signal);
+ if (code !== 0) throw new StageFailure(`the homepage build failed (exit ${code})`, 1);
+ const out = homepageOutDir(ctx.paths);
+ const manifest = await readPublishedManifest(out);
+ const built = await stampBuilt(ctx.paths, out, {
+ target: HOMEPAGE_TARGET,
+ kind: "homepage",
+ indexStampId: stamp.stampId,
+ inputSig: stamp.stampId,
+ runner: "local",
+ archivesStaged: 0,
+ sourceCommit: manifest?.sourceCommit ?? null,
+ });
+ return { status: "ran", stamp: built.stampId, summary: `homepage built: ${plural(built.files, "file")}` };
+}
+
+// ---------------------------------------------------------------------------
+// deploys
+// ---------------------------------------------------------------------------
+
+/** Where `--to local` publishes a site: the `site` service's volume. */
+export function localSiteOut(env: NodeJS.ProcessEnv = process.env): string | null {
+ return env.ARCHILYZER_SITE_OUT?.trim() || null;
+}
+
+/** …and the homepage: `homepage` beside it (the `homepage` service's mount). */
+export function localHomepageOut(env: NodeJS.ProcessEnv = process.env): string | null {
+ const site = localSiteOut(env);
+ return site ? path.join(path.dirname(site), "homepage") : null;
+}
+
+// Replace the CONTENTS of `dest` (a volume mount: never the directory itself).
+async function publishLocal(src: string, dest: string): Promise<void> {
+ await mkdir(dest, { recursive: true });
+ for (const name of await readdir(dest)) await rm(path.join(dest, name), { recursive: true, force: true });
+ await cp(src, dest, { recursive: true });
+}
+
+// Spot the deployment URL build.ts logs on success.
+function urlWatcher(onLog: (l: string) => void): { onLog: (l: string) => void; url: () => string | null } {
+ let url: string | null = null;
+ return {
+ onLog: (line) => {
+ const m = /^\[deployed\] (\S+)/.exec(line) ?? /\(this deployment: (\S+)\)/.exec(line);
+ if (m) url = m[1];
+ onLog(line);
+ },
+ url: () => url,
+ };
+}
+
+async function deployStage(
+ ctx: StageContext,
+ r: StageRequest,
+ target: string,
+ ship: (o: { onLog: (l: string) => void; previewBranch?: string }) => Promise<void>,
+ local: { src: string; dest: string | null } | null,
+ project: string | null,
+): Promise<StageOutcome> {
+ const { paths, onLog, signal } = ctx;
+ const built = (await readBuiltStamp(paths, target))!;
+ const kind = deployKindOf(r);
+ let url: string | null = null;
+ let alias: string | undefined;
+ if (kind === "local") {
+ if (!local) throw new StageFailure(`${target} has no local target`, 2);
+ if (!local.dest) {
+ throw new StageFailure("--to local needs ARCHILYZER_SITE_OUT (the directory the docker `site` service serves)", 3);
+ }
+ // A bundle built private is never published, here either.
+ const priv = builtAudienceProblem(local.src);
+ if (priv) throw new StageFailure(`${priv}. Build ${target} again, then deploy.`, 3);
+ onLog(`[publish] copying ${local.src} -> ${local.dest}\n`);
+ await publishLocal(local.src, local.dest);
+ } else {
+ if (r.preview !== undefined) {
+ const problem = previewBranchProblem(r.preview);
+ if (problem) throw new StageFailure(problem, 2);
+ }
+ const w = urlWatcher(onLog);
+ try {
+ await ship({ onLog: w.onLog, previewBranch: r.preview });
+ } catch (err) {
+ checkCancel(signal);
+ throw new StageFailure((err as Error).message, 1);
+ }
+ checkCancel(signal);
+ url = w.url();
+ if (r.preview && project) alias = previewAliasUrl(project, r.preview);
+ }
+ const record: DeployRecord = {
+ builtStampId: built.stampId,
+ builtAt: built.builtAt,
+ kind,
+ ...(r.preview ? { branch: r.preview } : {}),
+ url,
+ ...(alias ? { alias } : {}),
+ at: Date.now(),
+ liveCheck: null,
+ };
+ await recordDeploy(paths, target, record);
+ const where = kind === "local" ? "local" : kind === "preview" ? `preview "${r.preview}"` : "production";
+ return { status: "ran", stamp: built.stampId, summary: `${target} deployed (${where})${url ? ` ${url}` : ""}` };
+}
+
+// ---------------------------------------------------------------------------
+// The dispatcher
+// ---------------------------------------------------------------------------
+
+/** Run the stage `r` names (the body of every Stage's `run`). */
+export async function runStageBody(ctx: StageContext, r: StageRequest): Promise<StageOutcome> {
+ const { paths } = ctx;
+ if (r.kind === "update-index") return runUpdateIndex(ctx);
+ // Usage before state: a bad preview name is said as such, whatever is built.
+ if (r.kind.startsWith("deploy-")) {
+ if (r.preview !== undefined) {
+ const problem = previewBranchProblem(r.preview);
+ if (problem) throw new StageFailure(problem, 2);
+ }
+ if (r.preview && r.to === "local") {
+ throw new StageFailure("--preview and --to local are two different deploys", 2);
+ }
+ }
+ // The docker per-site container builds what the host's fan-out decided to
+ // build: no stamps of its own to judge by (its builds dir is scratch).
+ if (r.kind === "build-site" && inBuildContainer()) {
+ return buildOneSite(ctx, r, (await readIndexStamp(paths)) ?? CONTAINER_NO_STAMP);
+ }
+
+ const input = await readNeedsInput(paths, { mainHead: r.kind === "build-homepage" });
+ const f = STAGES[r.kind].needs(input, r);
+ if (f.state === "blocked") throw new StageFailure(f.reason, 3);
+ if (f.state === "fresh") {
+ const stampOf =
+ r.kind.startsWith("deploy-") || r.target === ALL_TARGET
+ ? (input.index.stamp?.stampId ?? "")
+ : "";
+ const built =
+ r.kind === "build-site" || r.kind === "deploy-site"
+ ? input.sites[r.target]?.built
+ : r.kind.endsWith("-hub")
+ ? input.hub.built
+ : input.homepage.built;
+ return {
+ status: "noop",
+ stamp: built?.stampId ?? stampOf,
+ summary: `${r.target}: fresh — nothing to do (--force runs it anyway)`,
+ };
+ }
+ ctx.onLog(`[publish] ${STAGES[r.kind].label} ${r.target}: ${f.reason}\n`);
+ const stamp = input.index.stamp;
+
+ const b = await import("./build");
+ switch (r.kind) {
+ case "build-site":
+ if (r.target === ALL_TARGET) {
+ return r.runner === "docker" ? buildAllDocker(ctx, r, input) : buildAllLocal(ctx, r, input);
+ }
+ return buildOneSite(ctx, r, stamp!);
+ case "build-hub":
+ return buildHubStage(ctx, stamp!);
+ case "build-homepage":
+ return buildHomepageStage(ctx, stamp!);
+ case "deploy-site": {
+ const site = getSite(r.target, paths);
+ const out = b.bundleDir(paths, site.siteId);
+ return deployStage(
+ ctx,
+ r,
+ site.siteId,
+ (o) =>
+ b.deploySite(site.siteId, {
+ paths,
+ signal: ctx.signal,
+ onLog: o.onLog,
+ previewBranch: o.previewBranch,
+ outDir: out,
+ stagingDir: b.dockerSiteStagingDir(paths, site.siteId),
+ }),
+ { src: out, dest: localSiteOut() },
+ site.cloudflareProject?.trim() || null,
+ );
+ }
+ case "deploy-hub":
+ return deployStage(
+ ctx,
+ r,
+ HUB_TARGET,
+ (o) =>
+ b.deployHub({
+ paths,
+ signal: ctx.signal,
+ onLog: o.onLog,
+ previewBranch: o.previewBranch,
+ outDir: b.bundleDir(paths, HUB_TARGET),
+ }),
+ null,
+ getHomepageConfig(paths).cloudflareProject?.trim() || null,
+ );
+ case "deploy-homepage":
+ return deployStage(
+ ctx,
+ r,
+ HOMEPAGE_TARGET,
+ (o) => b.deployHomepage({ paths, signal: ctx.signal, onLog: o.onLog, previewBranch: o.previewBranch }),
+ { src: b.homepageOutDir(paths), dest: localHomepageOut() },
+ b.HOMEPAGE_PAGES_PROJECT,
+ );
+ }
+}
diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts
@@ -0,0 +1,158 @@
+// 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 `<exportBuildsDir>/.publish.lock` first.
+
+import path from "node:path";
+import { setKillChildTrees } from "../jobs/runChild";
+import { getPaths, type Paths } from "../lib/paths";
+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<string, string | undefined>;
+};
+
+/**
+ * The child the editor spawns for `req`: `<common>/node_modules/.bin/tsx
+ * bin/archilyzer.ts stage <kind> <target> [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<Paths, "monorepoRoot">,
+ req: StageRequest,
+ baseEnv: NodeJS.ProcessEnv = process.env,
+): StageCommand {
+ const commonDir = path.join(paths.monorepoRoot, "common");
+ const env: Record<string, string | undefined> = { ...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<StageRunResult> {
+ 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();
+ onLog(`[stage] ${name}: ${outcome.status === "noop" ? "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;
+ 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<number> {
+ const ac = new AbortController();
+ const onSignal = () => {
+ if (ac.signal.aborted) process.exit(STAGE_EXIT.cancelled);
+ ac.abort();
+ setTimeout(() => process.exit(STAGE_EXIT.cancelled), 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);
+ }
+}
diff --git a/common/publish/stages.ts b/common/publish/stages.ts
@@ -0,0 +1,383 @@
+// The publish stages (release 18) — the ONLY module that knows all of them.
+//
+// Publishing is seven independent, queueable stages driven by on-disk state
+// (publish/stamps.ts), like the ingest lanes: one index build shared by every
+// site build, then builds and deploys one at a time. Each stage is:
+//
+// needs(input, req) PURE: is the target fresh, stale (and why), or blocked?
+// argv(req) the child argv the editor spawns: ["stage", kind, target, …]
+// run(ctx, req) the body, in that child or in the CLI's own process
+//
+// `needs()` reads a `NeedsInput` — the minimal PublishStatus-shaped input
+// defined here. S3's status view (common/views/publishStatus.ts) satisfies it
+// from the stamps plus the job metas (`changedChannels`) and config mtimes; the
+// stage child builds one from disk alone (`readNeedsInput`, stageBodies.ts),
+// because ordering is enforced ON DISK: a stage whose precondition is not met
+// when it starts exits 3, whatever the queue believed when it enqueued it.
+//
+// Nothing here loads LMDB, next or the AWS SDK: the bodies are imported lazily.
+
+import type { Paths } from "../lib/paths";
+import {
+ ALL_TARGET,
+ HOMEPAGE_TARGET,
+ HUB_TARGET,
+ INDEX_TARGET,
+ deployRecordFor,
+ type BuiltStamp,
+ type DeployKind,
+ type DeployedFile,
+ type IndexStamp,
+} from "./stamps";
+
+export type StageKind =
+ | "update-index"
+ | "build-site"
+ | "deploy-site"
+ | "build-hub"
+ | "deploy-hub"
+ | "build-homepage"
+ | "deploy-homepage";
+
+export const STAGE_KINDS: readonly StageKind[] = [
+ "update-index",
+ "build-site",
+ "deploy-site",
+ "build-hub",
+ "deploy-hub",
+ "build-homepage",
+ "deploy-homepage",
+];
+
+export function isStageKind(v: unknown): v is StageKind {
+ return typeof v === "string" && (STAGE_KINDS as readonly string[]).includes(v);
+}
+
+export type StageRequest = {
+ kind: StageKind;
+ // "_index" | siteId | "_all" | "_hub" | "_homepage"
+ target: string;
+ runId: string;
+ preview?: string;
+ to?: "pages" | "local";
+ runner?: "local" | "docker";
+ force?: boolean;
+ skipArchives?: boolean;
+ // On-disk preconditions of a run: the index stamp (for a build) or the
+ // target's built stamp (for a deploy) must be at least this new (ms).
+ indexAfter?: number;
+ builtAfter?: number;
+ // `build site --allow-missing-media` (a report citation with no prepared
+ // media is let through compose). Not part of the plan's shape; optional.
+ allowMissingMedia?: boolean;
+};
+
+export type Freshness =
+ | { state: "fresh" }
+ | { state: "stale"; reason: string }
+ | { state: "blocked"; reason: string };
+
+export type StageOutcome = { status: "ran" | "noop"; stamp: string; summary: string };
+
+export type StageContext = {
+ paths: Paths;
+ onLog: (line: string) => void;
+ signal: AbortSignal;
+};
+
+export type Stage = {
+ kind: StageKind;
+ label: string;
+ jobKind: `publish-${StageKind}`;
+ queueKey: "publish";
+ needs(s: NeedsInput, r: StageRequest): Freshness;
+ argv(r: StageRequest): string[];
+ run(ctx: StageContext, r: StageRequest): Promise<StageOutcome>;
+};
+
+// ---------------------------------------------------------------------------
+// The input needs() reads (S3's PublishStatus satisfies it)
+// ---------------------------------------------------------------------------
+
+export type TargetState = {
+ built: BuiltStamp | null;
+ deployed: DeployedFile | null;
+ // Member channels (a site's, or every listed site's for the hub) with an
+ // ingest job ended `done` after `built.builtAt`.
+ changedChannels: string[];
+ // The newest mtime (ms) of a config file this target's build reads (its
+ // site.json, tags.json, search-aliases.json, duplicates*.json), or null.
+ configChangedAt: number | null;
+ // What is wrong with the bundle on disk (builtBundleProblem / builtHubProblem
+ // / builtHomepageProblem), or null. Only asked when `built` is set.
+ bundleProblem: string | null;
+ // Why it is never deployed anywhere (a private site), or null.
+ deployProblem?: string | null;
+ // Why it cannot go to Cloudflare Pages (no project), or null.
+ pagesProblem?: string | null;
+};
+
+export type NeedsInput = {
+ index: {
+ stamp: IndexStamp | null;
+ // When the newest drainable ingest job ended `done` (ms), or null.
+ lastIngestDoneAt: number | null;
+ // The newest mtime (ms) of an index input config file (tags.json,
+ // search-aliases.json, duplicates*.json, sites/*/site.json,
+ // homepage.json, the settings file, the charts config), or null.
+ configChangedAt: number | null;
+ };
+ sites: Record<string, TargetState>;
+ hub: TargetState;
+ homepage: TargetState & {
+ // `main`'s HEAD where a repository is reachable, else null.
+ mainHead: string | null;
+ };
+};
+
+// ---------------------------------------------------------------------------
+// needs()
+// ---------------------------------------------------------------------------
+
+const FRESH: Freshness = { state: "fresh" };
+const stale = (reason: string): Freshness => ({ state: "stale", reason });
+const blocked = (reason: string): Freshness => ({ state: "blocked", reason });
+
+export const UPDATE_INDEX_FIRST = "update the index first";
+
+/** The deploy kind a request names: --to local, --preview <b>, else production. */
+export function deployKindOf(r: Pick<StageRequest, "to" | "preview">): DeployKind {
+ if (r.to === "local") return "local";
+ return r.preview ? "preview" : "production";
+}
+
+function namesList(slugs: string[], max = 4): string {
+ const shown = slugs.slice(0, max).join(", ");
+ return slugs.length > max ? `${shown}, …` : shown;
+}
+
+function needsIndex(s: NeedsInput, r: StageRequest): Freshness {
+ const stamp = s.index.stamp;
+ if (!stamp) return stale("no index stamp yet");
+ if (r.force) return stale("forced");
+ if (s.index.lastIngestDoneAt !== null && s.index.lastIngestDoneAt > stamp.scannedAt) {
+ return stale("new data since the last index");
+ }
+ if (s.index.configChangedAt !== null && s.index.configChangedAt > stamp.scannedAt) {
+ return stale("a config file changed since the last index");
+ }
+ return FRESH;
+}
+
+// The stamp a build needs, or why it is blocked.
+function indexGate(s: NeedsInput, r: StageRequest): Freshness | IndexStamp {
+ const stamp = s.index.stamp;
+ if (!stamp) return blocked(UPDATE_INDEX_FIRST);
+ if (r.indexAfter !== undefined && stamp.builtAt < r.indexAfter) {
+ return blocked("waiting for the index update this run started");
+ }
+ return stamp;
+}
+
+// Stale reasons shared by the three builds, after the target's own signature.
+function builtStale(t: TargetState, sigMatches: boolean, sigReason: string): Freshness {
+ const built = t.built!;
+ if (t.changedChannels.length > 0) {
+ const n = t.changedChannels.length;
+ return stale(`${n} channel${n === 1 ? "" : "s"} changed (${namesList(t.changedChannels)})`);
+ }
+ if (t.configChangedAt !== null && t.configChangedAt > built.builtAt) return stale("config changed");
+ if (!sigMatches) return stale(sigReason);
+ if (t.bundleProblem) return stale(t.bundleProblem);
+ return FRESH;
+}
+
+function needsBuildSite(s: NeedsInput, r: StageRequest): Freshness {
+ const gate = indexGate(s, r);
+ if ("state" in gate) return gate;
+ if (r.target === ALL_TARGET) {
+ const ids = Object.keys(s.sites).sort();
+ const staleIds = ids.filter((id) => needsBuildSite(s, { ...r, target: id }).state !== "fresh");
+ if (staleIds.length === 0) return FRESH;
+ return stale(`${staleIds.length} of ${ids.length} sites to build (${namesList(staleIds)})`);
+ }
+ const t = s.sites[r.target];
+ if (!t) return blocked(`no site "${r.target}"`);
+ const entry = gate.sites[r.target];
+ if (!entry) return blocked(`the index has not seen site "${r.target}" — ${UPDATE_INDEX_FIRST}`);
+ if (r.force) return stale("forced");
+ if (!t.built) return stale("never built");
+ return builtStale(t, t.built.inputSig === entry.inputSig, "data changed");
+}
+
+function needsBuildHub(s: NeedsInput, r: StageRequest): Freshness {
+ const gate = indexGate(s, r);
+ if ("state" in gate) return gate;
+ if (r.force) return stale("forced");
+ if (!s.hub.built) return stale("never built");
+ return builtStale(s.hub, s.hub.built.inputSig === gate.hubSig, "the index or the pool it lists changed");
+}
+
+function needsBuildHomepage(s: NeedsInput, r: StageRequest): Freshness {
+ const gate = indexGate(s, r);
+ if ("state" in gate) return gate;
+ const h = s.homepage;
+ if (r.force) return stale("forced");
+ if (!h.built) return stale("never built");
+ if (h.built.indexStampId !== gate.stampId) return stale("the index was updated");
+ if (h.mainHead !== null && (h.built.sourceCommit ?? null) !== h.mainHead) {
+ return stale("main has moved since the source was published");
+ }
+ if (h.bundleProblem) return stale(h.bundleProblem);
+ return FRESH;
+}
+
+function needsDeploy(
+ t: TargetState | undefined,
+ name: string,
+ buildCmd: string,
+ r: StageRequest,
+): Freshness {
+ if (!t) return blocked(`no site "${name}"`);
+ const kind = deployKindOf(r);
+ const never = t.deployProblem ?? (kind === "local" ? null : (t.pagesProblem ?? null));
+ if (never) return blocked(never);
+ const built = t.built;
+ if (!built) return blocked(`no build of ${name} — ${buildCmd}`);
+ if (r.builtAfter !== undefined && built.builtAt < r.builtAfter) {
+ return blocked(`waiting for the build of ${name} this run started`);
+ }
+ if (t.bundleProblem) return blocked(t.bundleProblem);
+ if (kind === "production" && built.branch !== null && built.branch !== "main") {
+ return blocked(
+ `${name} was built from branch "${built.branch}"; production ships only a build of main (deploy it as a preview)`,
+ );
+ }
+ if (r.force) return stale("forced");
+ const rec = deployRecordFor(t.deployed, kind, r.preview);
+ if (!rec) return stale(kind === "preview" ? `never deployed to preview "${r.preview}"` : `never deployed (${kind})`);
+ if (rec.builtStampId !== built.stampId) return stale("a newer build is not deployed");
+ return FRESH;
+}
+
+// ---------------------------------------------------------------------------
+// argv (the child's command line) and its parser
+// ---------------------------------------------------------------------------
+
+export function stageArgv(r: StageRequest): string[] {
+ const out = ["stage", r.kind, r.target, "--run-id", r.runId];
+ if (r.preview) out.push("--preview", r.preview);
+ if (r.to) out.push("--to", r.to);
+ if (r.runner) out.push("--runner", r.runner);
+ if (r.force) out.push("--force");
+ if (r.skipArchives) out.push("--skip-archives");
+ if (r.allowMissingMedia) out.push("--allow-missing-media");
+ if (r.indexAfter !== undefined) out.push("--index-after", String(r.indexAfter));
+ if (r.builtAfter !== undefined) out.push("--built-after", String(r.builtAfter));
+ return out;
+}
+
+/** The flags the `stage` row accepts (archilyzer.ts), by kind. */
+export const STAGE_FLAGS = {
+ "run-id": "string",
+ preview: "string",
+ to: "string",
+ runner: "string",
+ force: "boolean",
+ "skip-archives": "boolean",
+ "allow-missing-media": "boolean",
+ "index-after": "string",
+ "built-after": "string",
+} as const;
+
+const TARGET_RE = /^(?:_index|_all|_hub|_homepage|[a-z0-9][a-z0-9-]*)$/;
+
+/** The default target of a kind that has only one. */
+export function fixedTarget(kind: StageKind): string | null {
+ if (kind === "update-index") return INDEX_TARGET;
+ if (kind === "build-hub" || kind === "deploy-hub") return HUB_TARGET;
+ if (kind === "build-homepage" || kind === "deploy-homepage") return HOMEPAGE_TARGET;
+ return null;
+}
+
+/**
+ * The StageRequest a `stage <kind> <target> [flags]` command line names, or
+ * the usage problem. The inverse of `stageArgv`.
+ */
+export function parseStageArgs(
+ positionals: string[],
+ flags: Record<string, string | boolean | undefined>,
+): StageRequest | { error: string } {
+ const [kind, target] = positionals;
+ if (!isStageKind(kind)) return { error: `stage: which stage? one of ${STAGE_KINDS.join(", ")}` };
+ if (!target || !TARGET_RE.test(target)) return { error: `stage ${kind}: which target?` };
+ const fixed = fixedTarget(kind);
+ if (fixed !== null && target !== fixed) return { error: `stage ${kind}: the target is ${fixed}` };
+ if (fixed === null && (target.startsWith("_") && !(kind === "build-site" && target === ALL_TARGET))) {
+ return { error: `stage ${kind}: the target is a site id` };
+ }
+ const runId = typeof flags["run-id"] === "string" ? flags["run-id"] : "";
+ if (!runId) return { error: `stage ${kind}: --run-id is required` };
+ const r: StageRequest = { kind, target, runId };
+ if (typeof flags.preview === "string") r.preview = flags.preview;
+ if (flags.to !== undefined) {
+ if (flags.to !== "pages" && flags.to !== "local") return { error: `stage ${kind}: --to is pages or local` };
+ r.to = flags.to;
+ }
+ if (flags.runner !== undefined) {
+ if (flags.runner !== "local" && flags.runner !== "docker") return { error: `stage ${kind}: --runner is local or docker` };
+ r.runner = flags.runner;
+ }
+ if (flags.force === true) r.force = true;
+ if (flags["skip-archives"] === true) r.skipArchives = true;
+ if (flags["allow-missing-media"] === true) r.allowMissingMedia = true;
+ for (const [flag, key] of [
+ ["index-after", "indexAfter"],
+ ["built-after", "builtAfter"],
+ ] as const) {
+ const v = flags[flag];
+ if (v === undefined) continue;
+ const n = typeof v === "string" ? Number(v) : NaN;
+ if (!Number.isFinite(n)) return { error: `stage ${kind}: --${flag} is a time in ms` };
+ r[key] = n;
+ }
+ if (r.preview && r.to === "local") return { error: `stage ${kind}: --preview and --to local are two different deploys` };
+ return r;
+}
+
+// ---------------------------------------------------------------------------
+// The table
+// ---------------------------------------------------------------------------
+
+function stage(
+ kind: StageKind,
+ label: string,
+ needs: (s: NeedsInput, r: StageRequest) => Freshness,
+): Stage {
+ return {
+ kind,
+ label,
+ jobKind: `publish-${kind}`,
+ queueKey: "publish",
+ needs,
+ argv: stageArgv,
+ run: async (ctx, r) => (await import("./stageBodies")).runStageBody(ctx, r),
+ };
+}
+
+export const STAGES: Record<StageKind, Stage> = {
+ "update-index": stage("update-index", "Update the index", needsIndex),
+ "build-site": stage("build-site", "Build site", needsBuildSite),
+ "deploy-site": stage("deploy-site", "Deploy site", (s, r) =>
+ needsDeploy(s.sites[r.target], r.target, `archilyzer publish build ${r.target}`, r)),
+ "build-hub": stage("build-hub", "Build hub", needsBuildHub),
+ "deploy-hub": stage("deploy-hub", "Deploy hub", (s, r) => needsDeploy(s.hub, "the hub", "archilyzer publish hub", r)),
+ "build-homepage": stage("build-homepage", "Build homepage", needsBuildHomepage),
+ "deploy-homepage": stage("deploy-homepage", "Deploy homepage", (s, r) =>
+ needsDeploy(s.homepage, "the homepage", "archilyzer publish homepage", r)),
+};
+
+/** The job kind a stage runs as on the editor's `publish` queue. */
+export function stageJobKind(kind: StageKind): `publish-${StageKind}` {
+ return STAGES[kind].jobKind;
+}