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:
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`);