Archilyzer · Source

archilyzer

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

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:
Mcommon/lib/builtExport.test.ts | 38++++++++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 8++++++++
Mcommon/publish/build.test.ts | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/build.ts | 16++++++++++++++++
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;