Archilyzer · Source

archilyzer

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

commit a8de529ce42503854b06a2a1fb6d5283834f3040
parent 94bf53d32b09e7a69d7686681a3d151b88fdf710
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 09:28:47 -0400

common: review fixes — the hub never names a private-only X channel; deploy targets pinned; usage exit 2; Cancel reaches the live check

- H1: the hub's tombstones are limited to X channels a non-private site
  carries (withdrawnXChannels takes the sites); a channel only private
  sites, or no site, carry is never named in the hub's public/.
- M1: wrangler's lines go through the stage's log(), each ending in a newline.
- M2: deploy-hub / deploy-homepage take only "_hub" / "_homepage", and a
  site deploy refuses either; a mismatch is refused (exit 2) and writes nothing.
- M3: a tombstone's maxPageBytes is 0.
- Lows: the live check takes the stage's signal (reads abort, the interval
  sleep wakes, no retry after Cancel; a deploy cancelled mid-check is
  recorded without one and ends 130); a failed plain read is `unreachable`,
  not `stale-edge`; request-shape refusals exit 2; the R2 and wrangler
  failures throw the line they logged; `--to local` refuses a destination
  that holds the checkout, the corpus, the builds or the bundle, or is
  non-empty with no index.html.

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

Diffstat:
Mcommon/bin/compose-hub.test.ts | 35+++++++++++++++++++++++++++++++++--
Mcommon/bin/compose-hub.ts | 13++++++++-----
Mcommon/publish/deployStage.test.ts | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/publish/deployStage.ts | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcommon/publish/liveCheck.test.ts | 45++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/liveCheck.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcommon/publish/tombstones.test.ts | 33+++++++++++++++++++++++----------
Mcommon/publish/tombstones.ts | 50++++++++++++++++++++++++++++----------------------
8 files changed, 374 insertions(+), 73 deletions(-)

diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts @@ -5,6 +5,7 @@ import { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, statSync, @@ -273,7 +274,9 @@ test("compose-hub removes a site's data from public/, a linked entry by its link // paths — path for path, the rollout's requirement: posts/manifest.json (empty), // posts/thequartering-X/manifest.json (pageCount 0), posts/thequartering-X/ // page-0000.json ([]) — and serves posts/ no-store. A Bluesky channel is not X -// and gets nothing; with X public the hub ships no posts/ at all. +// and gets nothing; with X public the hub ships no posts/ at all. PRIVATE DATA +// IS NEVER NAMED ON THE HUB: an X channel only a private site carries, and one +// no site carries, get no tombstone — their slugs appear nowhere in public/. test("while X posts are private, the hub ships tombstones for every X channel, served no-store", async () => { const root = mkdtempSync(path.join(tmpdir(), "compose-hub-")); const log = console.log; @@ -286,6 +289,8 @@ test("while X posts are private, the hub ships tombstones for every X channel, s }; const X = "thequartering-X"; const SKY = "jer-sky"; + const PRIVATE_X = "only-private-x"; + const NO_SITE_X = "no-site-x"; writeJson(path.join(paths.channelsDir, X, "config.json"), { handling: "youtube", url: "https://x.com/x", @@ -298,7 +303,24 @@ test("while X posts are private, the hub ships tombstones for every X channel, s sourceKind: "social", platform: "bluesky", }); - for (const slug of [X, SKY]) { + for (const slug of [PRIVATE_X, NO_SITE_X]) { + writeJson(path.join(paths.channelsDir, slug, "config.json"), { + handling: "youtube", + url: `https://x.com/${slug}`, + sourceKind: "social", + platform: "twitter", + }); + } + const site = (siteId: string, slugs: string[], extra: Record<string, unknown> = {}) => + writeJson(path.join(paths.sitesDir, siteId, "site.json"), { + siteId, + siteTitle: siteId, + channels: slugs.map((slug) => ({ slug, groupId: "default" })), + ...extra, + }); + site("pub", [X, SKY]); + site("mine", [PRIVATE_X, X], { audience: "private" }); + for (const slug of [X, SKY, PRIVATE_X, NO_SITE_X]) { writeJson(path.join(paths.exportSharedPostsDir, slug, "manifest.json"), { version: 1, channelSlug: slug, @@ -321,6 +343,15 @@ test("while X posts are private, the hub ships tombstones for every X channel, s assert.deepEqual(read(`posts/${X}/manifest.json`).slugToPage, {}); assert.deepEqual(read(`posts/${X}/page-0000.json`), []); assert.ok(!existsSync(path.join(pub, "posts", SKY)), "a Bluesky channel is not withdrawn"); + // Private data is never named on the hub. + assert.deepEqual(readdirSync(path.join(pub, "posts")).sort(), ["manifest.json", X]); + for (const hidden of [PRIVATE_X, NO_SITE_X]) { + const named = readdirSync(pub, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .filter((e) => readFileSync(path.join(e.parentPath, e.name), "utf8").includes(hidden)); + assert.deepEqual(named.map((e) => e.name), [], `${hidden} is named in the hub's public/`); + assert.ok(!existsSync(path.join(pub, "posts", hidden))); + } const headers = readFileSync(path.join(pub, "_headers"), "utf8"); assert.ok( headers.endsWith( diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts @@ -15,8 +15,9 @@ // public/search-aliases.json <- the global alias dictionary // public/posts/ <- TOMBSTONES only, while X posts are private: // an empty posts/manifest.json and, per X -// channel with a shared posts tree, its -// manifest at pageCount 0 and `[]` pages +// channel with a shared posts tree that a +// non-private site carries, its manifest at +// pageCount 0 and `[]` pages // (publish/tombstones.ts), served no-store // // and REMOVES every per-site entry a site's compose left in public/ @@ -128,15 +129,17 @@ export const SITE_ONLY_PUBLIC_ENTRIES: readonly string[] = [ // kept serving them after the deploy that removed them. Removing is not enough // at the edge, so while X posts are private the hub REPLACES every path an X // channel's posts could have been served from: an empty posts manifest, and per -// X channel with a shared posts tree its manifest at pageCount 0 and an empty -// page for each page the shared tree holds now — all served no-store. With X +// X channel with a shared posts tree that a non-private site carries, its +// manifest at pageCount 0 and an empty page for each page the shared tree holds +// now — all served no-store. A channel only private sites (or no site) carry is +// never named here: private data is never named on the hub. With X // posts public, or no X channel, the hub ships no posts/ at all, as before. async function composeHubTombstones( paths: Paths, publicDir: string, settings: { social?: { x?: { visibility?: unknown } } }, ): Promise<PostsTombstone[]> { - const slugs = await withdrawnXChannels(paths, settings); + const slugs = await withdrawnXChannels(paths, settings, listSites(paths)); if (slugs.length === 0) return []; const postsDir = path.join(publicDir, "posts"); const tombstones = await writePostsTombstones({ diff --git a/common/publish/deployStage.test.ts b/common/publish/deployStage.test.ts @@ -274,12 +274,13 @@ test("every refusal leaves deployed.json untouched, and wrangler unspawned", asy 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 production branch as preview", { kind: "deploy-site", target: "anilyzer", preview: "main" }, TOKEN, 2, /is the production branch/], + ["the hub's target as a site", { kind: "deploy-site", target: "_hub" }, TOKEN, 2, /"_hub" is not a site — deploy it with deploy-hub/], ["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 and preview at once", { kind: "deploy-site", target: "anilyzer", to: "local", preview: "p" }, TOKEN, 2, /a local deploy has no preview branch/], ["local with nowhere to copy", { kind: "deploy-site", target: "anilyzer", to: "local" }, TOKEN, 1, /needs ARCHILYZER_SITE_OUT/], ]; site(fx, "nobuild"); @@ -375,6 +376,8 @@ test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replac const stamp = built(fx, "anilyzer"); const dest = path.join(fx.root, "site-out"); mkdirSync(dest); + // What an earlier local deploy (or the container's placeholder) left. + writeFileSync(path.join(dest, "index.html"), "old"); 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" }); @@ -396,6 +399,105 @@ test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replac /is private/, ); assert.ok(existsSync(path.join(dest, "site.json"))); + + // A destination that does not look like a bundle the copy made is not + // emptied: non-empty with no index.html, or one holding the checkout. + const notOurs = path.join(fx.root, "somebody-else"); + mkdirSync(notOurs); + writeFileSync(path.join(notOurs, "notes.txt"), "keep"); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: notOurs }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + /somebody-else is not empty and holds no index\.html/, + ); + assert.deepEqual(readdirSync(notOurs), ["notes.txt"]); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: fx.root }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + /holds the checkout/, + ); + assert.ok(existsSync(path.join(fx.root, "sites", "anilyzer", "site.json")), "nothing was emptied"); + } finally { + fx.cleanup(); + } +}); + +test("the hub's and the homepage's targets are fixed: a mismatch is a usage error and writes nothing", async () => { + const fx = fixture(); + try { + site(fx, "jeralyzer"); + built(fx, "jeralyzer"); + const before = deployedBytes(fx, "jeralyzer"); + for (const req of [ + { kind: "deploy-homepage", target: "jeralyzer" }, + { kind: "deploy-hub", target: "jeralyzer" }, + { kind: "deploy-homepage", target: "_hub" }, + ] as DeployStageRequest[]) { + const c = ctx(fx, TOKEN); + await refused(runDeployStage(c, req), 2, new RegExp(`^\\[deploy\\] REFUSED — ${req.kind} deploys "_(hub|homepage)", not "${req.target}"\\.$`)); + assert.deepEqual(c.asked, []); + } + assert.equal(deployedBytes(fx, "jeralyzer"), before); + assert.equal(deployedBytes(fx, "jeralyzer"), null); + assert.deepEqual(sidecar(fx, "jeralyzer"), []); + } finally { + fx.cleanup(); + } +}); + +test("wrangler's own lines reach the log as lines, each ending in a newline", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const c = ctx(fx, TOKEN); + await runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }); + assert.ok(c.lines.some((l) => l.startsWith("✨ Deployment complete!")), c.lines.join("")); + for (const l of c.lines) assert.ok(l.endsWith("\n"), JSON.stringify(l)); + } finally { + fx.cleanup(); + } +}); + +test("an R2 failure throws the line it logged, and nothing is spawned or recorded", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const c = ctx(fx, TOKEN, { uploadArchives: async () => 7 }); + await refused( + runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), + 1, + /^\[deploy\] FAILED — the archive R2 upload exited 7; nothing was sent to Cloudflare Pages\.$/, + ); + assert.equal(c.lines.at(-1), "[deploy] FAILED — the archive R2 upload exited 7; nothing was sent to Cloudflare Pages.\n"); + assert.deepEqual(sidecar(fx, "anilyzer"), []); + assert.equal(deployedBytes(fx, "anilyzer"), null); + } finally { + fx.cleanup(); + } +}); + +test("Cancel during the live check: the deploy is recorded without a check, and the stage ends cancelled", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + const stamp = built(fx, "anilyzer"); + const cancel = new AbortController(); + const c = ctx(fx, TOKEN, { + signal: cancel.signal, + liveCheck: { + sleep: async () => {}, + fetch: (async () => { + cancel.abort(); + return new Response(JSON.stringify({ generatedAt: GENERATED }), { status: 200 }); + }) as typeof fetch, + }, + }); + await refused(runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), 130, /cancelled during the live check/); + const rec = readDeployedFile(path.join(fx.paths.exportBuildsDir, "anilyzer"), "anilyzer"); + assert.equal(rec.production?.builtStampId, stamp.stampId); + assert.equal(rec.production?.liveCheck, null); } finally { fx.cleanup(); } @@ -493,7 +595,7 @@ test("the hub: its project, its bundle, and every tombstone probed plain and bus 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, + 2, /the hub has no local target/, ); } finally { diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts @@ -35,8 +35,10 @@ // 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. +// stage contract names (1 refused/failed, 2 a request the stage cannot run — a +// bad branch name, a kind and target that do not match, local with a preview — +// 3 precondition not met, 130 cancelled); its message is the sentence the log +// already ends on, word for word. // // 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. @@ -159,7 +161,7 @@ export type DeployStageOutcome = { status: "ran" | "noop"; stamp: string; summar export class DeployStageError extends Error { constructor( message: string, - readonly exitCode: 1 | 3 | 130, + readonly exitCode: 1 | 2 | 3 | 130, ) { super(message); this.name = "DeployStageError"; @@ -269,8 +271,49 @@ function readdirSyncDirs(dir: string): string[] { .sort(); } +// Why `dest` may not be emptied and filled with `outDir`, or null. The local +// copy EMPTIES its destination, and the stage runs on hosts as well as in the +// container, so a mis-set ARCHILYZER_SITE_OUT (a home dir, the repo, a data +// volume) must not be wiped: the destination may not be, or contain, the +// checkout, the corpus, the builds or the bundle, and a non-empty destination +// must look like a bundle this copy made (an `index.html` at its top — the +// container's placeholder page has one too). +export async function localDestProblem( + dest: string, + outDir: string, + paths: Pick<Paths, "monorepoRoot" | "transcriptsDir" | "exportBuildsDir" | "exportDir">, +): Promise<string | null> { + const d = path.resolve(dest); + const inside = (parent: string, child: string) => { + const rel = path.relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }; + for (const [what, dir] of [ + ["the checkout", paths.monorepoRoot], + ["the corpus", paths.transcriptsDir], + ["the builds directory", paths.exportBuildsDir], + ["export/", paths.exportDir], + ["the bundle", outDir], + ] as const) { + if (inside(d, path.resolve(dir)) || inside(path.resolve(outDir), d)) { + return `${d} holds ${what} (or is inside the bundle) — a local deploy empties its destination; set it to the directory the local server serves.`; + } + } + let entries: string[]; + try { + entries = await readdir(d); + } catch { + return null; // absent: it is made + } + if (entries.length > 0 && !entries.includes("index.html")) { + return `${d} is not empty and holds no index.html, so it is not a bundle a local deploy made — a local deploy empties its destination; empty it by hand or point the variable elsewhere.`; + } + return null; +} + // Copy `outDir`'s contents into `dest`, emptying it first — its CONTENTS, // never the directory: in the container it is a volume mount point. +// localDestProblem is asked first. async function copyToLocal(outDir: string, dest: string): Promise<number> { await mkdir(dest, { recursive: true }); for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true }); @@ -299,22 +342,32 @@ export async function runDeployStage( 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 refuse = (why: string, exitCode: 1 | 2 | 3 = 1): never => { const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`; log(line); throw new DeployStageError(line, exitCode); }; - // --- 1. the request --- + // --- 1. the request (a refusal here is a usage error, exit 2) --- const target = req.target.trim(); + // The hub's and the homepage's targets are fixed, and a site's is never one + // of them: a mismatch would read and WRITE another target's stamps (a + // homepage deploy recorded in a site's production slot). + const fixed = req.kind === "deploy-hub" ? HUB_TARGET : req.kind === "deploy-homepage" ? HOMEPAGE_TARGET : null; + if (fixed !== null && target !== fixed) { + refuse(`${req.kind} deploys "${fixed}", not "${target}".`, 2); + } + if (fixed === null && (target === HUB_TARGET || target === HOMEPAGE_TARGET)) { + refuse(`"${target}" is not a site — deploy it with ${target === HUB_TARGET ? "deploy-hub" : "deploy-homepage"}.`, 2); + } const toLocal = req.to === "local"; if (req.preview !== undefined) { const problem = previewBranchProblem(req.preview); - if (problem) refuse(problem); + if (problem) refuse(problem, 2); } 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."); + if (toLocal && branch) refuse("a local deploy has no preview branch — deploy locally or as a preview, not both.", 2); + if (toLocal && req.kind === "deploy-hub") refuse("the hub has no local target — deploy it to Cloudflare Pages.", 2); const recordKind: DeployRecord["kind"] = toLocal ? "local" : branch ? "preview" : "production"; // --- 2. the target's own refusals --- @@ -413,6 +466,8 @@ export async function runDeployStage( : "a local deploy needs ARCHILYZER_SITE_OUT — the directory the local site service serves (the container sets it).", ); } + const destProblem = await localDestProblem(dest, outDir, paths); + if (destProblem) refuse(destProblem); log(`[deploy] ${target} → ${dest} (local)`); const files = await copyToLocal(outDir, dest); if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); @@ -440,8 +495,9 @@ export async function runDeployStage( 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); + const line = `[deploy] FAILED — the archive R2 upload exited ${code}; nothing was sent to Cloudflare Pages.`; + log(line); + throw new DeployStageError(line, 1); } } @@ -451,7 +507,9 @@ export async function runDeployStage( const watch = (line: string) => { if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project); if (!authRefused && wranglerAuthFailureIn(line)) authRefused = true; - ctx.onLog(line); + // Through log(): runChildIntoLog hands lines without their newline, and a + // stage child writing raw to stdout would run wrangler's output together. + log(line); }; const code = await runChildIntoLog(watch, signal, { command: wranglerBin(paths, env), @@ -465,8 +523,9 @@ export async function runDeployStage( 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 line = `[deploy] FAILED — wrangler exited ${code}.`; + log(line); + throw new DeployStageError(line, 1); } const alias = branch ? previewAliasUrl(project, branch) : undefined; const shipped: string | null = deploymentUrl; @@ -485,9 +544,12 @@ export async function runDeployStage( path: req.kind === "deploy-homepage" ? "" : "corpus.json", tombstones: req.kind === "deploy-hub" ? hubTombstoneProbes(outDir) : undefined, }, - { env, ...ctx.liveCheck }, + { env, signal, ...ctx.liveCheck }, ); - for (const line of liveCheckLines(liveCheck)) log(line); + // Cancelled while checking: the deploy itself happened, so it is recorded — + // with no live check — and the stage ends cancelled. + if (signal.aborted) liveCheck = null; + else for (const line of liveCheckLines(liveCheck)) log(line); } else { log("[live] no URL to check: the site has no public URL and wrangler printed no deployment URL."); } @@ -504,6 +566,11 @@ export async function runDeployStage( ...(wranglerLabel(paths, env) ? { wrangler: wranglerLabel(paths, env) } : {}), liveCheck, }); + if (signal.aborted) { + const line = `[deploy] cancelled during the live check — ${target} was deployed and is recorded without one.`; + log(line); + throw new DeployStageError(line, 130); + } const verdict = liveCheck ? ` — live check: ${liveCheck.verdict}` : ""; const summary = `${target}: build ${b.stampId} deployed to ${recordKind === "preview" ? `preview "${branch}"` : "production"}` + diff --git a/common/publish/liveCheck.test.ts b/common/publish/liveCheck.test.ts @@ -57,7 +57,9 @@ test("liveCheckVerdict: the table", () => { 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"); + // A plain read that fails is not staleness: a visitor gets nothing. + assert.equal(liveCheckVerdict(missing, ok, NEW), "unreachable"); + assert.equal(liveCheckVerdict(down, ok, NEW), "unreachable"); assert.equal(liveCheckVerdict(old, old, NEW), "mismatch"); assert.equal(liveCheckVerdict(ok, old, NEW), "mismatch"); assert.equal(liveCheckVerdict(down, down, NEW), "unreachable"); @@ -229,3 +231,44 @@ test("the hub's tombstones: each path read plain and busted; an old shard at the ); assert.equal(mm.verdict, "mismatch"); }); + +test("Cancel stops the check: no read after the abort, no sleep waited out", async () => { + const cancel = new AbortController(); + let reads = 0; + const { f } = fakeFetch((_u, busted): Answer => { + reads++; + // The first pair reads an old edge copy; Cancel arrives during the busted read. + if (busted) cancel.abort(); + return { status: 200, body: { generatedAt: busted ? NEW : OLD } }; + }); + const s = sleeper(); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {}, signal: cancel.signal }, + ); + assert.equal(reads, 2, "no retry after Cancel"); + assert.equal(s.calls, 0); + assert.equal(check.verdict, "stale-edge", "what was read is kept"); + + // Cancelled before anything was read: unreachable, naming the cancel. + const early = new AbortController(); + early.abort(); + const none = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {}, signal: early.signal }, + ); + assert.equal(none.verdict, "unreachable"); + assert.equal(none.plain.error, "cancelled"); + + // The default sleep wakes on the abort rather than waiting its 10 s out. + const mid = new AbortController(); + const old = fakeFetch(() => ({ status: 200, body: { generatedAt: OLD } })); + const started = Date.now(); + setTimeout(() => mid.abort(), 50); + const stopped = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: old.f, now: NOW, env: {}, signal: mid.signal }, + ); + assert.ok(Date.now() - started < 5_000, "the 10 s interval was not waited out"); + assert.equal(stopped.verdict, "mismatch"); +}); diff --git a/common/publish/liveCheck.ts b/common/publish/liveCheck.ts @@ -11,11 +11,14 @@ // 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 +// ok both serve this build (or the plain one does and the busted +// read failed: a visitor gets this build) +// stale-edge the busted read serves this build, the plain one answers 2xx +// with something else: 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 +// unreachable neither read answered 2xx, or the plain read failed (a +// visitor gets nothing, whatever the deployment holds) // skipped E2E_LIVE_CHECK=skip (the e2e suite, whose fake wrangler // deploys nothing) // @@ -58,6 +61,8 @@ export type LiveCheck = { }; export type LiveCheckDeps = { + // The stage's Cancel: stops between reads and tries, aborts a read in flight. + signal?: AbortSignal; fetch?: typeof fetch; sleep?: (ms: number) => Promise<void>; now?: () => Date; @@ -96,7 +101,12 @@ export function liveCheckVerdict( ): 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"; + if (serves(busted)) { + if (serves(plain)) return "ok"; + // Stale only when the edge ANSWERS with another object; a plain read that + // fails is not staleness — a visitor gets nothing. + return reached(plain) ? "stale-edge" : "unreachable"; + } // 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"; @@ -115,9 +125,11 @@ function isTombstoneBody(rel: string, body: unknown): boolean { type Read = { probe: Probe; body: unknown }; -async function read(url: string, f: typeof fetch, timeoutMs: number): Promise<Read> { +async function read(url: string, f: typeof fetch, timeoutMs: number, cancel?: AbortSignal): Promise<Read> { try { - const res = await f(url, { redirect: "follow", signal: AbortSignal.timeout(timeoutMs) }); + const timeout = AbortSignal.timeout(timeoutMs); + const signal = cancel ? AbortSignal.any([cancel, timeout]) : timeout; + const res = await f(url, { redirect: "follow", signal }); 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"); @@ -143,7 +155,10 @@ async function read(url: string, f: typeof fetch, timeoutMs: number): Promise<Re /** * 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. + * until everything reads ok. Never throws. A `signal` that aborts stops it + * between reads and tries (the result then carries what was read, or an + * `unreachable` naming the cancel); the caller decides what a cancelled check + * means. */ export async function runLiveCheck( opts: { @@ -166,7 +181,19 @@ export async function runLiveCheck( 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 cancel = deps.signal; + const sleep = + deps.sleep ?? + ((ms: number) => + new Promise<void>((resolve) => { + const t = setTimeout(done, ms); + function done() { + clearTimeout(t); + cancel?.removeEventListener("abort", done); + resolve(); + } + cancel?.addEventListener("abort", done, { once: true }); + })); 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; @@ -175,17 +202,19 @@ export async function runLiveCheck( 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; + if (cancel?.aborted) break; + const plain = (await read(target, f, timeout, cancel)).probe; + const busted = (await read(cacheBusted(target, opts.builtStampId, attempt), f, timeout, cancel)).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) { + if (cancel?.aborted) break; 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 p = await read(u, f, timeout, cancel); + const b = await read(cacheBusted(u, opts.builtStampId, attempt), f, timeout, cancel); 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 }); @@ -198,9 +227,16 @@ export async function runLiveCheck( } } check = { ...base, plain, busted, verdict, ...(tombstones ? { tombstones } : {}) }; - if (verdict === "ok") break; + if (verdict === "ok" || cancel?.aborted) break; } - return check!; + return ( + check ?? { + ...base, + plain: { status: null, error: "cancelled" }, + busted: { status: null, error: "cancelled" }, + verdict: "unreachable", + } + ); } function describe(p: Probe): string { diff --git a/common/publish/tombstones.test.ts b/common/publish/tombstones.test.ts @@ -45,12 +45,12 @@ function sharedTree(shared: string, slug: string, pages: number) { } } -test("the tombstone shapes: a channel manifest with no pages, a site manifest with no channels", () => { - assert.deepEqual(tombstoneChannelManifest("x", "T", 9), { +test("the tombstone shapes: a channel manifest with no pages and no size, a site manifest with no channels", () => { + assert.deepEqual(tombstoneChannelManifest("x", "T"), { version: 1, channelSlug: "x", pageCount: 0, - maxPageBytes: 9, + maxPageBytes: 0, generatedAt: "T", slugToPage: {}, }); @@ -98,7 +98,8 @@ test("writePostsTombstones: one empty page per shared page, replacing a real tre for (const page of ["page-0000.json", "page-0001.json", "page-0002.json"]) { assert.deepEqual(readJson(path.join(posts, "big-x", page)), []); } - assert.deepEqual(readJson(path.join(posts, "big-x", "manifest.json")), tombstoneChannelManifest("big-x", "T", 4096)); + // The shared tree's page cap (4096) is not carried: a tombstone has no size. + assert.deepEqual(readJson(path.join(posts, "big-x", "manifest.json")), tombstoneChannelManifest("big-x", "T")); assert.deepEqual(readdirSync(path.join(posts, "linked-x")), ["manifest.json"]); assert.deepEqual(readJson(path.join(primary, "page-0000.json")), [{ id: "keep" }]); @@ -131,7 +132,7 @@ test("the no-store paths: a site names its manifest and each tombstone; the hub assert.deepEqual(tombstoneNoStoreForHub([]), []); }); -test("withdrawnXChannels: the X channels with a shared posts tree, only while X posts are private", async () => { +test("withdrawnXChannels: the X channels with a shared posts tree that a non-private site carries, only while X posts are private", async () => { const root = mkdtempSync(path.join(tmpdir(), "tombstones-")); try { const paths = { @@ -149,14 +150,26 @@ test("withdrawnXChannels: the X channels with a shared posts tree, only while X config("a-x", "twitter"); config("sky", "bluesky"); config("no-tree-x", "twitter"); - for (const slug of ["z-x", "a-x", "sky", "no-config"]) sharedTree(paths.exportSharedPostsDir, slug, 1); + config("private-only-x", "twitter"); + config("no-site-x", "twitter"); + for (const slug of ["z-x", "a-x", "sky", "no-config", "private-only-x", "no-site-x"]) { + sharedTree(paths.exportSharedPostsDir, slug, 1); + } + const members = (...slugs: string[]) => slugs.map((slug) => ({ slug, groupId: "default" })); + const sites = [ + { channels: members("a-x", "sky", "no-config", "no-tree-x") }, + { audience: "public" as const, channels: members("z-x") }, + // A private site's X channel that no public site carries: never named. + { audience: "private" as const, channels: members("private-only-x", "a-x") }, + ]; const priv = { social: { x: { visibility: "private" } } }; - assert.deepEqual(await withdrawnXChannels(paths, priv), ["a-x", "z-x"]); - assert.deepEqual(await withdrawnXChannels(paths, {}), []); - assert.deepEqual(await withdrawnXChannels(paths, { social: { x: { visibility: "public" } } }), []); + assert.deepEqual(await withdrawnXChannels(paths, priv, sites), ["a-x", "z-x"]); + assert.deepEqual(await withdrawnXChannels(paths, {}, sites), []); + assert.deepEqual(await withdrawnXChannels(paths, { social: { x: { visibility: "public" } } }, sites), []); + assert.deepEqual(await withdrawnXChannels(paths, priv, []), [], "no site, no tombstone"); const empty = { ...paths, exportSharedPostsDir: path.join(root, "absent") } as Paths; - assert.deepEqual(await withdrawnXChannels(empty, priv), []); + assert.deepEqual(await withdrawnXChannels(empty, priv, sites), []); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/common/publish/tombstones.ts b/common/publish/tombstones.ts @@ -35,23 +35,22 @@ import { type PostsManifest, } from "../lib/posts"; import { isXPostsChannel, xPostsVisibility } from "../lib/postsVisibility"; +import { isPrivateSite, type Site } from "../lib/siteSchema"; import { readChannelConfig } from "../controller/channels"; import { ownDir, writePublicFile } from "../bin/_publicFile"; // One channel's tombstone: its slug and how many empty pages stand in for it. export type PostsTombstone = { slug: string; pages: number }; -// The channel posts manifest a tombstone ships: no pages, no posts. -export function tombstoneChannelManifest( - slug: string, - generatedAt: string, - maxPageBytes = 0, -): ChannelPostsManifest { +// The channel posts manifest a tombstone ships: no pages, no posts — and no +// size: `maxPageBytes` is 0 (nothing reads it), so a tombstone says nothing of +// the withheld archive beyond how many empty pages stand in for it. +export function tombstoneChannelManifest(slug: string, generatedAt: string): ChannelPostsManifest { return { version: POSTS_MANIFEST_VERSION, channelSlug: slug, pageCount: 0, - maxPageBytes, + maxPageBytes: 0, generatedAt, slugToPage: {}, }; @@ -68,21 +67,16 @@ export function emptyPostsManifest(generatedAt: string, siteId?: string): PostsM }; } -// The pageCount (and page cap) of a channel's SHARED posts tree, or zeros when -// it has none or it cannot be read. -async function sharedPostsShape( - sharedPostsDir: string, - slug: string, -): Promise<{ pageCount: number; maxPageBytes: number }> { +// The pageCount of a channel's SHARED posts tree, or 0 when it has none or it +// cannot be read. +async function sharedPageCount(sharedPostsDir: string, slug: string): Promise<number> { try { const m = JSON.parse( await readFile(path.join(sharedPostsDir, slug, "manifest.json"), "utf8"), ) as Partial<ChannelPostsManifest>; - const pageCount = Number.isInteger(m.pageCount) && (m.pageCount as number) > 0 ? (m.pageCount as number) : 0; - const maxPageBytes = typeof m.maxPageBytes === "number" ? m.maxPageBytes : 0; - return { pageCount, maxPageBytes }; + return Number.isInteger(m.pageCount) && (m.pageCount as number) > 0 ? (m.pageCount as number) : 0; } catch { - return { pageCount: 0, maxPageBytes: 0 }; + return 0; } } @@ -108,10 +102,10 @@ export async function writePostsTombstones(opts: { // linked one in a worktree) goes; rm removes a link, never its target. await rm(dir, { recursive: true, force: true }); await ownDir(dir); - const { pageCount, maxPageBytes } = await sharedPostsShape(opts.sharedPostsDir, slug); + const pageCount = await sharedPageCount(opts.sharedPostsDir, slug); await writePublicFile( path.join(dir, "manifest.json"), - JSON.stringify(tombstoneChannelManifest(slug, generatedAt, maxPageBytes)), + JSON.stringify(tombstoneChannelManifest(slug, generatedAt)), ); for (let i = 0; i < pageCount; i++) { await writePublicFile(path.join(dir, postsPageFileName(i)), "[]"); @@ -151,15 +145,26 @@ export function tombstoneNoStoreForHub(tombstones: readonly PostsTombstone[]): s } /** - * Every X channel that has a SHARED posts tree, while X posts are private — - * the channels a hub tombstones (it never carries posts, and once carried a - * site's). Empty while X posts are public. + * The X channels a hub tombstones while X posts are private: every X channel + * that has a SHARED posts tree AND is a member of at least one site whose + * audience is not private — the only channels whose posts a public bundle (a + * public site's, or a hub composed over one) could ever have served, so the + * only paths the edge can still hold. PRIVATE DATA IS NEVER NAMED ON THE HUB: + * an X channel carried only by private sites, or by no site, gets no + * tombstone, because its slug in a public bundle would itself publish it. + * Empty while X posts are public. */ export async function withdrawnXChannels( paths: Paths, settings: { social?: { x?: { visibility?: unknown } } }, + sites: readonly Pick<Site, "audience" | "channels">[], ): Promise<string[]> { if (xPostsVisibility(settings) !== "private") return []; + const onPublicSite = new Set<string>(); + for (const site of sites) { + if (isPrivateSite(site)) continue; + for (const c of site.channels) onPublicSite.add(c.slug); + } let entries: string[]; try { entries = (await readdir(paths.exportSharedPostsDir, { withFileTypes: true })) @@ -171,6 +176,7 @@ export async function withdrawnXChannels( } const out: string[] = []; for (const slug of entries) { + if (!onPublicSite.has(slug)) continue; if (isXPostsChannel(await readChannelConfig(paths, slug))) out.push(slug); } return out;