// THE DEPLOY STAGE (release 18): one body for `publish-deploy-site`, // `publish-deploy-hub` and `publish-deploy-homepage` (stageBodies.ts runs it), // whichever runner built the bundle. // // It ships `//out` (the homepage: `homepage/out`), // the bundle a build stage wrote and stamped `built.json`, and records what it // did in `deployed.json` beside it (publish/stamps.ts, S1's shapes and // writers). The stage's `needs()` (stages.ts needsDeploy) is asked BEFORE this // body and answers most preconditions first — no build, a run's `builtAfter` // (it alone: it knows a no-op build's `checkedAt`), a private site, no Pages // project, the production branch, a bundle that is not the target's, freshness. // This body asks them again as the last word before wrangler, with the same // exit codes. In order — and NOTHING is written to `deployed.json` unless every // step before the record succeeded: // // 1. the request (exit 2): a preview branch name Cloudflare keeps verbatim; // a local deploy has no branch; the hub has no local target; the hub's // and the homepage's targets are fixed // 2. the target's own refusals (exit 3): a private site (siteDeployProblem), a // missing Pages project, the hub's project (hubProjectProblem) // 3. the build: `built.json` must exist (exit 3, "no build of X in "), // and — unless forced — not be the one this slot already shipped (a no-op) // 4. PRODUCTION ships only a build of `main` (exit 3): a build from another // branch, or with no branch recorded, is refused (a preview is fine) // 5. the bundle guards over the bundle itself (exit 3): builtBundleProblem // (the site's own, by site.json AND corpus.json — the stricter twin of // builtSiteProblem, naming the directory), builtAudienceProblem, // builtScopeProblem; the hub's builtHubProblem; the homepage's // builtHomepageProblem + publishedSourceProblem // 6. `--to local`: the bundle is copied into ARCHILYZER_SITE_OUT (the // directory the compose `site` service serves; the homepage's is // ARCHILYZER_HOMEPAGE_OUT) after localDestProblem, and the stage records // `local` — no credential, no R2, no wrangler, no live check // 7. the credential preflight (exit 1): no CLOUDFLARE_API_TOKEN and no // wrangler OAuth login on disk → refused before wrangler // 8. a site's oversize archives to R2, from `/.r2-staging` // 9. the pinned wrangler (wranglerBin), `--branch main` or `--branch `; // Cloudflare refusing the credential reads as CLOUDFLARE_AUTH_REFUSED // 10. the live check (liveCheck.ts): a WARNING, never a failure // 11. the `deployed.json` record (recordDeploy: temp file + rename) // // A refusal or a failure THROWS a DeployStageError carrying the exit code the // stage contract names (1 refused/failed, 2 usage, 3 precondition not met — // the codes needs() gives the same refusals — 130 cancelled); its message is // the sentence the log already ends on, word for word, and stageRun.ts does // not print it again. import os from "node:os"; import path from "node:path"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { cp, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { runChildIntoLog } from "../jobs/runChild"; import { builtAudienceProblem, builtBundleProblem, builtHomepageProblem, builtHubProblem, builtScopeProblem, isTombstonePostsTree, siteDeployProblem, } from "../lib/builtExport"; import { getHomepageConfig } from "../lib/homepage"; import { CLOUDFLARE_AUTH_REFUSED, PRODUCTION_BRANCH, cloudflareCredentialProblem, deploymentUrlIn, pagesDeployArgs, previewAliasUrl, previewBranchProblem, wranglerAuthFailureIn, wranglerBin, wranglerOAuthConfigFiles, } from "../lib/pagesDeploy"; import type { Paths } from "../lib/paths"; import { PROJECT_URL } from "../lib/project"; import { getSite, type Site } from "../lib/site"; import { HOMEPAGE_PAGES_PROJECT, PREVIEW_SHARES_ARCHIVES_NOTICE, bundleDir, dockerSiteStagingDir, homepageOutDir, hubProjectProblem, runArchiveUploadIntoLog, } from "./build"; import { liveCheckLines, runLiveCheck, type LiveCheckDeps } from "./liveCheck"; import { HOMEPAGE_TARGET, HUB_TARGET, deployRecordFor, readBuiltStamp, type BuiltStamp, readDeployedFile, recordDeploy, targetDir, type DeployRecord, type LiveCheck, } from "./stamps"; // The stamp shapes and their readers/writers are S1's (publish/stamps.ts): // `built.json` through readBuiltStamp (strict: a malformed stamp is no build), // `deployed.json` through recordDeploy (atomic, every other slot kept). export type { BuiltStamp, DeployRecord, DeployedFile } from "./stamps"; export { HOMEPAGE_TARGET, HUB_TARGET } from "./stamps"; export type DeployStageKind = "deploy-site" | "deploy-hub" | "deploy-homepage"; export type DeployStageRequest = { kind: DeployStageKind; // A site id; "_hub"; "_homepage". target: string; runId?: string; preview?: string; to?: "pages" | "local"; force?: boolean; }; export type DeployStageContext = { paths: Paths; onLog: (line: string) => void; signal: AbortSignal; // Default process.env: the credentials, WRANGLER_BIN, ARCHILYZER_SITE_OUT, // ARCHILYZER_HOMEPAGE_OUT, E2E_LIVE_CHECK. env?: Record; // Where wrangler's OAuth login would be (default os.homedir()). home?: string; now?: () => Date; liveCheck?: LiveCheckDeps; // The R2 step (default runArchiveUploadIntoLog); the test's seam. uploadArchives?: (site: Site, stagingDir: string) => Promise; }; export type DeployStageOutcome = { status: "ran" | "noop"; stamp: string; summary: string }; /** A refused or failed deploy, with the stage contract's exit code. */ export class DeployStageError extends Error { constructor( message: string, readonly exitCode: 1 | 2 | 3 | 130, ) { super(message); this.name = "DeployStageError"; } } /** Where a target's stamps live: `//`. */ export function stampDirOf(paths: Pick, target: string): string { return targetDir(paths, target); } /** The bundle a deploy of `target` ships. */ export function bundleDirOf(paths: Paths, kind: DeployStageKind, target: string): string { return kind === "deploy-homepage" ? homepageOutDir(paths) : bundleDir(paths, target); } function readJson(file: string): unknown { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return undefined; } } // The pinned wrangler's version (common/node_modules/wrangler), or the // override's path when WRANGLER_BIN replaced it. function wranglerLabel(paths: Paths, env: Record): string | undefined { if (env.WRANGLER_BIN?.trim()) return `WRANGLER_BIN=${env.WRANGLER_BIN.trim()}`; const pkg = readJson(path.join(paths.monorepoRoot, "common", "node_modules", "wrangler", "package.json")) as | { version?: unknown } | undefined; return typeof pkg?.version === "string" ? pkg.version : undefined; } // The `generatedAt` the bundle's own corpus.json carries: what the live check // expects when the build stamp names none. function bundleGeneratedAt(outDir: string): string | null { const g = (readJson(path.join(outDir, "corpus.json")) as { generatedAt?: unknown } | undefined)?.generatedAt; return typeof g === "string" ? g : null; } // The hub bundle's tombstone paths worth probing: the posts manifest, and per // withdrawn channel its manifest and first page. function hubTombstoneProbes(outDir: string): string[] { const posts = path.join(outDir, "posts"); if (!existsSync(posts) || !isTombstonePostsTree(posts)) return []; const out = ["posts/manifest.json"]; let slugs: string[] = []; try { slugs = readdirSyncDirs(posts); } catch { slugs = []; } for (const slug of slugs) { out.push(`posts/${slug}/manifest.json`); if (existsSync(path.join(posts, slug, "page-0000.json"))) out.push(`posts/${slug}/page-0000.json`); } return out; } function readdirSyncDirs(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) .sort(); } // A path with its symlinks resolved: realpath of its nearest existing // ancestor, the rest appended (the destination may not exist yet). async function resolvedPath(p: string): Promise { let head = path.resolve(p); const tail: string[] = []; for (;;) { try { return path.join(await realpath(head), ...tail.reverse()); } catch { const parent = path.dirname(head); if (parent === head) return path.resolve(p); tail.push(path.basename(head)); head = parent; } } } // Why `dest` may not be emptied and filled with `outDir`, or null. The local // copy EMPTIES its destination, and the stage runs on hosts as well as in the // container, so a mis-set ARCHILYZER_SITE_OUT (a home dir, the repo, a data // volume, `export/out` — a link to the last bundle) must not be wiped. With // every symlink resolved on both sides, the destination may not CONTAIN the // checkout, the corpus, the builds, export/ or the bundle, nor lie INSIDE the // corpus, the builds, export/ or the bundle; and a non-empty destination must // look like a bundle this copy made (an `index.html` at its top — the // container's placeholder page has one too). export async function localDestProblem( dest: string, outDir: string, paths: Pick, ): Promise { const d = await resolvedPath(dest); const inside = (parent: string, child: string) => { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); }; const protectedRoots: [string, string, boolean][] = [ // [what, dir, may the destination lie inside it?] ["the checkout", paths.monorepoRoot, true], ["the corpus", paths.transcriptsDir, false], ["the builds directory", paths.exportBuildsDir, false], ["export/", paths.exportDir, false], ["the bundle", outDir, false], ]; for (const [what, dir, insideOk] of protectedRoots) { const root = await resolvedPath(dir); if (inside(d, root)) { return `${dest} holds ${what} (${root}) — a local deploy empties its destination; set it to the directory the local server serves.`; } if (!insideOk && inside(root, d)) { return `${dest} is inside ${what} (${d}) — a local deploy empties its destination; set it to the directory the local server serves.`; } } let entries: string[]; try { entries = await readdir(d); } catch { return null; // absent: it is made } if (entries.length > 0 && !entries.includes("index.html")) { return `${d} is not empty and holds no index.html, so it is not a bundle a local deploy made — a local deploy empties its destination; empty it by hand or point the variable elsewhere.`; } return null; } // Copy `outDir`'s contents into `dest`, emptying it first — its CONTENTS, // never the directory: in the container it is a volume mount point. // localDestProblem is asked first. async function copyToLocal(outDir: string, dest: string): Promise { await mkdir(dest, { recursive: true }); for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true }); await cp(outDir, dest, { recursive: true, verbatimSymlinks: true }); let files = 0; const walk = async (d: string) => { for (const e of await readdir(d, { withFileTypes: true })) { if (e.isDirectory()) await walk(path.join(d, e.name)); else files++; } }; await walk(dest); return files; } /** * Run one deploy stage. Returns `ran` (deployed and recorded) or `noop` (this * slot already holds this build); THROWS a DeployStageError on every refusal * and failure, leaving `deployed.json` untouched. */ /** What a deploy request resolves to, once its own refusals are answered. */ export type ResolvedDeploy = { target: string; toLocal: boolean; branch: string | undefined; recordKind: DeployRecord["kind"]; site: Site | null; project: string; publicUrl: string | undefined; cwd: string; stampDir: string; outDir: string; built: BuiltStamp; }; /** The sentence a deploy request is refused with, and its exit code. */ export type DeployRequestProblem = { problem: string; exitCode: 2 | 3 }; /** * Steps 1–3 of the deploy stage (the request, the target's own refusals, a * build to ship), answered without touching anything. The stage body asks it * first; the editor's actions and the ops route ask it BEFORE a job exists, so * a refusal is the same sentence, word for word, either way. A site id that is * not one throws (getSite), as the stage always has. */ export async function resolveDeployRequest( paths: Paths, req: DeployStageRequest, ): Promise { // --- 1. the request (a refusal here is a usage error, exit 2) --- const target = req.target.trim(); const fail = (problem: string, exitCode: 2 | 3): DeployRequestProblem => ({ problem, exitCode }); // The hub's and the homepage's targets are fixed, and a site's is never one // of them: a mismatch would read and WRITE another target's stamps (a // homepage deploy recorded in a site's production slot). const fixed = req.kind === "deploy-hub" ? HUB_TARGET : req.kind === "deploy-homepage" ? HOMEPAGE_TARGET : null; if (fixed !== null && target !== fixed) { return fail(`${req.kind} deploys "${fixed}", not "${target}".`, 2); } if (fixed === null && (target === HUB_TARGET || target === HOMEPAGE_TARGET)) { return fail(`"${target}" is not a site — deploy it with ${target === HUB_TARGET ? "deploy-hub" : "deploy-homepage"}.`, 2); } const toLocal = req.to === "local"; if (req.preview !== undefined) { const problem = previewBranchProblem(req.preview); if (problem) return fail(problem, 2); } const branch = req.preview?.trim() || undefined; if (toLocal && branch) return fail("a local deploy has no preview branch — deploy locally or as a preview, not both.", 2); if (toLocal && req.kind === "deploy-hub") return fail("the hub has no local target — deploy it to Cloudflare Pages.", 2); const recordKind: DeployRecord["kind"] = toLocal ? "local" : branch ? "preview" : "production"; // --- 2. the target's own refusals --- let site: Site | null = null; let project: string; let publicUrl: string | undefined; let cwd = paths.exportDir; if (req.kind === "deploy-site") { site = getSite(target, paths); const privateProblem = siteDeployProblem(site); if (privateProblem) return fail(`${privateProblem}.`, 3); project = site.cloudflareProject?.trim() ?? ""; if (!project && !toLocal) return fail(`Site "${target}" has no Cloudflare Pages project configured.`, 3); publicUrl = site.siteUrl?.trim() || undefined; } else if (req.kind === "deploy-hub") { const hub = getHomepageConfig(paths); const problem = hubProjectProblem(hub.cloudflareProject); if (problem) return fail(problem, 3); project = hub.cloudflareProject!.trim(); publicUrl = hub.siteUrl; } else { project = HOMEPAGE_PAGES_PROJECT; publicUrl = PROJECT_URL; cwd = path.join(paths.monorepoRoot, "homepage"); } // --- 3. the build --- const stampDir = stampDirOf(paths, target); const outDir = bundleDirOf(paths, req.kind, target); const built = await readBuiltStamp(paths, target); const cliTarget = req.kind === "deploy-hub" ? "hub" : req.kind === "deploy-homepage" ? "homepage" : target; if (!built) { return fail( `no build of ${target} in ${stampDir} — archilyzer publish ${req.kind === "deploy-site" ? `build ${cliTarget}` : cliTarget}`, 3, ); } return { target, toLocal, branch, recordKind, site, project, publicUrl, cwd, stampDir, outDir, built }; } export async function runDeployStage( ctx: DeployStageContext, req: DeployStageRequest, ): Promise { const { paths, signal } = ctx; const env = ctx.env ?? process.env; const now = ctx.now ?? (() => new Date()); const log = (line: string) => ctx.onLog(line.endsWith("\n") ? line : `${line}\n`); const refuse = (why: string, exitCode: 1 | 2 | 3 = 1): never => { const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`; log(line); throw new DeployStageError(line, exitCode); }; const resolved = await resolveDeployRequest(paths, req); if ("problem" in resolved) refuse(resolved.problem, resolved.exitCode); const { target, toLocal, branch, recordKind, site, project, publicUrl, cwd, outDir } = resolved as ResolvedDeploy; const b = (resolved as ResolvedDeploy).built; // (A run's `builtAfter` is the stage's needs() — stages.ts needsDeploy — asked // before this body runs: it knows a no-op build's `checkedAt`.) const slot = deployRecordFor(await readDeployedFile(paths, target), recordKind, branch); if (!req.force && slot?.builtStampId === b.stampId) { const where = recordKind === "preview" ? `preview "${branch}"` : recordKind; const summary = `${target}: build ${b.stampId} is already deployed (${where}, ${new Date(slot.at).toISOString()}).`; log(`[deploy] ${summary} Nothing to do.`); return { status: "noop", stamp: b.stampId, summary }; } // --- 4. production ships only a build of main (a build with no branch // recorded — a detached HEAD, an image built without ARCHILYZER_BRANCH — is // refused the same way: S1's rule, stages.ts needsDeploy) --- if (recordKind === "production" && b.branch !== PRODUCTION_BRANCH) { refuse( (b.branch === null ? `the build of ${target} has no branch recorded (a detached HEAD, or an image built without ARCHILYZER_BRANCH)` : `the build of ${target} was made from branch "${b.branch}", not ${PRODUCTION_BRANCH}`) + `: production ships only a build of ${PRODUCTION_BRANCH}. Build it from ${PRODUCTION_BRANCH}, or deploy this one as a preview.`, 3, ); } // --- 5. the bundle guards --- if (req.kind === "deploy-site") { const problem = builtBundleProblem(outDir, target) ?? builtAudienceProblem(outDir) ?? builtScopeProblem(site!, outDir); if (problem) { refuse(`${problem}. Nothing was sent to Cloudflare Pages; build ${target} again, then deploy.`, 3); } } else if (req.kind === "deploy-hub") { const problem = builtHubProblem(outDir); if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`, 3); } else { const problem = builtHomepageProblem(outDir) ?? (await (await import("./source")).publishedSourceProblem(paths, outDir)); if (problem) refuse(problem, 3); } const builtAt = b.builtAt; const at = () => now().getTime(); const record = async (r: DeployRecord): Promise => { await recordDeploy(paths, target, r); }; // --- 6. --to local --- if (toLocal) { const dest = (req.kind === "deploy-homepage" ? env.ARCHILYZER_HOMEPAGE_OUT : env.ARCHILYZER_SITE_OUT)?.trim() ?? ""; if (!dest) { refuse( req.kind === "deploy-homepage" ? "--to local needs ARCHILYZER_HOMEPAGE_OUT — the directory the local homepage service serves (the container sets it)." : "--to local needs ARCHILYZER_SITE_OUT — the directory the local site service serves (the container sets it).", 3, ); } const destProblem = await localDestProblem(dest, outDir, paths); if (destProblem) refuse(destProblem); log(`[deploy] ${target} → ${dest} (local)`); const files = await copyToLocal(outDir, dest); if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); await record({ builtStampId: b.stampId, builtAt, kind: "local", url: null, at: at(), liveCheck: null }); const summary = `${target}: build ${b.stampId} copied to ${dest} (${files} files).`; log(`[deployed] ${summary}`); return { status: "ran", stamp: b.stampId, summary }; } // --- 7. the credential preflight --- const home = ctx.home ?? os.homedir(); const oauth = wranglerOAuthConfigFiles(home, env).some((f) => existsSync(f)); const credProblem = cloudflareCredentialProblem(env, oauth); if (credProblem) refuse(`${credProblem}.`); // --- 8. the archives, before the pages that link them --- if (branch) { log(`=== Deploy ${target} (preview "${branch}") ===`); if (site) log(PREVIEW_SHARES_ARCHIVES_NOTICE); } if (site) { const staging = dockerSiteStagingDir(paths, target); const upload = ctx.uploadArchives ?? ((s: Site, dir: string) => runArchiveUploadIntoLog(ctx.onLog, signal, s, paths, dir)); const code = await upload(site, staging); if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); if (code !== 0) { const line = `[deploy] FAILED — the archive R2 upload exited ${code}; nothing was sent to Cloudflare Pages.`; log(line); throw new DeployStageError(line, 1); } } // --- 9. wrangler --- let deploymentUrl: string | null = null; let authRefused = false; const watch = (line: string) => { if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project); if (!authRefused && wranglerAuthFailureIn(line)) authRefused = true; // Through log(): runChildIntoLog hands lines without their newline, and a // stage child writing raw to stdout would run wrangler's output together. log(line); }; const code = await runChildIntoLog(watch, signal, { command: wranglerBin(paths, env), args: pagesDeployArgs({ outDir, project, previewBranch: branch }), cwd, env: { ...env, NODE_ENV: "production" }, }); if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); if (code !== 0) { if (authRefused) { log(CLOUDFLARE_AUTH_REFUSED); throw new DeployStageError(CLOUDFLARE_AUTH_REFUSED, 1); } const line = `[deploy] FAILED — wrangler exited ${code}.`; log(line); throw new DeployStageError(line, 1); } const alias = branch ? previewAliasUrl(project, branch) : undefined; const shipped: string | null = deploymentUrl; if (alias) log(`[preview] ${alias}${shipped ? ` (this deployment: ${shipped})` : ""}`); else if (shipped) log(`[deployed] ${shipped}`); // --- 10. the live check --- const checkUrl = (branch ? alias : publicUrl) ?? shipped; let liveCheck: LiveCheck | null = null; if (checkUrl) { liveCheck = await runLiveCheck( { url: checkUrl, builtStampId: b.stampId, expected: req.kind === "deploy-homepage" ? null : (b.corpusGeneratedAt ?? bundleGeneratedAt(outDir)), path: req.kind === "deploy-homepage" ? "" : "corpus.json", tombstones: req.kind === "deploy-hub" ? hubTombstoneProbes(outDir) : undefined, }, { env, signal, ...ctx.liveCheck }, ); // Cancelled while checking: the deploy itself happened, so it is recorded — // with no live check — and the stage ends cancelled. if (signal.aborted) liveCheck = null; else for (const line of liveCheckLines(liveCheck)) log(line); } else { log("[live] no URL to check: the site has no public URL and wrangler printed no deployment URL."); } // --- 11. the record --- await record({ builtStampId: b.stampId, builtAt, kind: recordKind, ...(branch ? { branch } : {}), url: shipped, ...(alias ? { alias } : {}), at: at(), ...(wranglerLabel(paths, env) ? { wrangler: wranglerLabel(paths, env) } : {}), liveCheck, }); if (signal.aborted) { const line = `[deploy] cancelled during the live check — ${target} was deployed and is recorded without one.`; log(line); throw new DeployStageError(line, 130); } const verdict = liveCheck ? ` — live check: ${liveCheck.verdict}` : ""; const summary = `${target}: build ${b.stampId} deployed to ${recordKind === "preview" ? `preview "${branch}"` : "production"}` + ` (${project})${verdict}.`; return { status: "ran", stamp: b.stampId, summary }; }