Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/bin/archilyzer.ts | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Acommon/bin/publish.ts | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/inputSig.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/stageBodies.ts | 693+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/stageRun.ts | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/stages.ts | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}