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