commit 2bc6b88e589fa2ead48d87766567b503356a7ea1
parent 35814656e122ae95dd0f05d187cd91641befebef
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 13:13:28 -0400
common: the container deploy phase refuses a per-site out/ that is not the site's own bundle
runDockerDeployAllPhase shipped whatever export/.export-builds/<id>/out held;
the host deploy checks builtSiteProblem, this path checked nothing. It now
checks builtBundleProblem first — before the Cloudflare-project skip, so that
the R2 upload and the Pages deploy are never reached with another site's data.
The test's sites have no Cloudflare project, so it could never reach a real
deploy even if the check were removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts
@@ -1,6 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
import type { Paths } from "../lib/paths";
+import type { Site } from "../lib/site";
import {
HOMEPAGE_PAGES_PROJECT,
buildHubSteps,
@@ -12,6 +16,7 @@ import {
dockerSiteOutDir,
dockerSiteStagingDir,
resolveOutDir,
+ runDockerDeployAllPhase,
} from "./build";
// Run with:
@@ -158,3 +163,39 @@ test("deployHomepage refuses a bad preview branch before it looks for a build",
/homepage\/out holds no build/,
);
});
+
+// Phase C ships each per-site out/ a container wrote. It must be THAT site's
+// bundle: a container once published the public/ baked into its image, so an
+// out/ could carry another site's data. The check comes first. These sites have
+// NO Cloudflare project, so were it ever removed they would be "skipped", and
+// this test could never reach a real upload or deploy.
+test("runDockerDeployAllPhase refuses a per-site out/ that is not the site's own bundle", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "deploy-all-"));
+ try {
+ const outFor = (id: string) => path.join(root, id, "out");
+ mkdirSync(outFor("anilyzer"), { recursive: true });
+ writeFileSync(path.join(outFor("anilyzer"), "site.json"), JSON.stringify({ siteId: "jeralyzer" }));
+ writeFileSync(path.join(outFor("anilyzer"), "corpus.json"), JSON.stringify({ site: { id: "jeralyzer" } }));
+ mkdirSync(outFor("bonnellyzer"), { recursive: true }); // built, but no bundle in it
+ const log: string[] = [];
+ const sites = [
+ { siteId: "anilyzer" },
+ { siteId: "bonnellyzer" },
+ ] as Site[];
+ const outcomes = await runDockerDeployAllPhase(
+ (l) => log.push(l),
+ new AbortController().signal,
+ sites,
+ new Set(["anilyzer", "bonnellyzer"]),
+ { ...paths, exportBuildsDir: root } as Paths,
+ outFor,
+ );
+ assert.deepEqual(outcomes.map((o) => [o.siteId, o.status]), [["anilyzer", "failed"], ["bonnellyzer", "failed"]]);
+ assert.match(outcomes[0].reason!, /holds a build of "jeralyzer", not "anilyzer"/);
+ assert.match(outcomes[1].reason!, /has no site\.json naming a site/);
+ assert.ok(log.some((l) => l.startsWith("[anilyzer] deploy REFUSED — ")), log.join("\n"));
+ assert.ok(!log.some((l) => l.startsWith("=== Deploy")), "nothing reached the deploy");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -14,7 +14,7 @@ import { createReadStream, existsSync } from "node:fs";
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { runChildIntoLog } from "../jobs/runChild";
-import { builtHubProblem, builtSiteProblem } from "../lib/builtExport";
+import { builtBundleProblem, builtHubProblem, builtSiteProblem } from "../lib/builtExport";
import { getHomepageConfig } from "../lib/homepage";
import {
deploymentUrlIn,
@@ -569,7 +569,8 @@ export async function runDockerBuildAllPhase(
// Phase C: deploy each built site SERIALLY on the host, after the build barrier.
// Partial-failure tolerant — a site that fails to upload/deploy is recorded and
// the loop continues. Sites that failed to build, or have no Cloudflare project,
-// are skipped. `outDirFor` resolves each site's built bundle (docker: per-site;
+// are skipped; a bundle that is not the site's own is refused (builtBundleProblem).
+// `outDirFor` resolves each site's built bundle (docker: per-site;
// basic fallback: export/out).
export async function runDockerDeployAllPhase(
onLog: (line: string) => void,
@@ -587,6 +588,16 @@ export async function runDockerDeployAllPhase(
outcomes.push({ siteId: site.siteId, status: "skipped", reason: "build failed" });
continue;
}
+ // The bundle must be this site's own before anything else is asked of it —
+ // the check build-site.sh makes before it hands the bundle back, made again
+ // over whatever the per-site dir holds now. First, so that nothing past it
+ // (the R2 upload, the Pages deploy) is ever reached with another site's data.
+ const bundleProblem = builtBundleProblem(outDirFor(site.siteId), site.siteId);
+ if (bundleProblem) {
+ onLog(`[${site.siteId}] deploy REFUSED — ${bundleProblem}`);
+ outcomes.push({ siteId: site.siteId, status: "failed", reason: bundleProblem });
+ continue;
+ }
if (!site.cloudflareProject) {
onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
outcomes.push({