Archilyzer · Source

archilyzer

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

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:
Mcommon/publish/build.test.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/build.ts | 15+++++++++++++--
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({