Archilyzer · Source

archilyzer

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

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:
MENVIRONMENT.md | 10+++++++++-
Mcommon/lib/envVars.ts | 10+++++++++-
Mcommon/lib/pagesDeploy.ts | 4+---
Acommon/publish/deployStage.test.ts | 486+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/deployStage.ts | 515+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.test.ts | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.ts | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/fixtures/bin/fake-wrangler.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/site-publish-preview.spec.ts | 6++++--
Meditor/playwright.config.ts | 15+++++++++++++++
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");