Archilyzer · Source

archilyzer

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

commit ca5bbaa634a3bde79587e24ef242b63ea4cde67c
parent bdd81e51122910799013d6e56a026c6c7fc59ed6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 10:03:15 -0400

publish: the deploy stages are runDeployStage; S1's stamp types and readers

- stageBodies.ts: deploy-site, deploy-hub and deploy-homepage run
  deployStage.ts's runDeployStage end to end (bundle guards, credential
  preflight, R2, the pinned wrangler, the live check, deployed.json, and
  --to local). S1's interim deploy wrapper (the build.ts deploy calls, its
  own local copy and URL watcher) is gone: one implementation.
- stageRun.ts: a DeployStageError's exit code is the stage's; its sentence,
  already logged by the stage, is not said again.
- deployStage.ts / liveCheck.ts: BuiltStamp, DeployRecord, DeployedFile,
  LiveCheck and Probe are S1's (stamps.ts) — times are ms (builtAt, at),
  `age` a number; built.json through S1's strict readBuiltStamp,
  deployed.json through recordDeploy. Production refuses a build with no
  branch recorded too (S1's rule). builtAfter is needs()'s alone (it knows
  checkedAt). "--to local needs ARCHILYZER_SITE_OUT/_HOMEPAGE_OUT" is a
  precondition (exit 3), S1's sentence.
- stageRun.test.ts: the local deploy's destination is seeded as a bundle an
  earlier local deploy left (an index.html), as the stage now requires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/publish/deployStage.test.ts | 86++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcommon/publish/deployStage.ts | 155+++++++++++++++++++++----------------------------------------------------------
Mcommon/publish/liveCheck.test.ts | 6+++---
Mcommon/publish/liveCheck.ts | 33++++++++-------------------------
Mcommon/publish/stageBodies.ts | 159++++++++++---------------------------------------------------------------------
Mcommon/publish/stageRun.test.ts | 3+++
Mcommon/publish/stageRun.ts | 12++++++++++++
7 files changed, 141 insertions(+), 313 deletions(-)

diff --git a/common/publish/deployStage.test.ts b/common/publish/deployStage.test.ts @@ -14,9 +14,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { getPaths, type Paths } from "../lib/paths"; import { CLOUDFLARE_AUTH_REFUSED } from "../lib/pagesDeploy"; +import { readDeployedFile as readDeployed, type DeployedFile } from "./stamps"; import { DeployStageError, - readDeployedFile, runDeployStage, type BuiltStamp, type DeployStageContext, @@ -79,6 +79,27 @@ function site(fx: Fixture, siteId: string, extra: Record<string, unknown> = {}) }); } +// Every field S1's strict built.json reader asks for. +function stampFields(target: string, kind: BuiltStamp["kind"]): BuiltStamp { + return { + v: 1, + stampId: `built-${target}-1`, + target, + kind, + indexStampId: "idx-1", + inputSig: "sig", + builtAt: Date.parse("2026-10-06T09:30:00.000Z"), + commit: null, + branch: "main", + runner: "local", + audience: "public", + corpusGeneratedAt: GENERATED, + files: 3, + bytes: 100, + archivesStaged: 0, + }; +} + // A built bundle under <builds>/<target>/out and its built.json. function built( fx: Fixture, @@ -99,9 +120,9 @@ function built( kind: "site", indexStampId: "idx-1", inputSig: "sig", - builtAt: "2026-10-06T09:30:00.000Z", + builtAt: Date.parse("2026-10-06T09:30:00.000Z"), commit: null, - branch: null, + branch: "main", runner: "local", audience: "public", corpusGeneratedAt: GENERATED, @@ -170,6 +191,13 @@ function deployedBytes(fx: Fixture, target: string): string | null { return existsSync(file) ? readFileSync(file, "utf8") : null; } +// S1's reader: deployed.json, or null when there is none (or it is malformed). +async function deployedOf(fx: Fixture, target: string): Promise<DeployedFile> { + const f = await readDeployed(fx.paths, target); + assert.ok(f, `no deployed.json for ${target}`); + return f; +} + async function refused( p: Promise<unknown>, exitCode: number, @@ -208,7 +236,7 @@ test("a preview deploy runs the pinned binary with --branch <b>, checks the alia `https://r18.anilyzer-proj.pages.dev/corpus.json?cb=${stamp.stampId}`, ]); - const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer"); + const rec = (await deployedOf(fx, "anilyzer")); assert.equal(rec.v, 1); assert.equal(rec.production, undefined); const p = rec.previews.r18; @@ -218,7 +246,7 @@ test("a preview deploy runs the pinned binary with --branch <b>, checks the alia assert.equal(p.branch, "r18"); assert.equal(p.alias, "https://r18.anilyzer-proj.pages.dev"); assert.equal(p.url, "https://r18.anilyzer-proj.pages.dev"); - assert.equal(p.at, "2026-10-06T10:00:00.000Z"); + assert.equal(p.at, Date.parse("2026-10-06T10:00:00.000Z")); assert.match(p.wrangler ?? "", /^WRANGLER_BIN=/); assert.equal(p.liveCheck?.verdict, "ok"); assert.equal(p.liveCheck?.expected, GENERATED); @@ -239,17 +267,17 @@ test("production is --branch main, checked at the site's public URL, recorded be const fx = fixture(); try { site(fx, "jeralyzer"); - built(fx, "jeralyzer", { stamp: { branch: "main" } }); + built(fx, "jeralyzer"); writeJson(path.join(fx.paths.exportBuildsDir, "jeralyzer", "deployed.json"), { v: 1, target: "jeralyzer", - previews: { old: { builtStampId: "x", builtAt: "t", kind: "preview", branch: "old", url: null, at: "t", liveCheck: null } }, + previews: { old: { builtStampId: "x", builtAt: 1, kind: "preview", branch: "old", url: null, at: 1, liveCheck: null } }, }); const c = ctx(fx, TOKEN); await runDeployStage(c, { kind: "deploy-site", target: "jeralyzer" }); assert.deepEqual(sidecar(fx, "jeralyzer")[0].argv.slice(-2), ["--branch", "main"]); assert.equal(c.asked[0], "https://jeralyzer.example.test/corpus.json"); - const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "jeralyzer"), "jeralyzer"); + const rec = (await deployedOf(fx, "jeralyzer")); assert.equal(rec.production?.kind, "production"); assert.equal(rec.production?.branch, undefined); assert.equal(rec.production?.url, "https://main.jeralyzer-proj.pages.dev"); @@ -279,9 +307,8 @@ test("every refusal leaves deployed.json untouched, and wrangler unspawned", asy ["a private site", { kind: "deploy-site", target: "mine" }, TOKEN, 1, /is private \(audience: private\)/], ["no Pages project", { kind: "deploy-site", target: "noproj" }, TOKEN, 1, /no Cloudflare Pages project configured/], ["never built", { kind: "deploy-site", target: "nobuild" }, TOKEN, 3, /no build of nobuild in .*builds\/nobuild — archilyzer publish build nobuild/], - ["a build older than the run", { kind: "deploy-site", target: "anilyzer", builtAfter: Date.parse("2026-10-06T09:45:00.000Z") }, TOKEN, 3, /has not finished/], ["local and preview at once", { kind: "deploy-site", target: "anilyzer", to: "local", preview: "p" }, TOKEN, 2, /a local deploy has no preview branch/], - ["local with nowhere to copy", { kind: "deploy-site", target: "anilyzer", to: "local" }, TOKEN, 1, /needs ARCHILYZER_SITE_OUT/], + ["local with nowhere to copy", { kind: "deploy-site", target: "anilyzer", to: "local" }, TOKEN, 3, /^\[deploy\] REFUSED — --to local needs ARCHILYZER_SITE_OUT/], ]; site(fx, "nobuild"); for (const [name, req, env, code, why] of cases) { @@ -340,6 +367,15 @@ test("production ships only a build of main; the same build may go to a preview" ); assert.equal(deployedBytes(fx, "anilyzer"), null); assert.equal((await runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer", preview: "feat" })).status, "ran"); + // No branch recorded (a detached HEAD, an image built without + // ARCHILYZER_BRANCH) is refused for production the same way (S1's rule). + site(fx, "jasolyzer"); + built(fx, "jasolyzer", { stamp: { branch: null } }); + await refused( + runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "jasolyzer" }), + 1, + /has no branch recorded .*production ships only a build of main/, + ); } finally { fx.cleanup(); } @@ -386,7 +422,7 @@ test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replac assert.deepEqual(sidecar(fx, "anilyzer"), [], "no wrangler"); assert.deepEqual(c.uploads, [], "no R2"); assert.deepEqual(c.asked, [], "no live check"); - const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer"); + const rec = (await deployedOf(fx, "anilyzer")); assert.equal(rec.local?.builtStampId, stamp.stampId); assert.equal(rec.local?.kind, "local"); assert.equal(rec.local?.liveCheck, null); @@ -495,7 +531,7 @@ test("Cancel during the live check: the deploy is recorded without a check, and }, }); await refused(runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), 130, /cancelled during the live check/); - const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer"); + const rec = (await deployedOf(fx, "anilyzer")); assert.equal(rec.production?.builtStampId, stamp.stampId); assert.equal(rec.production?.liveCheck, null); } finally { @@ -510,12 +546,8 @@ test("the homepage's local deploy copies homepage/out into ARCHILYZER_HOMEPAGE_O mkdirSync(out, { recursive: true }); writeFileSync(path.join(out, "index.html"), "<!doctype html>"); writeJson(path.join(fx.paths.exportBuildsDir, "_homepage", "built.json"), { - v: 1, + ...stampFields("_homepage", "homepage"), stampId: "home-1", - target: "_homepage", - kind: "homepage", - builtAt: "2026-10-06T09:30:00.000Z", - branch: "main", corpusGeneratedAt: null, }); const req: DeployStageRequest = { kind: "deploy-homepage", target: "_homepage", to: "local" }; @@ -533,15 +565,15 @@ test("the homepage's local deploy copies homepage/out into ARCHILYZER_HOMEPAGE_O // Only the SITE's directory set: the homepage is not copied beside it. await refused( runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: path.join(fx.root, "site-out") }), req), - 1, - /a local homepage deploy needs ARCHILYZER_HOMEPAGE_OUT/, + 3, + /--to local needs ARCHILYZER_HOMEPAGE_OUT/, ); assert.equal(existsSync(dest), false); assert.equal(deployedBytes(fx, "_homepage"), null); const res = await runDeployStage(ctx(fx, { ARCHILYZER_HOMEPAGE_OUT: dest }), req); assert.equal(res.status, "ran"); assert.deepEqual(readdirSync(dest).sort(), ["index.html", "source"]); - assert.equal(readDeployedFile(path.join(fx.paths.exportBuildsDir, "_homepage"), "_homepage").local?.builtStampId, "home-1"); + assert.equal((await deployedOf(fx, "_homepage")).local?.builtStampId, "home-1"); } finally { fx.cleanup(); } @@ -561,15 +593,7 @@ test("the hub: its project, its bundle, and every tombstone probed plain and bus writeJson(path.join(out, "posts", "manifest.json"), { channels: [], totalCount: 0 }); writeJson(path.join(out, "posts", "thequartering-X", "manifest.json"), { pageCount: 0, slugToPage: {} }); writeJson(path.join(out, "posts", "thequartering-X", "page-0000.json"), []); - writeJson(path.join(dir, "built.json"), { - v: 1, - stampId: "hub-1", - target: "_hub", - kind: "hub", - builtAt: "2026-10-06T09:30:00.000Z", - branch: "main", - corpusGeneratedAt: GENERATED, - }); + writeJson(path.join(dir, "built.json"), { ...stampFields("_hub", "hub"), stampId: "hub-1" }); const c = ctx(fx, TOKEN); const res = await runDeployStage(c, { kind: "deploy-hub", target: "_hub" }); assert.equal(res.status, "ran"); @@ -585,7 +609,7 @@ test("the hub: its project, its bundle, and every tombstone probed plain and bus assert.ok(c.asked.includes(`https://archilyzer-hub.pages.dev/${rel}`), rel); assert.ok(c.asked.includes(`https://archilyzer-hub.pages.dev/${rel}?cb=hub-1`), `${rel} busted`); } - const rec = readDeployedFile(dir, "_hub"); + const rec = await deployedOf(fx, "_hub"); assert.equal(rec.production?.liveCheck?.verdict, "ok"); assert.equal(rec.production?.liveCheck?.tombstones?.length, 3); assert.ok(c.lines.some((l) => /3 withdrawn path\(s\) read as tombstones/.test(l)), c.lines.join("")); @@ -623,7 +647,7 @@ test("a stale edge is a WARNING: the deploy is recorded with its verdict and the assert.equal(res.status, "ran"); assert.match(res.summary, /live check: stale-edge\.$/); assert.ok(c.lines.some((l) => l.startsWith("[live] WARNING stale-edge"))); - const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer"); + const rec = (await deployedOf(fx, "anilyzer")); assert.equal(rec.production?.liveCheck?.verdict, "stale-edge"); assert.equal(rec.production?.liveCheck?.plain.cfCacheStatus, "HIT"); } finally { diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts @@ -46,7 +46,7 @@ import os from "node:os"; import path from "node:path"; import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { cp, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, readdir, rm } from "node:fs/promises"; import { runChildIntoLog } from "../jobs/runChild"; import { builtAudienceProblem, @@ -76,55 +76,30 @@ 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 LiveCheck, type LiveCheckDeps } from "./liveCheck"; - -// --------------------------------------------------------------------------- -// The stamp shapes (release 18 "Model"; S1's stamps.ts owns them). -// --------------------------------------------------------------------------- - -export type BuiltStamp = { - v: 1; - stampId: string; - target: string; - kind: "site" | "hub" | "homepage"; - indexStampId: string | null; - inputSig: string | null; - builtAt: string; - commit: string | null; - branch: string | null; - runner: "local" | "docker"; - audience: string; - corpusGeneratedAt: string | null; - files: number; - bytes: number; - archivesStaged: number; - sourceCommit?: string; -}; - -export type DeployRecord = { - builtStampId: string; - builtAt: string; - kind: "production" | "preview" | "local"; - branch?: string; - url: string | null; - alias?: string; - at: string; - wrangler?: string; - liveCheck: LiveCheck | null; -}; - -export type DeployedFile = { - v: 1; - target: string; - production?: DeployRecord; - local?: DeployRecord; - previews: Record<string, DeployRecord>; -}; +import { liveCheckLines, runLiveCheck, type LiveCheckDeps } from "./liveCheck"; +import { + HOMEPAGE_TARGET, + HUB_TARGET, + deployRecordFor, + readBuiltStamp, + 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"; @@ -136,8 +111,6 @@ export type DeployStageRequest = { preview?: string; to?: "pages" | "local"; force?: boolean; - // ms since the epoch: the run that queued this deploy also queued its build. - builtAfter?: number; }; export type DeployStageContext = { @@ -168,17 +141,14 @@ export class DeployStageError extends Error { } } -export const HUB_TARGET = "_hub"; -export const HOMEPAGE_TARGET = "_homepage"; - /** Where a target's stamps live: `<exportBuildsDir>/<target>/`. */ -export function stampDirOf(paths: Paths, target: string): string { - return path.join(paths.exportBuildsDir, target); +export function stampDirOf(paths: Pick<Paths, "exportBuildsDir">, 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) : path.join(stampDirOf(paths, target), "out"); + return kind === "deploy-homepage" ? homepageOutDir(paths) : bundleDir(paths, target); } function readJson(file: string): unknown { @@ -189,45 +159,6 @@ function readJson(file: string): unknown { } } -/** `<dir>/built.json`, or null when it is absent or malformed (= no build). */ -export function readBuiltStamp(dir: string): BuiltStamp | null { - const v = readJson(path.join(dir, "built.json")) as Partial<BuiltStamp> | undefined; - if (!v || v.v !== 1 || typeof v.stampId !== "string" || !v.stampId || typeof v.builtAt !== "string") { - return null; - } - return v as BuiltStamp; -} - -/** `<dir>/deployed.json`, or an empty record for `target`. */ -export function readDeployedFile(dir: string, target: string): DeployedFile { - const v = readJson(path.join(dir, "deployed.json")) as Partial<DeployedFile> | undefined; - if (!v || v.v !== 1 || typeof v.previews !== "object" || v.previews === null) { - return { v: 1, target, previews: {} }; - } - return { ...(v as DeployedFile), target }; -} - -/** Write `<dir>/deployed.json` atomically (a temp file, then rename). */ -export async function writeDeployedFile(dir: string, file: DeployedFile): Promise<void> { - await mkdir(dir, { recursive: true }); - const dest = path.join(dir, "deployed.json"); - const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`); - await rename(tmp, dest); -} - -function deployedSlot(file: DeployedFile, kind: DeployRecord["kind"], branch?: string): DeployRecord | undefined { - if (kind === "production") return file.production; - if (kind === "local") return file.local; - return branch ? file.previews[branch] : undefined; -} - -function msOf(t: string | number | undefined): number { - if (typeof t === "number") return t; - const ms = t ? Date.parse(t) : NaN; - return Number.isFinite(ms) ? ms : 0; -} - // 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, string | undefined>): string | undefined { @@ -397,7 +328,7 @@ export async function runDeployStage( // --- 3. the build --- const stampDir = stampDirOf(paths, target); const outDir = bundleDirOf(paths, req.kind, target); - const built = readBuiltStamp(stampDir); + const built = await readBuiltStamp(paths, target); const cliTarget = req.kind === "deploy-hub" ? "hub" : req.kind === "deploy-homepage" ? "homepage" : target; if (!built) { refuse( @@ -406,26 +337,25 @@ export async function runDeployStage( ); } const b = built!; - if (req.builtAfter !== undefined && msOf(b.builtAt) < req.builtAfter) { - refuse( - `the build of ${target} this deploy waits for has not finished (the newest was built ${b.builtAt}).`, - 3, - ); - } - const deployed = readDeployedFile(stampDir, target); - const slot = deployedSlot(deployed, recordKind, branch); + // (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}, ${slot.at}).`; + 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 --- - if (recordKind === "production" && b.branch && b.branch !== PRODUCTION_BRANCH) { + // --- 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( - `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.`, + (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.`, ); } @@ -446,13 +376,9 @@ export async function runDeployStage( } const builtAt = b.builtAt; - const at = () => now().toISOString(); + const at = () => now().getTime(); const record = async (r: DeployRecord): Promise<void> => { - const next = readDeployedFile(stampDir, target); - if (r.kind === "production") next.production = r; - else if (r.kind === "local") next.local = r; - else next.previews = { ...next.previews, [r.branch!]: r }; - await writeDeployedFile(stampDir, next); + await recordDeploy(paths, target, r); }; // --- 6. --to local --- @@ -462,8 +388,9 @@ export async function runDeployStage( if (!dest) { refuse( req.kind === "deploy-homepage" - ? "a local homepage deploy needs ARCHILYZER_HOMEPAGE_OUT — the directory the local homepage service serves (the container sets it)." - : "a local deploy needs ARCHILYZER_SITE_OUT — the directory the local site service serves (the container sets it).", + ? "--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); diff --git a/common/publish/liveCheck.test.ts b/common/publish/liveCheck.test.ts @@ -81,14 +81,14 @@ test("ok: both reads serve this build, on the first try", async () => { { fetch: f, sleep: s.fn, now: NOW, env: {} }, ); assert.equal(check.verdict, "ok"); - assert.equal(check.at, "2026-10-06T10:05:00.000Z"); + assert.equal(check.at, Date.parse("2026-10-06T10:05:00.000Z")); assert.equal(check.url, "https://jeralyzer.pages.dev"); assert.equal(check.expected, NEW); assert.deepEqual(check.plain, { status: 200, generatedAt: NEW, cfCacheStatus: "MISS", - age: "0", + age: 0, cacheControl: "public, max-age=0, must-revalidate", }); assert.deepEqual(asked, [ @@ -117,7 +117,7 @@ test("stale-edge: the edge HITs an old corpus.json, the busted read is this buil assert.equal(check.verdict, "stale-edge"); assert.equal(check.plain.cfCacheStatus, "HIT"); assert.equal(check.plain.generatedAt, OLD); - assert.equal(check.plain.age, "86400"); + assert.equal(check.plain.age, 86400); assert.equal(check.plain.cacheControl, "public, s-maxage=604800"); assert.equal(check.busted.generatedAt, NEW); assert.equal(asked.length, 6); diff --git a/common/publish/liveCheck.ts b/common/publish/liveCheck.ts @@ -31,34 +31,16 @@ // path must answer, plain and busted, with the empty object that replaced the // withdrawn content. // -// The record shapes are release 18's model (plans/release-18.md, "Model"); -// S1's publish/stamps.ts owns them once it lands — these are the same fields. -// // Everything that touches the network or the clock is injected: `fetch`, // `sleep`, `now`, `env`. -export type Probe = { - status: number | null; - generatedAt?: string; - cfCacheStatus?: string; - age?: string; - cacheControl?: string; - error?: string; -}; - -export type LiveCheckVerdict = "ok" | "stale-edge" | "mismatch" | "unreachable" | "skipped"; +// The record shapes are S1's (publish/stamps.ts): `at` is ms, `age` seconds. +import type { LiveCheck, Probe } from "./stamps"; +export type { LiveCheck, Probe } from "./stamps"; -export type TombstoneProbe = { path: string; plain: Probe; busted: Probe; ok: boolean }; +export type LiveCheckVerdict = LiveCheck["verdict"]; -export type LiveCheck = { - at: string; - url: string; - plain: Probe; - busted: Probe; - expected: string | null; - verdict: LiveCheckVerdict; - tombstones?: TombstoneProbe[]; -}; +export type TombstoneProbe = NonNullable<LiveCheck["tombstones"]>[number]; export type LiveCheckDeps = { // The stage's Cancel: stops between reads and tries, aborts a read in flight. @@ -133,7 +115,8 @@ async function read(url: string, f: typeof fetch, timeoutMs: number, cancel?: Ab const probe: Probe = { status: res.status }; const h = (name: string) => res.headers.get(name) ?? undefined; if (h("cf-cache-status")) probe.cfCacheStatus = h("cf-cache-status"); - if (h("age")) probe.age = h("age"); + const age = Number(h("age")); + if (h("age") !== undefined && Number.isFinite(age)) probe.age = age; if (h("cache-control")) probe.cacheControl = h("cache-control"); let body: unknown; try { @@ -173,7 +156,7 @@ export async function runLiveCheck( const now = deps.now ?? (() => new Date()); const env = deps.env ?? process.env; const base: Omit<LiveCheck, "plain" | "busted" | "verdict"> = { - at: now().toISOString(), + at: now().getTime(), url: opts.url, expected: opts.expected, }; diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts @@ -9,13 +9,11 @@ // 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). +// The three deploy bodies are release 18 S2's deploy stage (deployStage.ts): +// pinned wrangler, credential preflight, the live check, deployed.json. import { execFile } from "node:child_process"; -import { cp, mkdir, readdir, rm } from "node:fs/promises"; -import path from "node:path"; +import { readdir } from "node:fs/promises"; import { promisify } from "node:util"; import { builtAudienceProblem, @@ -25,10 +23,10 @@ import { siteDeployProblem, } from "../lib/builtExport"; import { getHomepageConfig } from "../lib/homepage"; -import { previewAliasUrl, previewBranchProblem } from "../lib/pagesDeploy"; +import { previewBranchProblem } from "../lib/pagesDeploy"; import type { Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; -import { getSite, listSites, type Site } from "../lib/site"; +import { listSites, type Site } from "../lib/site"; import { ALL_TARGET, HOMEPAGE_TARGET, @@ -38,17 +36,14 @@ import { 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, @@ -535,92 +530,13 @@ async function buildHomepageStage(ctx: StageContext, stamp: IndexStamp): Promise // 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: what the `homepage` service serves (release 18 S5 -// declares the name; read by name here until both slices are merged). -const HOMEPAGE_OUT_NAME = "ARCHILYZER_HOMEPAGE_OUT"; - -export function localHomepageOut(env: NodeJS.ProcessEnv = process.env): string | null { - return env[HOMEPAGE_OUT_NAME]?.trim() || 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; needs: string } | 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 ${local.needs}`, 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 three deploy bodies are ONE function, release 18 S2's runDeployStage +// (publish/deployStage.ts): the bundle guards, the credential preflight, the +// archives to R2, the pinned wrangler (wranglerBin), the live check and the +// `deployed.json` record — or `--to local` into ARCHILYZER_SITE_OUT / +// ARCHILYZER_HOMEPAGE_OUT. Its refusals and failures are DeployStageErrors +// carrying the stage's exit code; it logs each sentence itself (stageRun.ts +// does not say it again). // --------------------------------------------------------------------------- // The dispatcher @@ -676,7 +592,6 @@ export async function runStageBody(ctx: StageContext, r: StageRequest): Promise< 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) { @@ -687,50 +602,14 @@ export async function runStageBody(ctx: StageContext, r: StageRequest): Promise< 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(), needs: "ARCHILYZER_SITE_OUT (the directory the docker `site` service serves)" }, - site.cloudflareProject?.trim() || null, - ); - } + case "deploy-site": 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(), needs: "ARCHILYZER_HOMEPAGE_OUT (the directory the docker `homepage` service serves)" }, - b.HOMEPAGE_PAGES_PROJECT, + case "deploy-homepage": { + const { runDeployStage } = await import("./deployStage"); + return runDeployStage( + { paths, onLog: ctx.onLog, signal: ctx.signal }, + { kind: r.kind, target: r.target, preview: r.preview, to: r.to, force: r.force }, ); + } } } diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts @@ -164,6 +164,9 @@ test("a site the index has not seen is blocked; a fresh site's build is a no-op; // …and with it, copies the bundle into it (its CONTENTS replaced) and records the deploy. const siteOut = path.join(ROOT, "builds", "site"); mkdirSync(siteOut, { recursive: true }); + // What an earlier local deploy left (a bundle: it has an index.html — a + // destination without one is refused, deployStage.ts localDestProblem). + writeFileSync(path.join(siteOut, "index.html"), "old"); writeFileSync(path.join(siteOut, "stale.html"), "old"); process.env.ARCHILYZER_SITE_OUT = siteOut; try { diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts @@ -17,6 +17,7 @@ import path from "node:path"; import { killChildTreesNow, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; +import { DeployStageError } from "./deployStage"; import { StageCancelled, StageFailure } from "./stageBodies"; import { LockWaitCancelled, acquirePublishLock, type LockEnv } from "./stageLock"; import { STAGES, type StageOutcome, type StageRequest } from "./stages"; @@ -117,6 +118,17 @@ export async function runStage( return { code: STAGE_EXIT.cancelled, outcome: null, message: "cancelled" }; } const message = (err as Error).message; + // The deploy stage logged its own sentence (deployStage.ts refuse()): + // the exit code is its, and the sentence is not said twice. + if (err instanceof DeployStageError) { + if (err.exitCode === STAGE_EXIT.cancelled) { + onLog(`[stage] ${name}: cancelled\n`); + return { code: STAGE_EXIT.cancelled, outcome: null, message }; + } + const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; + onLog(`[stage] ${name}: ${word} (exit ${err.exitCode})\n`); + return { code: err.exitCode, outcome: null, message }; + } if (err instanceof StageFailure) { const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; onLog(`[stage] ${name}: ${word} — ${message}\n`);