commit f4d1e563d917676154d6df091cbfcabbccd6627c
parent 1862ed472d8229e8fe23da582331aaad606351ee
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 08:27:59 -0400
common: the deploy stage (runDeployStage) and the live check; the e2e fake wrangler
publish/deployStage.ts is one body for deploy-site, deploy-hub and
deploy-homepage over <exportBuildsDir>/<target>/out and its built.json:
the bundle guards, production refused for a build not of main, the
credential preflight, R2 from <id>/.r2-staging, the pinned wrangler with
the auth classifier, --to local (ARCHILYZER_SITE_OUT), the live check, and
deployed.json written atomically last — a refusal or failure throws a
DeployStageError (exit 1, or 3 for a missing/older build) and leaves it
untouched. publish/liveCheck.ts reads <url>/corpus.json plain and
?cb=<builtStampId>, 3 tries 10 s apart (injected fetch/sleep): ok,
stale-edge, mismatch, unreachable, skipped (E2E_LIVE_CHECK=skip); the hub
also probes its tombstones. The stamp types are local until S1's stamps.ts.
editor/e2e/fixtures/bin/fake-wrangler.mjs records its argv beside the
bundle (.fake-wrangler.json), prints "Take a peek over at
https://<branch>.<project>.pages.dev", and with E2E_FAKE_WRANGLER_AUTH_FAIL=1
fails as wrangler does on a rejected token. The editor's test server gets
WRANGLER_BIN=<the fake> and E2E_LIVE_CHECK=skip. envVars.ts declares the
variables read (ENVIRONMENT.md regenerated).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 1634 insertions(+), 7 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -59,6 +59,11 @@ Tokens, credentials and knobs a running process reads. Most configuration is not
| `SYNC_TICK_TOKEN` | unset (no auth) | Bearer token for the tick endpoint; set on both the editor and the cron job. | common/bin/sync-tick.ts, editor/app/scheduler/auth.ts |
| `R2_ACCESS_KEY_ID` | — | R2 S3 credentials for uploading oversize archives at deploy time (with `R2_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCOUNT_ID`). See [PUBLISH.md](PUBLISH.md). | common/publish/build.ts |
| `R2_SECRET_ACCESS_KEY` | — | See `R2_ACCESS_KEY_ID`. | common/publish/build.ts |
+| `CLOUDFLARE_API_TOKEN` | — (a `wrangler login` on this machine instead) | The Cloudflare API token every Pages deploy runs wrangler with. With no token, no key pair and no `wrangler login`, a deploy is refused before wrangler runs; a token Cloudflare rejects ends the deploy on "REFUSED by Cloudflare". Never printed. | common/lib/pagesDeploy.ts, wrangler |
+| `CLOUDFLARE_API_KEY` | — | wrangler's global-key alternative to `CLOUDFLARE_API_TOKEN`, with `CLOUDFLARE_EMAIL`. | common/lib/pagesDeploy.ts, wrangler |
+| `CLOUDFLARE_EMAIL` | — | See `CLOUDFLARE_API_KEY`. | common/lib/pagesDeploy.ts, wrangler |
+| `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin) |
+| `XDG_CONFIG_HOME` | `~/.config` | Where a deploy's credential check looks for a `wrangler login` (`<it>/.wrangler/config/default.toml`, beside `~/.wrangler`). | common/lib/pagesDeploy.ts |
| `CLOUDFLARE_ACCOUNT_ID` | — | The account the R2 endpoint belongs to. wrangler reads its own credentials. | common/publish/build.ts |
| `DOCKER_BIN` | `docker` | The container engine for docker-mode builds (e.g. `podman`). | common/publish/build.ts |
| `DOCKER_BUILD_MEMORY` | no cap | Per-container memory cap for a docker-mode build (`--memory`). | common/publish/build.ts |
@@ -149,7 +154,8 @@ The container's own set, read by `docker/*.sh`, the compose files and Caddy —
| `ARCHILYZER_FETCH_MODEL` | per transcriber | Which model the first boot downloads; `none` skips it. | docker/entrypoint.sh |
| `ARCHILYZER_MODELS_DIR` | `/data/models` | Where models live in the container. | docker/entrypoint.sh |
| `ARCHILYZER_BUILDS_DIR` | `/data/builds` | Where the container keeps built sites. | docker/entrypoint.sh |
-| `ARCHILYZER_SITE_OUT` | `/data/builds/site` | The built export site the `site` service serves. | docker/entrypoint.sh, docker/publish-site.sh |
+| `ARCHILYZER_SITE_OUT` | `/data/builds/site` | The built export site the `site` service serves; `deploy <id> --to local` copies a site's bundle here. | docker/entrypoint.sh, docker/publish-site.sh, common/publish/deployStage.ts |
+| `ARCHILYZER_HOMEPAGE_OUT` | `homepage/` beside `ARCHILYZER_SITE_OUT` | Where `deploy homepage --to local` copies the homepage's bundle. | common/publish/deployStage.ts |
| `ARCHILYZER_IDLE_BOOT` | off | `1` boots the editor without arming the heartbeat or any auto-queue runner. | common/lib/idleBoot.ts (the editor) |
| `ARCHILYZER_AUTH_MODE` | `basic` | `basic`, `forward` or `none` — the only escape hatch from the exposure guard. | docker/guard-exposure.sh, docker/caddy-start.sh |
| `ARCHILYZER_AUTH_USER` | `archilyzer` | Basic-auth user. | docker/Caddyfile |
@@ -187,6 +193,8 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one
| `E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT` | — | Fake yt-dlp: corrupt deterministically. | editor/e2e/fixtures/bin/fake-ytdlp.mjs |
| `E2E_FAKE_YTDLP_RECOVER_ON_RESUME` | — | Fake yt-dlp: a resumed run recovers. | editor/e2e/fixtures/bin/fake-ytdlp.mjs |
| `E2E_FAKE_YTDLP_TOTAL_CHUNKS` | — | Fake yt-dlp: how many chunks a download has. | editor/e2e/fixtures/bin/fake-ytdlp.mjs |
+| `E2E_FAKE_WRANGLER_AUTH_FAIL` | — | Fake wrangler: fail as Cloudflare refusing the API token (`Authentication error [code: 10000]`). | editor/e2e/fixtures/bin/fake-wrangler.mjs |
+| `E2E_LIVE_CHECK` | on | `skip`: a deploy's live check reads nothing and records `skipped`. Set for the editor's test server, whose fake wrangler deploys nothing. | common/publish/liveCheck.ts |
| `E2E_FAKE_GALLERY_DL_AUTH_FAIL` | — | Fake gallery-dl: fail as an auth error. | editor/e2e/fixtures/bin/fake-gallery-dl.mjs |
| `E2E_FIXTURE_MAX_LIFETIME_MS` | the watchdog's | How long a fake binary may live before its watchdog kills it. | editor/e2e/fixtures/bin/_watchdog.mjs |
| `E2E_OLLAMA_STUB_MODEL` | `qwen2.5:7b` | The model the ollama stub claims to serve. | editor/e2e/fixtures/ollama-stub.mjs |
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -95,6 +95,11 @@ const DECLARED: EnvVarDecl[] = [
{ name: "SYNC_TICK_TOKEN", audience: "runtime", default: "unset (no auth)", readBy: "common/bin/sync-tick.ts, editor/app/scheduler/auth.ts", doc: "Bearer token for the tick endpoint; set on both the editor and the cron job." },
{ name: "R2_ACCESS_KEY_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts", doc: "R2 S3 credentials for uploading oversize archives at deploy time (with `R2_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCOUNT_ID`). See [PUBLISH.md](PUBLISH.md)." },
{ name: "R2_SECRET_ACCESS_KEY", audience: "runtime", default: "—", readBy: "common/publish/build.ts", doc: "See `R2_ACCESS_KEY_ID`." },
+ { name: "CLOUDFLARE_API_TOKEN", audience: "runtime", default: "— (a `wrangler login` on this machine instead)", readBy: "common/lib/pagesDeploy.ts, wrangler", doc: "The Cloudflare API token every Pages deploy runs wrangler with. With no token, no key pair and no `wrangler login`, a deploy is refused before wrangler runs; a token Cloudflare rejects ends the deploy on \"REFUSED by Cloudflare\". Never printed." },
+ { name: "CLOUDFLARE_API_KEY", audience: "runtime", default: "—", readBy: "common/lib/pagesDeploy.ts, wrangler", doc: "wrangler's global-key alternative to `CLOUDFLARE_API_TOKEN`, with `CLOUDFLARE_EMAIL`." },
+ { name: "CLOUDFLARE_EMAIL", audience: "runtime", default: "—", readBy: "common/lib/pagesDeploy.ts, wrangler", doc: "See `CLOUDFLARE_API_KEY`." },
+ { name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin)", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." },
+ { name: "XDG_CONFIG_HOME", audience: "runtime", default: "`~/.config`", readBy: "common/lib/pagesDeploy.ts", doc: "Where a deploy's credential check looks for a `wrangler login` (`<it>/.wrangler/config/default.toml`, beside `~/.wrangler`)." },
{ name: "CLOUDFLARE_ACCOUNT_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts", doc: "The account the R2 endpoint belongs to. wrangler reads its own credentials." },
{ name: "DOCKER_BIN", audience: "runtime", default: "`docker`", readBy: "common/publish/build.ts", doc: "The container engine for docker-mode builds (e.g. `podman`)." },
{ name: "DOCKER_BUILD_MEMORY", audience: "runtime", default: "no cap", readBy: "common/publish/build.ts", doc: "Per-container memory cap for a docker-mode build (`--memory`)." },
@@ -152,7 +157,8 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHILYZER_FETCH_MODEL", audience: "docker", default: "per transcriber", readBy: "docker/entrypoint.sh", doc: "Which model the first boot downloads; `none` skips it." },
{ name: "ARCHILYZER_MODELS_DIR", audience: "docker", default: "`/data/models`", readBy: "docker/entrypoint.sh", doc: "Where models live in the container." },
{ name: "ARCHILYZER_BUILDS_DIR", audience: "docker", default: "`/data/builds`", readBy: "docker/entrypoint.sh", doc: "Where the container keeps built sites." },
- { name: "ARCHILYZER_SITE_OUT", audience: "docker", default: "`/data/builds/site`", readBy: "docker/entrypoint.sh, docker/publish-site.sh", doc: "The built export site the `site` service serves." },
+ { name: "ARCHILYZER_SITE_OUT", audience: "docker", default: "`/data/builds/site`", readBy: "docker/entrypoint.sh, docker/publish-site.sh, common/publish/deployStage.ts", doc: "The built export site the `site` service serves; `deploy <id> --to local` copies a site's bundle here." },
+ { name: "ARCHILYZER_HOMEPAGE_OUT", audience: "docker", default: "`homepage/` beside `ARCHILYZER_SITE_OUT`", readBy: "common/publish/deployStage.ts", doc: "Where `deploy homepage --to local` copies the homepage's bundle." },
{ name: "ARCHILYZER_IDLE_BOOT", audience: "docker", default: "off", readBy: "common/lib/idleBoot.ts (the editor)", doc: "`1` boots the editor without arming the heartbeat or any auto-queue runner." },
{ name: "ARCHILYZER_AUTH_MODE", audience: "docker", default: "`basic`", readBy: "docker/guard-exposure.sh, docker/caddy-start.sh", doc: "`basic`, `forward` or `none` — the only escape hatch from the exposure guard." },
{ name: "ARCHILYZER_AUTH_USER", audience: "docker", default: "`archilyzer`", readBy: "docker/Caddyfile", doc: "Basic-auth user." },
@@ -185,6 +191,8 @@ const DECLARED: EnvVarDecl[] = [
{ name: "E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: corrupt deterministically." },
{ name: "E2E_FAKE_YTDLP_RECOVER_ON_RESUME", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: a resumed run recovers." },
{ name: "E2E_FAKE_YTDLP_TOTAL_CHUNKS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many chunks a download has." },
+ { name: "E2E_FAKE_WRANGLER_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-wrangler.mjs", doc: "Fake wrangler: fail as Cloudflare refusing the API token (`Authentication error [code: 10000]`)." },
+ { name: "E2E_LIVE_CHECK", audience: "test", default: "on", readBy: "common/publish/liveCheck.ts", doc: "`skip`: a deploy's live check reads nothing and records `skipped`. Set for the editor's test server, whose fake wrangler deploys nothing." },
{ name: "E2E_FAKE_GALLERY_DL_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-gallery-dl.mjs", doc: "Fake gallery-dl: fail as an auth error." },
{ name: "E2E_FIXTURE_MAX_LIFETIME_MS", audience: "test", default: "the watchdog's", readBy: "editor/e2e/fixtures/bin/_watchdog.mjs", doc: "How long a fake binary may live before its watchdog kills it." },
{ name: "E2E_OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." },
diff --git a/common/lib/pagesDeploy.ts b/common/lib/pagesDeploy.ts
@@ -149,13 +149,11 @@ export function wranglerOAuthConfigFiles(
env: Record<string, string | undefined>,
): string[] {
const xdg = env.XDG_CONFIG_HOME?.trim() || `${home}/.config`;
- const files = [
+ return [
`${home}/.wrangler/config/default.toml`,
`${xdg}/.wrangler/config/default.toml`,
`${home}/Library/Preferences/.wrangler/config/default.toml`,
];
- if (env.APPDATA?.trim()) files.push(`${env.APPDATA.trim()}/xdg.config/.wrangler/config/default.toml`);
- return files;
}
/**
diff --git a/common/publish/deployStage.test.ts b/common/publish/deployStage.test.ts
@@ -0,0 +1,486 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+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 {
+ DeployStageError,
+ readDeployedFile,
+ runDeployStage,
+ type BuiltStamp,
+ type DeployStageContext,
+ type DeployStageRequest,
+} from "./deployStage";
+
+// Run with: pnpm --filter yt-dlp-transcript-common test
+//
+// The deploy stage over a temp tree, spawning the e2e FAKE wrangler
+// (editor/e2e/fixtures/bin/fake-wrangler.mjs) as WRANGLER_BIN — the same
+// binary the editor suite deploys through — and an injected fetch for the live
+// check. Nothing here can reach Cloudflare: the env handed to the stage is
+// built from scratch, and the fake deploys nothing.
+
+const FAKE_WRANGLER = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+ "editor",
+ "e2e",
+ "fixtures",
+ "bin",
+ "fake-wrangler.mjs",
+);
+const GENERATED = "2026-10-06T09:00:00.000Z";
+
+type Fixture = { root: string; paths: Paths; home: string; cleanup: () => void };
+
+function fixture(): Fixture {
+ const root = mkdtempSync(path.join(tmpdir(), "deploy-stage-"));
+ const home = path.join(root, "home");
+ mkdirSync(home);
+ const sitesDir = path.join(root, "sites");
+ const paths: Paths = {
+ ...getPaths(),
+ monorepoRoot: root,
+ exportDir: path.join(root, "export"),
+ exportBuildsDir: path.join(root, "builds"),
+ sitesDir,
+ homepageDir: path.join(sitesDir, "_homepage"),
+ homepageConfigFile: path.join(sitesDir, "_homepage", "homepage.json"),
+ };
+ mkdirSync(paths.exportDir, { recursive: true });
+ return { root, paths, home, cleanup: () => rmSync(root, { recursive: true, force: true }) };
+}
+
+const writeJson = (file: string, value: unknown) => {
+ mkdirSync(path.dirname(file), { recursive: true });
+ writeFileSync(file, JSON.stringify(value));
+};
+
+function site(fx: Fixture, siteId: string, extra: Record<string, unknown> = {}) {
+ writeJson(path.join(fx.paths.sitesDir, siteId, "site.json"), {
+ siteId,
+ siteTitle: siteId,
+ cloudflareProject: `${siteId}-proj`,
+ siteUrl: `https://${siteId}.example.test`,
+ channels: [],
+ ...extra,
+ });
+}
+
+// A built bundle under <builds>/<target>/out and its built.json.
+function built(
+ fx: Fixture,
+ target: string,
+ opts: { bundleOf?: string; stamp?: Partial<BuiltStamp>; corpus?: Record<string, unknown> } = {},
+): BuiltStamp {
+ const dir = path.join(fx.paths.exportBuildsDir, target);
+ const out = path.join(dir, "out");
+ mkdirSync(out, { recursive: true });
+ const id = opts.bundleOf ?? target;
+ writeJson(path.join(out, "site.json"), { siteId: id });
+ writeJson(path.join(out, "corpus.json"), { generatedAt: GENERATED, site: { id }, ...opts.corpus });
+ writeFileSync(path.join(out, "index.html"), "<!doctype html>");
+ const stamp: BuiltStamp = {
+ v: 1,
+ stampId: `built-${target}-1`,
+ target,
+ kind: "site",
+ indexStampId: "idx-1",
+ inputSig: "sig",
+ builtAt: "2026-10-06T09:30:00.000Z",
+ commit: null,
+ branch: null,
+ runner: "local",
+ audience: "public",
+ corpusGeneratedAt: GENERATED,
+ files: 3,
+ bytes: 100,
+ archivesStaged: 0,
+ ...opts.stamp,
+ };
+ writeJson(path.join(dir, "built.json"), stamp);
+ return stamp;
+}
+
+// A live check that finds the build live: every URL answers this build's
+// corpus.json (or a tombstone body for a posts path), and records each URL.
+function liveFetch(asked: string[], generatedAt = GENERATED): typeof fetch {
+ return (async (input: string | URL | Request) => {
+ const url = String(input);
+ asked.push(url);
+ const body = url.includes("/posts/")
+ ? url.includes("page-")
+ ? []
+ : url.includes("/posts/manifest.json")
+ ? { channels: [] }
+ : { pageCount: 0 }
+ : { generatedAt };
+ return new Response(JSON.stringify(body), { status: 200 });
+ }) as typeof fetch;
+}
+
+function ctx(
+ fx: Fixture,
+ env: Record<string, string | undefined>,
+ extra: Partial<DeployStageContext> = {},
+): DeployStageContext & { lines: string[]; asked: string[]; uploads: string[] } {
+ const lines: string[] = [];
+ const asked: string[] = [];
+ const uploads: string[] = [];
+ return {
+ paths: fx.paths,
+ onLog: (l) => lines.push(l),
+ signal: new AbortController().signal,
+ env: { PATH: process.env.PATH, WRANGLER_BIN: FAKE_WRANGLER, ...env },
+ home: fx.home,
+ now: () => new Date("2026-10-06T10:00:00.000Z"),
+ liveCheck: { fetch: liveFetch(asked), sleep: async () => {} },
+ uploadArchives: async (_s, dir) => {
+ uploads.push(dir);
+ return 0;
+ },
+ lines,
+ asked,
+ uploads,
+ ...extra,
+ };
+}
+
+const TOKEN = { CLOUDFLARE_API_TOKEN: "test-token-not-real" };
+
+function sidecar(fx: Fixture, target: string): { argv: string[]; branch: string }[] {
+ const file = path.join(fx.paths.exportBuildsDir, target, ".fake-wrangler.json");
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).invocations : [];
+}
+
+function deployedBytes(fx: Fixture, target: string): string | null {
+ const file = path.join(fx.paths.exportBuildsDir, target, "deployed.json");
+ return existsSync(file) ? readFileSync(file, "utf8") : null;
+}
+
+async function refused(
+ p: Promise<unknown>,
+ exitCode: number,
+ why: RegExp,
+): Promise<void> {
+ await assert.rejects(p, (err: unknown) => {
+ assert.ok(err instanceof DeployStageError, String(err));
+ assert.equal(err.exitCode, exitCode, err.message);
+ assert.match(err.message, why);
+ return true;
+ });
+}
+
+test("a preview deploy runs the pinned binary with --branch <b>, checks the alias live and records previews[b]", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ const stamp = built(fx, "anilyzer");
+ const c = ctx(fx, TOKEN);
+ const req: DeployStageRequest = { kind: "deploy-site", target: "anilyzer", preview: "r18" };
+ const out = await runDeployStage(c, req);
+ assert.equal(out.status, "ran");
+ assert.equal(out.stamp, stamp.stampId);
+ assert.match(out.summary, /deployed to preview "r18" \(anilyzer-proj\) — live check: ok\.$/);
+
+ const outDir = path.join(fx.paths.exportBuildsDir, "anilyzer", "out");
+ assert.deepEqual(sidecar(fx, "anilyzer").map((i) => i.argv), [
+ ["pages", "deploy", outDir, "--project-name", "anilyzer-proj", "--branch", "r18"],
+ ]);
+ // The archives went first, from the per-site staging dir.
+ assert.deepEqual(c.uploads, [path.join(fx.paths.exportBuildsDir, "anilyzer", ".r2-staging", "anilyzer", "archives")]);
+ assert.ok(c.lines.includes("[preview] https://r18.anilyzer-proj.pages.dev (this deployment: https://r18.anilyzer-proj.pages.dev)\n"), c.lines.join(""));
+ // The live check read the ALIAS, plain and cache-busted.
+ assert.deepEqual(c.asked, [
+ "https://r18.anilyzer-proj.pages.dev/corpus.json",
+ `https://r18.anilyzer-proj.pages.dev/corpus.json?cb=${stamp.stampId}`,
+ ]);
+
+ const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer");
+ assert.equal(rec.v, 1);
+ assert.equal(rec.production, undefined);
+ const p = rec.previews.r18;
+ assert.equal(p.builtStampId, stamp.stampId);
+ assert.equal(p.builtAt, stamp.builtAt);
+ assert.equal(p.kind, "preview");
+ 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.match(p.wrangler ?? "", /^WRANGLER_BIN=/);
+ assert.equal(p.liveCheck?.verdict, "ok");
+ assert.equal(p.liveCheck?.expected, GENERATED);
+
+ // The same build to the same slot again: a no-op, no second spawn.
+ const again = await runDeployStage(ctx(fx, TOKEN), req);
+ assert.equal(again.status, "noop");
+ assert.equal(sidecar(fx, "anilyzer").length, 1);
+ // …unless forced.
+ assert.equal((await runDeployStage(ctx(fx, TOKEN), { ...req, force: true })).status, "ran");
+ assert.equal(sidecar(fx, "anilyzer").length, 2);
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("production is --branch main, checked at the site's public URL, recorded beside the previews", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "jeralyzer");
+ built(fx, "jeralyzer", { stamp: { branch: "main" } });
+ 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 } },
+ });
+ 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");
+ assert.equal(rec.production?.kind, "production");
+ assert.equal(rec.production?.branch, undefined);
+ assert.equal(rec.production?.url, "https://main.jeralyzer-proj.pages.dev");
+ assert.ok(rec.previews.old, "the previews already recorded are kept");
+ assert.ok(c.lines.includes("[deployed] https://main.jeralyzer-proj.pages.dev\n"));
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("every refusal leaves deployed.json untouched, and wrangler unspawned", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ site(fx, "mine", { audience: "private" });
+ site(fx, "noproj", { cloudflareProject: "" });
+ built(fx, "anilyzer");
+ built(fx, "mine");
+ built(fx, "noproj");
+ const before = JSON.stringify({ v: 1, target: "anilyzer", previews: {} }, null, 2);
+ writeFileSync(path.join(fx.paths.exportBuildsDir, "anilyzer", "deployed.json"), before);
+
+ const cases: [string, DeployStageRequest, Record<string, string | undefined>, number, RegExp][] = [
+ ["no credential", { kind: "deploy-site", target: "anilyzer" }, {}, 1, /set CLOUDFLARE_API_TOKEN in \.env/],
+ ["a production branch as preview", { kind: "deploy-site", target: "anilyzer", preview: "main" }, TOKEN, 1, /is the production branch/],
+ ["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, 1, /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/],
+ ];
+ site(fx, "nobuild");
+ for (const [name, req, env, code, why] of cases) {
+ const c = ctx(fx, env);
+ await refused(runDeployStage(c, req), code, why);
+ assert.match(c.lines.at(-1) ?? "", /^\[deploy\] REFUSED/, name);
+ assert.equal(deployedBytes(fx, "anilyzer"), before, `${name}: deployed.json changed`);
+ assert.equal(deployedBytes(fx, "mine"), null, name);
+ assert.deepEqual(sidecar(fx, req.target), [], `${name}: wrangler was spawned`);
+ assert.deepEqual(c.asked, [], `${name}: a live check ran`);
+ }
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("Cloudflare refusing the token: wrangler's error becomes the REFUSED sentence; deployed.json untouched, exit 1", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ built(fx, "anilyzer");
+ const c = ctx(fx, { ...TOKEN, E2E_FAKE_WRANGLER_AUTH_FAIL: "1" });
+ await refused(runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), 1, /^\[deploy\] REFUSED by Cloudflare — the API token was not accepted$/);
+ assert.ok(c.lines.some((l) => l.includes("Authentication error [code: 10000]")), "wrangler's own line is in the log");
+ assert.equal(c.lines.at(-1), `${CLOUDFLARE_AUTH_REFUSED}\n`);
+ assert.equal(deployedBytes(fx, "anilyzer"), null);
+ assert.deepEqual(c.asked, []);
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("a wrangler OAuth login on disk passes the preflight with no token", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ built(fx, "anilyzer");
+ writeJson(path.join(fx.home, ".config", ".wrangler", "config", "default.toml"), "oauth_token = \"x\"");
+ const out = await runDeployStage(ctx(fx, {}), { kind: "deploy-site", target: "anilyzer", preview: "p" });
+ assert.equal(out.status, "ran");
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("production ships only a build of main; the same build may go to a preview", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ built(fx, "anilyzer", { stamp: { branch: "r18/feature" } });
+ const c = ctx(fx, TOKEN);
+ await refused(
+ runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }),
+ 1,
+ /made from branch "r18\/feature", not main: production ships only a build of main/,
+ );
+ assert.equal(deployedBytes(fx, "anilyzer"), null);
+ assert.equal((await runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer", preview: "feat" })).status, "ran");
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("the bundle guards: another site's bundle and a private build are refused before wrangler", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ built(fx, "anilyzer", { bundleOf: "jeralyzer" });
+ await refused(
+ runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer" }),
+ 1,
+ /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)\. Nothing was sent to Cloudflare Pages/,
+ );
+ site(fx, "bonnellyzer");
+ built(fx, "bonnellyzer", { corpus: { site: { id: "bonnellyzer", audience: "private" } } });
+ await refused(
+ runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "bonnellyzer" }),
+ 1,
+ /private build of "bonnellyzer"/,
+ );
+ assert.deepEqual(sidecar(fx, "anilyzer"), []);
+ assert.deepEqual(sidecar(fx, "bonnellyzer"), []);
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replaced) and records local", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ const stamp = built(fx, "anilyzer");
+ const dest = path.join(fx.root, "site-out");
+ mkdirSync(dest);
+ writeFileSync(path.join(dest, "stale.html"), "old");
+ const c = ctx(fx, { ARCHILYZER_SITE_OUT: dest });
+ const out = await runDeployStage(c, { kind: "deploy-site", target: "anilyzer", to: "local" });
+ assert.equal(out.status, "ran");
+ assert.deepEqual(readdirSync(dest).sort(), ["corpus.json", "index.html", "site.json"]);
+ 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");
+ assert.equal(rec.local?.builtStampId, stamp.stampId);
+ assert.equal(rec.local?.kind, "local");
+ assert.equal(rec.local?.liveCheck, null);
+ // A private site is still refused locally.
+ site(fx, "mine", { audience: "private" });
+ built(fx, "mine");
+ await refused(
+ runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: dest }), { kind: "deploy-site", target: "mine", to: "local" }),
+ 1,
+ /is private/,
+ );
+ assert.ok(existsSync(path.join(dest, "site.json")));
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("the hub: its project, its bundle, and every tombstone probed plain and busted", async () => {
+ const fx = fixture();
+ try {
+ writeJson(fx.paths.homepageConfigFile, {
+ cloudflareProject: "archilyzer-hub",
+ siteUrl: "https://archilyzer-hub.pages.dev",
+ });
+ const dir = path.join(fx.paths.exportBuildsDir, "_hub");
+ const out = path.join(dir, "out");
+ writeJson(path.join(out, "hub-sites.json"), []);
+ writeJson(path.join(out, "corpus.json"), { kind: "hub", generatedAt: GENERATED });
+ 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,
+ });
+ const c = ctx(fx, TOKEN);
+ const res = await runDeployStage(c, { kind: "deploy-hub", target: "_hub" });
+ assert.equal(res.status, "ran");
+ assert.deepEqual(sidecar(fx, "_hub")[0].argv, [
+ "pages", "deploy", out, "--project-name", "archilyzer-hub", "--branch", "main",
+ ]);
+ for (const rel of [
+ "corpus.json",
+ "posts/manifest.json",
+ "posts/thequartering-X/manifest.json",
+ "posts/thequartering-X/page-0000.json",
+ ]) {
+ 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");
+ 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(""));
+
+ // The homepage's project is never the hub's.
+ writeJson(fx.paths.homepageConfigFile, { cloudflareProject: "archilyzer" });
+ await refused(runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", force: true }), 1, /homepage's/);
+ await refused(
+ runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", to: "local" }),
+ 1,
+ /the hub has no local target/,
+ );
+ } finally {
+ fx.cleanup();
+ }
+});
+
+test("a stale edge is a WARNING: the deploy is recorded with its verdict and the stage succeeds", async () => {
+ const fx = fixture();
+ try {
+ site(fx, "anilyzer");
+ built(fx, "anilyzer");
+ const asked: string[] = [];
+ const stale = (async (input: string | URL | Request) => {
+ const url = String(input);
+ asked.push(url);
+ const fresh = url.includes("?cb=");
+ return new Response(JSON.stringify({ generatedAt: fresh ? GENERATED : "2026-10-01T00:00:00.000Z" }), {
+ status: 200,
+ headers: { "cf-cache-status": fresh ? "MISS" : "HIT" },
+ });
+ }) as typeof fetch;
+ const c = ctx(fx, TOKEN, { liveCheck: { fetch: stale, sleep: async () => {} } });
+ const res = await runDeployStage(c, { kind: "deploy-site", target: "anilyzer" });
+ 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");
+ assert.equal(rec.production?.liveCheck?.verdict, "stale-edge");
+ assert.equal(rec.production?.liveCheck?.plain.cfCacheStatus, "HIT");
+ } finally {
+ fx.cleanup();
+ }
+});
diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts
@@ -0,0 +1,515 @@
+// THE DEPLOY STAGE (release 18): one body for `publish-deploy-site`,
+// `publish-deploy-hub` and `publish-deploy-homepage`, whichever runner built
+// the bundle.
+//
+// It ships `<exportBuildsDir>/<target>/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. In order — and NOTHING is written to
+// `deployed.json` unless every step before the record succeeded:
+//
+// 1. the request: a preview branch name Cloudflare keeps verbatim; a local
+// deploy has no branch; the hub has no local target
+// 2. the target's own refusals, before anything else is read: 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 <dir>"), be
+// newer than `builtAfter` when the run started a build (exit 3), and —
+// unless forced — not be the one this slot already shipped (a no-op)
+// 4. PRODUCTION ships only a build of `main`: a `built.branch` that is set
+// and is not `main` is refused (a preview is fine)
+// 5. today's bundle guards over the bundle itself: 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) and the stage records `local` — no credential,
+// no R2, no wrangler, no live check
+// 7. the credential preflight: no CLOUDFLARE_API_TOKEN (nor the global key
+// pair) and no wrangler OAuth login on disk → refused before wrangler
+// 8. a site's oversize archives to R2, from `<id>/.r2-staging`
+// 9. the pinned wrangler (wranglerBin), `--branch main` or `--branch <b>`;
+// 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 (atomic: temp file + rename)
+//
+// A refusal or a failure THROWS a DeployStageError carrying the exit code the
+// stage contract names (1 refused/failed, 3 precondition not met, 130
+// cancelled); its message is the sentence the log already ends on.
+//
+// The stamp shapes are release 18's model (plans/release-18.md, "Model"). S1's
+// publish/stamps.ts owns them once it lands — the fields here are the same.
+
+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 { 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,
+ 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>;
+};
+
+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;
+ // ms since the epoch: the run that queued this deploy also queued its build.
+ builtAfter?: number;
+};
+
+export type DeployStageContext = {
+ paths: Paths;
+ onLog: (line: string) => void;
+ signal: AbortSignal;
+ // Default process.env: the credentials, WRANGLER_BIN, ARCHILYZER_SITE_OUT,
+ // E2E_LIVE_CHECK.
+ env?: Record<string, string | undefined>;
+ // 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<number>;
+};
+
+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 | 3 | 130,
+ ) {
+ super(message);
+ this.name = "DeployStageError";
+ }
+}
+
+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);
+}
+
+/** 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");
+}
+
+function readJson(file: string): unknown {
+ try {
+ return JSON.parse(readFileSync(file, "utf8"));
+ } catch {
+ return undefined;
+ }
+}
+
+/** `<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 {
+ 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();
+}
+
+// Copy `outDir`'s contents into `dest`, emptying it first — its CONTENTS,
+// never the directory: in the container it is a volume mount point.
+async function copyToLocal(outDir: string, dest: string): Promise<number> {
+ 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.
+ */
+export async function runDeployStage(
+ ctx: DeployStageContext,
+ req: DeployStageRequest,
+): Promise<DeployStageOutcome> {
+ 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 | 3 = 1): never => {
+ const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`;
+ log(line);
+ throw new DeployStageError(line, exitCode);
+ };
+
+ // --- 1. the request ---
+ const target = req.target.trim();
+ const toLocal = req.to === "local";
+ if (req.preview !== undefined) {
+ const problem = previewBranchProblem(req.preview);
+ if (problem) refuse(problem);
+ }
+ const branch = req.preview?.trim() || undefined;
+ if (toLocal && branch) refuse("a local deploy has no preview branch — deploy locally or as a preview, not both.");
+ if (toLocal && req.kind === "deploy-hub") refuse("the hub has no local target — deploy it to Cloudflare Pages.");
+ 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) refuse(`${privateProblem}.`);
+ project = site.cloudflareProject?.trim() ?? "";
+ if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`);
+ publicUrl = site.siteUrl?.trim() || undefined;
+ } else if (req.kind === "deploy-hub") {
+ const hub = getHomepageConfig(paths);
+ const problem = hubProjectProblem(hub.cloudflareProject);
+ if (problem) refuse(problem);
+ 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 = readBuiltStamp(stampDir);
+ const cliTarget = req.kind === "deploy-hub" ? "hub" : req.kind === "deploy-homepage" ? "homepage" : target;
+ if (!built) {
+ refuse(
+ `no build of ${target} in ${stampDir} — archilyzer publish ${req.kind === "deploy-site" ? `build ${cliTarget}` : cliTarget}`,
+ 3,
+ );
+ }
+ 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);
+ 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}).`;
+ 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) {
+ 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.`,
+ );
+ }
+
+ // --- 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.`);
+ }
+ } else if (req.kind === "deploy-hub") {
+ const problem = builtHubProblem(outDir);
+ if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`);
+ } else {
+ const problem =
+ builtHomepageProblem(outDir) ?? (await (await import("./source")).publishedSourceProblem(paths, outDir));
+ if (problem) refuse(problem);
+ }
+
+ const builtAt = b.builtAt;
+ const at = () => now().toISOString();
+ 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);
+ };
+
+ // --- 6. --to local ---
+ if (toLocal) {
+ const dest =
+ req.kind === "deploy-homepage"
+ ? env.ARCHILYZER_HOMEPAGE_OUT?.trim() ||
+ (env.ARCHILYZER_SITE_OUT?.trim() ? path.join(path.dirname(env.ARCHILYZER_SITE_OUT.trim()), "homepage") : "")
+ : env.ARCHILYZER_SITE_OUT?.trim() ?? "";
+ if (!dest) {
+ refuse(
+ req.kind === "deploy-homepage"
+ ? "a local homepage deploy needs ARCHILYZER_HOMEPAGE_OUT (or ARCHILYZER_SITE_OUT) — the directory the local server serves."
+ : "a local deploy needs ARCHILYZER_SITE_OUT — the directory the local site service serves.",
+ );
+ }
+ 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) {
+ log(`[deploy] FAILED — the archive R2 upload exited ${code}; nothing was sent to Cloudflare Pages.`);
+ throw new DeployStageError(`Archive R2 upload failed (exit ${code}).`, 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;
+ ctx.onLog(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);
+ }
+ log(`[deploy] FAILED — wrangler exited ${code}.`);
+ throw new DeployStageError(`Deploy failed (exit ${code}).`, 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, ...ctx.liveCheck },
+ );
+ 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,
+ });
+ 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 };
+}
diff --git a/common/publish/liveCheck.test.ts b/common/publish/liveCheck.test.ts
@@ -0,0 +1,231 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ cacheBusted,
+ liveCheckLines,
+ liveCheckVerdict,
+ liveUrl,
+ runLiveCheck,
+ type Probe,
+} from "./liveCheck";
+
+// Run with: pnpm --filter yt-dlp-transcript-common test
+//
+// Every case runs over an injected fetch and sleep: no test reaches a network.
+
+const NEW = "2026-10-06T10:00:00.000Z";
+const OLD = "2026-10-01T09:00:00.000Z";
+const STAMP = "01J-built";
+
+type Answer = { status: number; body?: unknown; headers?: Record<string, string> } | Error;
+
+// A fetch that answers by URL: `plain` for the URL without a query, `busted`
+// for the cache-busted one; records every URL asked.
+function fakeFetch(route: (url: string, busted: boolean) => Answer) {
+ const asked: string[] = [];
+ const f = (async (input: string | URL | Request) => {
+ const url = String(input);
+ asked.push(url);
+ const a = route(url, url.includes("?cb="));
+ if (a instanceof Error) throw a;
+ return new Response(a.body === undefined ? "" : JSON.stringify(a.body), {
+ status: a.status,
+ headers: a.headers,
+ });
+ }) as typeof fetch;
+ return { f, asked };
+}
+
+const noSleep = { calls: 0, fn: async () => {} };
+function sleeper() {
+ const s = { calls: 0, ms: [] as number[], fn: async (ms: number) => void (s.calls++, s.ms.push(ms)) };
+ return s;
+}
+const NOW = () => new Date("2026-10-06T10:05:00.000Z");
+
+test("liveUrl and cacheBusted", () => {
+ assert.equal(liveUrl("https://a.pages.dev/", "/corpus.json"), "https://a.pages.dev/corpus.json");
+ assert.equal(liveUrl("https://a.pages.dev", "posts/manifest.json"), "https://a.pages.dev/posts/manifest.json");
+ assert.equal(cacheBusted("https://a/corpus.json", "s 1"), "https://a/corpus.json?cb=s%201");
+ assert.equal(cacheBusted("https://a/c.json", "s", 3), "https://a/c.json?cb=s&try=3");
+});
+
+test("liveCheckVerdict: the table", () => {
+ const ok: Probe = { status: 200, generatedAt: NEW };
+ const old: Probe = { status: 200, generatedAt: OLD, cfCacheStatus: "HIT" };
+ const down: Probe = { status: null, error: "ECONNREFUSED" };
+ const missing: Probe = { status: 404 };
+ assert.equal(liveCheckVerdict(ok, ok, NEW), "ok");
+ assert.equal(liveCheckVerdict(old, ok, NEW), "stale-edge");
+ assert.equal(liveCheckVerdict(missing, ok, NEW), "stale-edge");
+ assert.equal(liveCheckVerdict(old, old, NEW), "mismatch");
+ assert.equal(liveCheckVerdict(ok, old, NEW), "mismatch");
+ assert.equal(liveCheckVerdict(down, down, NEW), "unreachable");
+ assert.equal(liveCheckVerdict(missing, missing, NEW), "unreachable");
+ assert.equal(liveCheckVerdict(ok, down, NEW), "ok");
+ // No expected generatedAt (a homepage): answering is all that is asked.
+ assert.equal(liveCheckVerdict({ status: 200 }, { status: 200 }, null), "ok");
+});
+
+test("ok: both reads serve this build, on the first try", async () => {
+ const { f, asked } = fakeFetch(() => ({
+ status: 200,
+ body: { generatedAt: NEW },
+ headers: { "cf-cache-status": "MISS", age: "0", "cache-control": "public, max-age=0, must-revalidate" },
+ }));
+ const s = sleeper();
+ const check = await runLiveCheck(
+ { url: "https://jeralyzer.pages.dev", builtStampId: STAMP, expected: NEW },
+ { 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.url, "https://jeralyzer.pages.dev");
+ assert.equal(check.expected, NEW);
+ assert.deepEqual(check.plain, {
+ status: 200,
+ generatedAt: NEW,
+ cfCacheStatus: "MISS",
+ age: "0",
+ cacheControl: "public, max-age=0, must-revalidate",
+ });
+ assert.deepEqual(asked, [
+ "https://jeralyzer.pages.dev/corpus.json",
+ `https://jeralyzer.pages.dev/corpus.json?cb=${STAMP}`,
+ ]);
+ assert.equal(s.calls, 0);
+ assert.match(liveCheckLines(check)[0], /^\[live\] ok — https:\/\/jeralyzer\.pages\.dev serves this build/);
+});
+
+test("stale-edge: the edge HITs an old corpus.json, the busted read is this build — three tries, 10 s apart", async () => {
+ const { f, asked } = fakeFetch((_u, busted): Answer =>
+ busted
+ ? { status: 200, body: { generatedAt: NEW }, headers: { "cf-cache-status": "MISS" } }
+ : {
+ status: 200,
+ body: { generatedAt: OLD },
+ headers: { "cf-cache-status": "HIT", age: "86400", "cache-control": "public, s-maxage=604800" },
+ },
+ );
+ const s = sleeper();
+ const check = await runLiveCheck(
+ { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW },
+ { fetch: f, sleep: s.fn, now: NOW, env: {} },
+ );
+ 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.cacheControl, "public, s-maxage=604800");
+ assert.equal(check.busted.generatedAt, NEW);
+ assert.equal(asked.length, 6);
+ assert.deepEqual(s.ms, [10_000, 10_000]);
+ // Each retry busts with a query the edge has not seen either.
+ assert.ok(asked.includes(`https://archilyzer-hub.pages.dev/corpus.json?cb=${STAMP}&try=3`));
+ const [line] = liveCheckLines(check);
+ assert.match(line, /^\[live\] WARNING stale-edge — /);
+ assert.match(line, /cf-cache-status HIT/);
+});
+
+test("a fresh deployment that answers on the second try is ok, after one sleep", async () => {
+ let n = 0;
+ const { f } = fakeFetch(() => {
+ n++;
+ return n <= 2 ? { status: 404 } : { status: 200, body: { generatedAt: NEW } };
+ });
+ const s = sleeper();
+ const check = await runLiveCheck(
+ { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW },
+ { fetch: f, sleep: s.fn, now: NOW, env: {} },
+ );
+ assert.equal(check.verdict, "ok");
+ assert.equal(s.calls, 1);
+});
+
+test("mismatch: the deployment itself serves another build", async () => {
+ const { f } = fakeFetch(() => ({ status: 200, body: { generatedAt: OLD } }));
+ const check = await runLiveCheck(
+ { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW },
+ { fetch: f, sleep: noSleep.fn, now: NOW, env: {} },
+ );
+ assert.equal(check.verdict, "mismatch");
+ assert.match(liveCheckLines(check)[0], /^\[live\] WARNING mismatch — https:\/\/x\.pages\.dev does not serve this build/);
+});
+
+test("unreachable: nothing answers; the error is recorded, never thrown", async () => {
+ const { f } = fakeFetch(() => new Error("getaddrinfo ENOTFOUND x.pages.dev"));
+ const check = await runLiveCheck(
+ { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW },
+ { fetch: f, sleep: noSleep.fn, now: NOW, env: {} },
+ );
+ assert.equal(check.verdict, "unreachable");
+ assert.equal(check.plain.status, null);
+ assert.match(check.plain.error ?? "", /ENOTFOUND/);
+ assert.match(liveCheckLines(check)[0], /^\[live\] WARNING unreachable — .*The deploy itself succeeded\.$/);
+});
+
+test("skipped: E2E_LIVE_CHECK=skip asks nothing", async () => {
+ const { f, asked } = fakeFetch(() => ({ status: 200 }));
+ const check = await runLiveCheck(
+ { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW },
+ { fetch: f, sleep: noSleep.fn, now: NOW, env: { E2E_LIVE_CHECK: "skip" } },
+ );
+ assert.equal(check.verdict, "skipped");
+ assert.deepEqual(check.plain, { status: null });
+ assert.deepEqual(asked, []);
+ assert.deepEqual(liveCheckLines(check), ["[live] skipped (E2E_LIVE_CHECK=skip)."]);
+});
+
+test("the hub's tombstones: each path read plain and busted; an old shard at the edge is stale-edge", async () => {
+ const tombstones = [
+ "posts/manifest.json",
+ "posts/thequartering-X/manifest.json",
+ "posts/thequartering-X/page-0000.json",
+ ];
+ const body = (url: string, stale: boolean): unknown => {
+ if (url.includes("/corpus.json")) return { generatedAt: NEW };
+ if (url.includes("/posts/manifest.json")) return stale ? { channels: [{ slug: "thequartering-X" }] } : { channels: [] };
+ if (url.includes("/manifest.json")) return { pageCount: stale ? 1 : 0 };
+ return stale ? [{ id: "1" }] : [];
+ };
+ // The deployment has the tombstones; the edge still serves the old shard
+ // page at its plain URL.
+ const { f, asked } = fakeFetch((url, busted) => ({
+ status: 200,
+ body: body(url, !busted && url.endsWith("page-0000.json")),
+ }));
+ const check = await runLiveCheck(
+ { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones },
+ { fetch: f, sleep: noSleep.fn, now: NOW, env: {} },
+ );
+ assert.equal(check.verdict, "stale-edge");
+ assert.deepEqual(
+ check.tombstones?.map((t) => [t.path, t.ok]),
+ [
+ ["posts/manifest.json", true],
+ ["posts/thequartering-X/manifest.json", true],
+ ["posts/thequartering-X/page-0000.json", false],
+ ],
+ );
+ assert.ok(asked.includes(`https://archilyzer-hub.pages.dev/posts/thequartering-X/page-0000.json?cb=${STAMP}`));
+ const lines = liveCheckLines(check);
+ assert.match(lines[0], /^\[live\] WARNING stale-edge — .* 1 withdrawn path\(s\) do not read as tombstones/);
+ assert.match(lines[1], /^\[live\] tombstone posts\/thequartering-X\/page-0000\.json: /);
+
+ // All three read as tombstones: ok.
+ const good = fakeFetch((url) => ({ status: 200, body: body(url, false) }));
+ const ok = await runLiveCheck(
+ { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones },
+ { fetch: good.f, sleep: noSleep.fn, now: NOW, env: {} },
+ );
+ assert.equal(ok.verdict, "ok");
+ assert.match(liveCheckLines(ok)[0], /3 withdrawn path\(s\) read as tombstones\.$/);
+
+ // The deployment itself lacks a tombstone (busted reads the old shard): mismatch.
+ const bad = fakeFetch((url) => ({ status: 200, body: body(url, url.includes("page-0000")) }));
+ const mm = await runLiveCheck(
+ { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones },
+ { fetch: bad.f, sleep: noSleep.fn, now: NOW, env: {} },
+ );
+ assert.equal(mm.verdict, "mismatch");
+});
diff --git a/common/publish/liveCheck.ts b/common/publish/liveCheck.ts
@@ -0,0 +1,268 @@
+// THE LIVE CHECK (release 18): after every deploy, read what the URL actually
+// serves.
+//
+// A deploy that exits 0 says wrangler uploaded a bundle; it does not say a
+// visitor gets it. Cloudflare's edge keeps serving a cached object after a
+// deploy that changed or removed it (the hub served a withdrawn X shard from a
+// 7-day cache after the deploy that took it out), and a deploy can land on a
+// preview when production was meant. So the deploy stage reads
+// `<url>/corpus.json` twice — PLAIN, as a visitor would, and CACHE-BUSTED
+// (`?cb=<builtStampId>`), which the edge has never seen and so fetches from the
+// deployment — and compares each `generatedAt` with the build's own
+// (`built.corpusGeneratedAt`):
+//
+// ok both serve this build
+// stale-edge the busted read serves this build, the plain one does not: the
+// deployment is right and the edge still has the old object
+// mismatch the busted read serves something else: not this build
+// unreachable neither read answered 2xx
+// skipped E2E_LIVE_CHECK=skip (the e2e suite, whose fake wrangler
+// deploys nothing)
+//
+// stale-edge and mismatch are WARNINGS: the deploy itself succeeded and the job
+// ends `done`; the verdict is recorded in deployed.json and said in the log.
+// Three tries, 10 s apart, before a verdict short of ok stands — a fresh
+// deployment takes a few seconds to answer everywhere.
+//
+// The hub's deploy also probes its TOMBSTONES (publish/tombstones.ts): each
+// 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";
+
+export type TombstoneProbe = { path: string; plain: Probe; busted: Probe; ok: boolean };
+
+export type LiveCheck = {
+ at: string;
+ url: string;
+ plain: Probe;
+ busted: Probe;
+ expected: string | null;
+ verdict: LiveCheckVerdict;
+ tombstones?: TombstoneProbe[];
+};
+
+export type LiveCheckDeps = {
+ fetch?: typeof fetch;
+ sleep?: (ms: number) => Promise<void>;
+ now?: () => Date;
+ env?: Record<string, string | undefined>;
+ tries?: number;
+ intervalMs?: number;
+ timeoutMs?: number;
+};
+
+export const LIVE_CHECK_TRIES = 3;
+export const LIVE_CHECK_INTERVAL_MS = 10_000;
+const LIVE_CHECK_TIMEOUT_MS = 15_000;
+
+/** The root-relative URL of `rel` under `base` (a site URL, alias or deployment URL). */
+export function liveUrl(base: string, rel: string): string {
+ return `${base.replace(/\/+$/, "")}/${rel.replace(/^\/+/, "")}`;
+}
+
+/** The cache-busted form of `url`: a query the edge has never seen. */
+export function cacheBusted(url: string, builtStampId: string, attempt = 1): string {
+ const cb = encodeURIComponent(builtStampId);
+ return `${url}${url.includes("?") ? "&" : "?"}cb=${cb}${attempt > 1 ? `&try=${attempt}` : ""}`;
+}
+
+const reached = (p: Probe) => p.status !== null && p.status >= 200 && p.status < 300;
+
+/**
+ * The verdict for one plain + busted pair against the build's `expected`
+ * `generatedAt` (null: the build named none, so answering 2xx is all that is
+ * asked). Pure.
+ */
+export function liveCheckVerdict(
+ plain: Probe,
+ busted: Probe,
+ expected: string | null,
+): Exclude<LiveCheckVerdict, "skipped"> {
+ const serves = (p: Probe) => reached(p) && (expected === null || p.generatedAt === expected);
+ if (!reached(plain) && !reached(busted)) return "unreachable";
+ if (serves(busted)) return serves(plain) ? "ok" : "stale-edge";
+ // The busted read failed but the plain one serves this build: a visitor
+ // gets it, which is what the check is for.
+ if (!reached(busted) && serves(plain)) return "ok";
+ return "mismatch";
+}
+
+// Whether a parsed body is the tombstone that belongs at `rel`.
+function isTombstoneBody(rel: string, body: unknown): boolean {
+ if (/\/page-\d+\.json$/.test(rel)) return Array.isArray(body) && body.length === 0;
+ if (rel === "posts/manifest.json") {
+ const ch = (body as { channels?: unknown } | null)?.channels;
+ return Array.isArray(ch) && ch.length === 0;
+ }
+ return (body as { pageCount?: unknown } | null)?.pageCount === 0;
+}
+
+type Read = { probe: Probe; body: unknown };
+
+async function read(url: string, f: typeof fetch, timeoutMs: number): Promise<Read> {
+ try {
+ const res = await f(url, { redirect: "follow", signal: AbortSignal.timeout(timeoutMs) });
+ 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");
+ if (h("cache-control")) probe.cacheControl = h("cache-control");
+ let body: unknown;
+ try {
+ body = JSON.parse(await res.text());
+ } catch {
+ body = undefined;
+ }
+ const generatedAt = (body as { generatedAt?: unknown } | undefined)?.generatedAt;
+ if (typeof generatedAt === "string") probe.generatedAt = generatedAt;
+ return { probe, body };
+ } catch (err) {
+ return {
+ probe: { status: null, error: err instanceof Error ? err.message : String(err) },
+ body: undefined,
+ };
+ }
+}
+
+/**
+ * Read `<url>/<path>` (default `corpus.json`) plain and cache-busted, and each
+ * of `tombstones` the same way; retry up to `tries` times, `intervalMs` apart,
+ * until everything reads ok. Never throws.
+ */
+export async function runLiveCheck(
+ opts: {
+ url: string;
+ builtStampId: string;
+ expected: string | null;
+ path?: string;
+ tombstones?: readonly string[];
+ },
+ deps: LiveCheckDeps = {},
+): Promise<LiveCheck> {
+ const now = deps.now ?? (() => new Date());
+ const env = deps.env ?? process.env;
+ const base: Omit<LiveCheck, "plain" | "busted" | "verdict"> = {
+ at: now().toISOString(),
+ url: opts.url,
+ expected: opts.expected,
+ };
+ if (env.E2E_LIVE_CHECK === "skip") {
+ return { ...base, plain: { status: null }, busted: { status: null }, verdict: "skipped" };
+ }
+ const f = deps.fetch ?? fetch;
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
+ const tries = Math.max(1, deps.tries ?? LIVE_CHECK_TRIES);
+ const interval = deps.intervalMs ?? LIVE_CHECK_INTERVAL_MS;
+ const timeout = deps.timeoutMs ?? LIVE_CHECK_TIMEOUT_MS;
+ const target = liveUrl(opts.url, opts.path ?? "corpus.json");
+
+ let check: LiveCheck | null = null;
+ for (let attempt = 1; attempt <= tries; attempt++) {
+ if (attempt > 1) await sleep(interval);
+ const plain = (await read(target, f, timeout)).probe;
+ const busted = (await read(cacheBusted(target, opts.builtStampId, attempt), f, timeout)).probe;
+ let verdict: LiveCheckVerdict = liveCheckVerdict(plain, busted, opts.expected);
+ let tombstones: TombstoneProbe[] | undefined;
+ if (opts.tombstones && opts.tombstones.length > 0) {
+ tombstones = [];
+ let staleOnly = true;
+ for (const rel of opts.tombstones) {
+ const u = liveUrl(opts.url, rel);
+ const p = await read(u, f, timeout);
+ const b = await read(cacheBusted(u, opts.builtStampId, attempt), f, timeout);
+ const pOk = reached(p.probe) && isTombstoneBody(rel, p.body);
+ const bOk = reached(b.probe) && isTombstoneBody(rel, b.body);
+ tombstones.push({ path: rel, plain: p.probe, busted: b.probe, ok: pOk && bOk });
+ if (!(pOk && bOk) && !bOk) staleOnly = false;
+ }
+ // The corpus reads ok but a tombstone does not: the edge still serves
+ // the withdrawn object (stale-edge) — or the deployment never had it.
+ if (verdict === "ok" && tombstones.some((t) => !t.ok)) {
+ verdict = staleOnly ? "stale-edge" : "mismatch";
+ }
+ }
+ check = { ...base, plain, busted, verdict, ...(tombstones ? { tombstones } : {}) };
+ if (verdict === "ok") break;
+ }
+ return check!;
+}
+
+function describe(p: Probe): string {
+ if (p.status === null) return p.error ? `no answer (${p.error})` : "no answer";
+ const parts = [`HTTP ${p.status}`];
+ if (p.generatedAt) parts.push(`generatedAt ${p.generatedAt}`);
+ if (p.cfCacheStatus) parts.push(`cf-cache-status ${p.cfCacheStatus}`);
+ if (p.age) parts.push(`age ${p.age}`);
+ if (p.cacheControl) parts.push(`cache-control "${p.cacheControl}"`);
+ return parts.join(", ");
+}
+
+/**
+ * The log lines a live check ends a deploy on: one verdict line (WARNING for
+ * anything short of ok but skipped), and one per tombstone that read wrong.
+ */
+export function liveCheckLines(check: LiveCheck): string[] {
+ const where = check.url;
+ const want = check.expected ? `this build (generatedAt ${check.expected})` : "this build";
+ switch (check.verdict) {
+ case "skipped":
+ return ["[live] skipped (E2E_LIVE_CHECK=skip)."];
+ case "ok": {
+ const n = check.tombstones?.length ?? 0;
+ return [
+ `[live] ok — ${where} serves ${want}; plain: ${describe(check.plain)}` +
+ (n > 0 ? `; ${n} withdrawn path(s) read as tombstones.` : "."),
+ ];
+ }
+ case "unreachable":
+ return [
+ `[live] WARNING unreachable — ${where} did not answer: plain ${describe(check.plain)}; ` +
+ `cache-busted ${describe(check.busted)}. The deploy itself succeeded.`,
+ ];
+ case "stale-edge":
+ case "mismatch": {
+ const bad = (check.tombstones ?? []).filter((t) => !t.ok);
+ const corpusOk = liveCheckVerdict(check.plain, check.busted, check.expected) === "ok";
+ const lines = corpusOk
+ ? [
+ `[live] WARNING ${check.verdict} — ${where} serves ${want}, but ${bad.length} withdrawn ` +
+ `path(s) do not read as tombstones` +
+ (check.verdict === "stale-edge"
+ ? ": the deployment has them, Cloudflare's edge still serves the old objects until they expire."
+ : ": the deployment does not serve them."),
+ ]
+ : check.verdict === "stale-edge"
+ ? [
+ `[live] WARNING stale-edge — the deployment serves ${want}, but ${where} still answers ` +
+ `with an older object from Cloudflare's edge: plain ${describe(check.plain)}; ` +
+ `cache-busted ${describe(check.busted)}. It is replaced when the edge's copy expires.`,
+ ]
+ : [
+ `[live] WARNING mismatch — ${where} does not serve ${want}: plain ${describe(check.plain)}; ` +
+ `cache-busted ${describe(check.busted)}.`,
+ ];
+ for (const t of bad) {
+ lines.push(
+ `[live] tombstone ${t.path}: plain ${describe(t.plain)}; cache-busted ${describe(t.busted)}.`,
+ );
+ }
+ return lines;
+ }
+ }
+}
diff --git a/editor/e2e/fixtures/bin/fake-wrangler.mjs b/editor/e2e/fixtures/bin/fake-wrangler.mjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+// E2E fake wrangler (release 18). The deploy stage spawns `wranglerBin(paths)`
+// — WRANGLER_BIN when set, which playwright.config.ts points here — with
+//
+// pages deploy <outDir> --project-name <project> --branch <branch>
+//
+// (common/lib/pagesDeploy.ts pagesDeployArgs). The suite must never reach
+// Cloudflare, so this deploys nothing. What it does:
+//
+// - records every invocation in an argv sidecar, `.fake-wrangler.json`,
+// BESIDE the bundle (the directory holding <outDir>): `{ invocations: [{
+// argv, cwd, project, branch, outDir, files }] }`, appended, so a spec reads
+// what was "deployed", from where, to which branch;
+// - prints wrangler 4's success lines, ending on "Take a peek over at
+// https://<branch>.<project>.pages.dev" — the line deploymentUrlIn reads;
+// - with E2E_FAKE_WRANGLER_AUTH_FAIL=1, prints wrangler's own refusal for a
+// token Cloudflare does not accept ("Authentication error [code: 10000]")
+// and exits 1, without writing the sidecar — the deploy stage must turn
+// that into "[deploy] REFUSED by Cloudflare — the API token was not
+// accepted" and leave deployed.json untouched;
+// - answers `--version` like the pinned binary.
+//
+// A missing <outDir> fails as wrangler does. Anything else is a usage error.
+import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { installFixtureWatchdog } from "./_watchdog.mjs";
+
+// Never outlive the run that spawned us — see _watchdog.mjs.
+installFixtureWatchdog();
+
+const VERSION = "4.147.0";
+const argv = process.argv.slice(2);
+
+if (argv[0] === "--version" || argv[0] === "-v") {
+ process.stdout.write(`${VERSION}\n`);
+ process.exit(0);
+}
+
+const opt = (flag) => {
+ const i = argv.indexOf(flag);
+ return i < 0 ? undefined : argv[i + 1];
+};
+
+if (argv[0] !== "pages" || argv[1] !== "deploy" || !argv[2] || argv[2].startsWith("--")) {
+ process.stderr.write(`[fake-wrangler] unsupported invocation: ${argv.join(" ")}\n`);
+ process.exit(2);
+}
+const outDir = path.resolve(argv[2]);
+const project = opt("--project-name");
+const branch = opt("--branch") ?? "main";
+
+process.stdout.write(`\n ⛅️ wrangler ${VERSION} (fake)\n───────────────────\n`);
+
+if (process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") {
+ process.stderr.write(
+ `\n✘ [ERROR] A request to the Cloudflare API (/accounts/0000/pages/projects/${project ?? "?"}) failed.\n\n` +
+ " Authentication error [code: 10000]\n\n" +
+ " 📎 It looks like you are authenticating Wrangler via a custom API token set in an environment variable.\n" +
+ " Please ensure it has the correct permissions for this operation.\n\n",
+ );
+ process.exit(1);
+}
+
+if (!existsSync(outDir) || !statSync(outDir).isDirectory()) {
+ process.stderr.write(`\n✘ [ERROR] The directory specified ("${argv[2]}") does not exist.\n`);
+ process.exit(1);
+}
+if (!project) {
+ process.stderr.write("\n✘ [ERROR] Must specify a project name.\n");
+ process.exit(1);
+}
+
+let files = 0;
+const walk = (d) => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ if (e.isDirectory()) walk(path.join(d, e.name));
+ else files++;
+ }
+};
+walk(outDir);
+
+const sidecar = path.join(path.dirname(outDir), ".fake-wrangler.json");
+let record = { invocations: [] };
+try {
+ record = JSON.parse(readFileSync(sidecar, "utf8"));
+ if (!Array.isArray(record.invocations)) record = { invocations: [] };
+} catch {
+ // first invocation
+}
+record.invocations.push({ argv, cwd: process.cwd(), project, branch, outDir, files });
+writeFileSync(sidecar, `${JSON.stringify(record, null, 2)}\n`);
+
+process.stdout.write(`Uploading... (${files}/${files})\n`);
+process.stdout.write(`✨ Success! Uploaded ${files} files (0 already uploaded) (0.01 sec)\n\n`);
+process.stdout.write("🌎 Deploying...\n");
+process.stdout.write(`✨ Deployment complete! Take a peek over at https://${branch}.${project}.pages.dev\n`);
diff --git a/editor/e2e/site-publish-preview.spec.ts b/editor/e2e/site-publish-preview.spec.ts
@@ -5,8 +5,10 @@ import { resetData, writeSite } from "./helpers";
// The preview control on a site's Publish tab.
//
// NOTHING HERE CLICKS DEPLOY. Every external binary the suite touches has a
-// fake in e2e/fixtures/bin; wrangler has none, because no spec has ever had a
-// reason to reach a deploy. What is worth pinning anyway is everything decided
+// fake in e2e/fixtures/bin — wrangler's is fake-wrangler.mjs since release 18
+// (WRANGLER_BIN in playwright.config.ts; it deploys nothing and writes its argv
+// beside the bundle), and the deploy stage itself is publish.spec's. What is
+// pinned here is everything decided
// BEFORE the job: that the two deploys are told apart by their labels, that the
// branch input refuses exactly what the server refuses and with the same
// sentence, and that the alias is shown while you type — it is a function of
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -25,11 +25,24 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// fake-ytdlp.mjs's env fallbacks behind its
// .fake-ytdlp-audio-check.json sidecar
// E2E_FAKE_GALLERY_DL_AUTH_FAIL fake-gallery-dl.mjs fails as an auth error
+// E2E_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare
+// refusing the API token ("Authentication error
+// [code: 10000]"), for the deploy stage's
+// "[deploy] REFUSED by Cloudflare" sentence
+// E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and
+// records "skipped" (common/publish/liveCheck.ts):
+// the fake wrangler deploys nothing to read. Set
+// for the test server below
// E2E_FIXTURE_MAX_LIFETIME_MS a fake binary's watchdog budget
// E2E_OLLAMA_STUB_MODEL the model the ollama stub claims to serve
// E2E_SHARDS, E2E_IMAGE, E2E_SKIP_BUILD, E2E_RETRIES
// the sharded runner (scripts/run-sharded-e2e.mjs)
//
+// WRANGLER_BIN is not prefixed either: it is the deploy stage's own override
+// (common/lib/pagesDeploy.ts wranglerBin), pointed below at
+// e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake
+// writes its argv to `.fake-wrangler.json` beside the bundle it was handed.
+//
// The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per
// worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor
// are the queue's own E2E_QUEUE / E2E_PORT_CHECK / E2E_QUEUE_TIMEOUT (read by
@@ -41,6 +54,8 @@ const E2E_SERVER_ENV = {
E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS: "50",
E2E_AUDIO_CHECK_RECOVER_STEP_MS: "100",
E2E_AUDIO_CHECK_RECOVER_AFTER: "2",
+ E2E_LIVE_CHECK: "skip",
+ WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"),
};
const PORT = portFor("PORT");