commit 86733c372d3b8c776c9e3024222fe59ead66cfe6
parent 9d11f5aefeede6e615c3313f6a0d4e035550600d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 13:41:57 -0400
common: every site deploy refuses a bundle that is not the site's own, right before wrangler (review S1)
runDeployIntoLog is the one function every SITE deploy passes through: the
container Phase C, deploySite, the Publish tab's Build & deploy (and ops
build-deploy) and the host Build & deploy all. The last two shipped export/out
with no check between their build and wrangler, and the deploy queue runs
beside the build queue, so a build of another site or of the hub replacing
export/out mid-upload would have gone to this site's Pages project. It now
checks builtBundleProblem first — nothing runs between the check and the
spawn — and a refusal logs '[deploy] REFUSED — <why>. Nothing was sent to
Cloudflare Pages; build <id> again, then deploy.' and returns 1, which every
caller already reports as a failed deploy. The hub and the homepage deploy
through runPagesDeployIntoLog and are unaffected.
builtSiteProblem (deploySite's and the editor deploy action's fast answer) now
refuses exactly what builtBundleProblem refuses — a site.json naming the site
with a corpus.json that does not is 'an incomplete build' — so a legitimately
built site is never refused only at the last step; a test walks every bundle
shape through both.
The test's PATH holds only a fake pnpm that records its argv, checked to
answer before any deploy runs: no version of this code, guarded or not, can
reach a real wrangler from it. A good bundle still deploys with the same argv.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 143 insertions(+), 1 deletion(-)
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -58,6 +58,8 @@ test("builtSiteIdIn answers null for every flavour of 'nothing was built here'",
test("builtSiteProblem passes a build of the site being deployed", () => {
const { dir, cleanup } = tempOut(JSON.stringify({ siteId: "anilyzer" }));
+ // compose writes corpus.json beside site.json, from the same descriptor.
+ writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ site: { id: "anilyzer" } }));
try {
assert.equal(builtSiteProblem(dir, "anilyzer"), null);
// Trimmed, so a padded id from a form is not itself the complaint.
@@ -210,3 +212,39 @@ test("builtBundleProblem refuses a bundle missing either identity file, naming t
unnamed.cleanup();
}
});
+
+// deploySite and the editor's deploy action answer with builtSiteProblem before
+// any job; the deploy itself refuses with builtBundleProblem just before
+// wrangler. The two must never disagree about a bundle: a legitimately built
+// site refused only at the last step, or a bad one let through to it.
+test("builtSiteProblem refuses exactly what builtBundleProblem refuses", () => {
+ const cases: { name: string; site?: unknown; corpus?: unknown }[] = [
+ { name: "a good build", site: { siteId: "anilyzer" }, corpus: { site: { id: "anilyzer" } } },
+ { name: "nothing built" },
+ { name: "another site", site: { siteId: "jeralyzer" }, corpus: { site: { id: "jeralyzer" } } },
+ { name: "no corpus.json", site: { siteId: "anilyzer" } },
+ { name: "a torn build", site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } },
+ { name: "a corpus.json that names no site", site: { siteId: "anilyzer" }, corpus: { spec: 4 } },
+ { name: "a hub build", corpus: { kind: "hub" } },
+ ];
+ for (const c of cases) {
+ const t = bundle(c);
+ try {
+ const site = builtSiteProblem(t.dir, "anilyzer");
+ const deploy = builtBundleProblem(t.dir, "anilyzer");
+ assert.equal(site === null, deploy === null, `${c.name}: builtSiteProblem ${site} / builtBundleProblem ${deploy}`);
+ if (site) assert.match(site, /^export\/out holds .* — build anilyzer first$/, c.name);
+ } finally {
+ t.cleanup();
+ }
+ }
+ const torn = bundle({ site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } });
+ try {
+ assert.equal(
+ builtSiteProblem(torn.dir, "anilyzer"),
+ 'export/out holds an incomplete build of "anilyzer" (its corpus.json does not name it) — build anilyzer first',
+ );
+ } finally {
+ torn.cleanup();
+ }
+});
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -46,6 +46,11 @@ export function builtSiteIdIn(outDir: string): string | null {
* Refuses rather than rebuilding, because a deploy-only action is the operator
* saying "ship what is there"; quietly building something else would be a much
* larger surprise than a refusal naming the fix.
+ *
+ * It refuses exactly what builtBundleProblem refuses — the check the deploy
+ * itself makes before wrangler (publish/build.ts, runDeployIntoLog) — in the
+ * operator's words, so an action's fast answer and the deploy's last word
+ * never disagree about a bundle.
*/
export function builtSiteProblem(outDir: string, siteId: string): string | null {
const built = builtSiteIdIn(outDir);
@@ -56,6 +61,9 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null
if (built !== asked) {
return `export/out holds a build of "${built}", not "${asked}" — build ${asked} first`;
}
+ if (corpusSiteIdIn(outDir) !== asked) {
+ return `export/out holds an incomplete build of "${asked}" (its corpus.json does not name it) — build ${asked} first`;
+ }
return null;
}
diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts
@@ -1,8 +1,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
+import { runChildIntoLog } from "../jobs/runChild";
import type { Paths } from "../lib/paths";
import type { Site } from "../lib/site";
import {
@@ -16,6 +25,7 @@ import {
dockerSiteOutDir,
dockerSiteStagingDir,
resolveOutDir,
+ runDeployIntoLog,
runDockerDeployAllPhase,
} from "./build";
@@ -199,3 +209,73 @@ test("runDockerDeployAllPhase refuses a per-site out/ that is not the site's own
rmSync(root, { recursive: true, force: true });
}
});
+
+function writeBundle(dir: string, siteId: string | null, corpusId: string | null): void {
+ mkdirSync(dir, { recursive: true });
+ if (siteId !== null) writeFileSync(path.join(dir, "site.json"), JSON.stringify({ siteId }));
+ if (corpusId !== null) writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ site: { id: corpusId } }));
+ writeFileSync(path.join(dir, "index.html"), "<!doctype html>");
+}
+
+// runDeployIntoLog is the one door every SITE deploy goes through — the
+// container Phase C, deploySite, the Publish tab's Build & deploy, the host
+// Build & deploy all — and the build queue can rewrite export/out between a
+// site's build and its deploy. So it refuses a bundle that is not the site's
+// own right before wrangler. PATH here holds only a fake `pnpm` that records
+// its argv: with the check or without it, no run of this test can reach a real
+// wrangler, and the fake is proved to answer before anything is deployed.
+test("runDeployIntoLog refuses a bundle that is not the site's own before wrangler, and ships the site's own", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "deploy-guard-"));
+ const bin = path.join(root, "bin");
+ const argvFile = path.join(root, "pnpm-argv");
+ mkdirSync(bin);
+ writeFileSync(
+ path.join(bin, "pnpm"),
+ `#!/bin/sh\nprintf '%s\\n' "$@" >> '${argvFile}'\necho "Take a peek over at https://abc123.w3c-never-real.pages.dev"\n`,
+ );
+ chmodSync(path.join(bin, "pnpm"), 0o755);
+ const savedPath = process.env.PATH;
+ process.env.PATH = bin;
+ const signal = new AbortController().signal;
+ const site = { siteId: "anilyzer", cloudflareProject: "w3c-never-real" } as Site;
+ const testPaths = { ...paths, exportDir: root } as Paths;
+ try {
+ // The fake answers the same spawn the deploy makes, or nothing below runs.
+ await runChildIntoLog(() => {}, signal, { command: "pnpm", args: ["--fake?"], cwd: root, env: { ...process.env } });
+ assert.equal(readFileSync(argvFile, "utf8"), "--fake?\n");
+ rmSync(argvFile);
+
+ const refusals: [string, string | null, string | null, RegExp][] = [
+ ["another site's bundle", "jeralyzer", "jeralyzer", /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)/],
+ ["the hub's bundle (no site.json)", null, null, /has no site\.json naming a site/],
+ ["a torn bundle", "anilyzer", "jeralyzer", /describes "jeralyzer", not "anilyzer" \(corpus\.json\)/],
+ ["a bundle with no corpus.json", "anilyzer", null, /has no corpus\.json naming a site/],
+ ];
+ for (const [name, siteId, corpusId, why] of refusals) {
+ const out = path.join(root, name.replace(/\W+/g, "-"), "out");
+ writeBundle(out, siteId, corpusId);
+ const log: string[] = [];
+ const code = await runDeployIntoLog((l) => log.push(l), signal, site, out, testPaths);
+ assert.equal(code, 1, name);
+ assert.equal(log.length, 1, `${name}: ${log.join("")}`);
+ assert.match(log[0], /^\[deploy\] REFUSED — /, name);
+ assert.match(log[0], why, name);
+ assert.match(log[0], /Nothing was sent to Cloudflare Pages; build anilyzer again, then deploy\.\n$/, name);
+ assert.equal(existsSync(argvFile), false, `${name}: pnpm was spawned`);
+ }
+
+ // A legitimately built site is never refused: the same argv as ever.
+ const good = path.join(root, "good", "out");
+ writeBundle(good, "anilyzer", "anilyzer");
+ const log: string[] = [];
+ assert.equal(await runDeployIntoLog((l) => log.push(l), signal, site, good, testPaths), 0, log.join("\n"));
+ assert.equal(
+ readFileSync(argvFile, "utf8"),
+ ["dlx", "wrangler", "pages", "deploy", good, "--project-name", "w3c-never-real", ""].join("\n"),
+ );
+ assert.ok(log.includes("[deployed] https://abc123.w3c-never-real.pages.dev\n"), log.join("\n"));
+ } finally {
+ process.env.PATH = savedPath;
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -332,6 +332,22 @@ export async function runDeployIntoLog(
paths: Paths,
opts?: { previewBranch?: string },
): Promise<number> {
+ // The last word before wrangler: the bundle must be this site's own
+ // (site.json AND corpus.json name it — builtBundleProblem). Every SITE deploy
+ // comes through here — the container Phase C, deploySite, the Publish tab's
+ // Build & deploy and the host Build & deploy all — and the deploy queue runs
+ // beside the build queue, so between a site's build and this line another
+ // job (a build of another site, the hub) can rewrite export/out. Nothing runs
+ // between this check and the spawn. The hub and the homepage deploy through
+ // runPagesDeployIntoLog and never come here.
+ const bundleProblem = builtBundleProblem(outDir, site.siteId);
+ if (bundleProblem) {
+ onLog(
+ `[deploy] REFUSED — ${bundleProblem}. Nothing was sent to Cloudflare Pages; ` +
+ `build ${site.siteId} again, then deploy.\n`,
+ );
+ return 1;
+ }
const project = site.cloudflareProject as string;
const previewBranch = opts?.previewBranch?.trim() || undefined;