commit 2fea61e240b83155fdd9498fd12b77c58eaba5c8
parent bb7b28e3c2ee72794ef5be030e15c905b835b1cd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 13:13:28 -0400
common: builtBundleProblem — a per-site bundle is the site's own only when site.json and corpus.json both name it
Stricter than builtSiteProblem, for the bundle a container build hands back:
both identity files compose writes must be there and both must name the site.
A container build once published the public/ baked into its image instead of
the composed one, so its out/ carried no data, or another site's. The check
names the directory and which file disagreed. 3 tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 92 insertions(+), 0 deletions(-)
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:
import { tmpdir } from "node:os";
import path from "node:path";
import {
+ builtBundleProblem,
builtHomepageAt,
builtHomepageProblem,
builtHubProblem,
@@ -160,3 +161,52 @@ test("builtHomepageAt is index.html's mtime, and null with no build", () => {
empty.cleanup();
}
});
+
+// A per-site container build publishes its own out/ (docker/build-site.sh). It
+// once published the public/ baked into the image instead of the one compose
+// had just written — so out/ carried no data, or another site's. Both identity
+// files must be there and both must name the site.
+function bundle(files: { site?: unknown; corpus?: unknown }): { dir: string; cleanup: () => void } {
+ const t = tempOut(files.site === undefined ? undefined : JSON.stringify(files.site));
+ if (files.corpus !== undefined) {
+ writeFileSync(path.join(t.dir, "corpus.json"), JSON.stringify(files.corpus));
+ }
+ return t;
+}
+
+test("builtBundleProblem passes a bundle whose site.json and corpus.json both name the site", () => {
+ const t = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 4, kind: "site", site: { id: "anilyzer" } } });
+ try {
+ assert.equal(builtBundleProblem(t.dir, "anilyzer"), null);
+ assert.equal(builtBundleProblem(t.dir, " anilyzer "), null);
+ } finally {
+ t.cleanup();
+ }
+});
+
+test("builtBundleProblem refuses another site's bundle, by either file", () => {
+ const other = bundle({ site: { siteId: "jeralyzer" }, corpus: { site: { id: "jeralyzer" } } });
+ const torn = bundle({ site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } });
+ try {
+ assert.match(builtBundleProblem(other.dir, "anilyzer")!, /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)$/);
+ assert.match(builtBundleProblem(torn.dir, "anilyzer")!, /describes "jeralyzer", not "anilyzer" \(corpus\.json\)$/);
+ } finally {
+ other.cleanup();
+ torn.cleanup();
+ }
+});
+
+test("builtBundleProblem refuses a bundle missing either identity file, naming the directory", () => {
+ const none = bundle({});
+ const noCorpus = bundle({ site: { siteId: "anilyzer" } });
+ const unnamed = bundle({ site: { siteId: "anilyzer" }, corpus: { site: {} } });
+ try {
+ assert.equal(builtBundleProblem(none.dir, "anilyzer"), `${none.dir} has no site.json naming a site — it is not a build of "anilyzer"`);
+ assert.match(builtBundleProblem(noCorpus.dir, "anilyzer")!, /has no corpus\.json naming a site/);
+ assert.match(builtBundleProblem(unnamed.dir, "anilyzer")!, /has no corpus\.json naming a site/);
+ } finally {
+ none.cleanup();
+ noCorpus.cleanup();
+ unnamed.cleanup();
+ }
+});
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -60,6 +60,48 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null
}
/**
+ * Why the bundle in a per-site container's `outDir` is not `siteId`'s own, as
+ * one sentence naming the directory — or null when it is.
+ *
+ * Stricter than builtSiteProblem: both identity files compose writes must be
+ * there and both must name the site — `site.json`'s `siteId` and
+ * `corpus.json`'s `site.id`. A container build once published the public/
+ * baked into its image instead of the one compose had just written, so its
+ * out/ carried whatever the image's build context held: no data at all, or a
+ * DIFFERENT site's. docker/build-site.sh refuses to hand back such an out/,
+ * and the container deploy phase refuses to ship one (publish/build.ts).
+ */
+export function builtBundleProblem(outDir: string, siteId: string): string | null {
+ const asked = siteId.trim();
+ const built = builtSiteIdIn(outDir);
+ if (built === null) {
+ return `${outDir} has no site.json naming a site — it is not a build of "${asked}"`;
+ }
+ if (built !== asked) {
+ return `${outDir} holds a build of "${built}", not "${asked}" (site.json)`;
+ }
+ const described = corpusSiteIdIn(outDir);
+ if (described === null) {
+ return `${outDir} has no corpus.json naming a site — it is not a complete build of "${asked}"`;
+ }
+ if (described !== asked) {
+ return `${outDir} describes "${described}", not "${asked}" (corpus.json)`;
+ }
+ return null;
+}
+
+// corpus.json's `site.id`, or null when there is no readable one.
+function corpusSiteIdIn(outDir: string): string | null {
+ try {
+ const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8"));
+ const site = (parsed as { site?: { id?: unknown } } | null)?.site;
+ return typeof site?.id === "string" && site.id.trim() ? site.id.trim() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
* Why `outDir` may not be deployed as the HUB, as one sentence — or null when
* it holds a hub build.
*