commit ae124f0de136ca812a6394886143d608bdbe8ac5
parent a6e893dcf62d2b8a43294243157b1189fad1a39c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 09:13:35 -0400
sites: deploy-only must not ship a build of another site
export/out is one directory whichever site composed into it — resolveOutDir
ignores its site id, because the basic build shares the export tree — so
building jeralyzer and then deploying anilyzer put jeralyzer's bundle on
anilyzer's Pages project, in production, with a green log. The bundle names
itself in its own site.json, so the action reads it and refuses before the job
exists. It refuses rather than rebuilding: "deploy the export" is the operator
saying ship what is there, and building something else instead would be the
larger surprise. build-deploy needs nothing — it builds.
deploymentUrlIn also tightens at both ends: the host prefix is one label, as
Cloudflare emits, and the match stops at the TLD — otherwise
https://x.proj.pages.devil.com yields a URL that reads exactly right.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
8 files changed, 271 insertions(+), 2 deletions(-)
diff --git a/DEPLOY_CLOUDFLARE.md b/DEPLOY_CLOUDFLARE.md
@@ -85,6 +85,11 @@ The high-value loop is **build once, preview, then promote**: `build-site` (or t
Publish tab's *Build static export*), then `deploy-site` with a `preview`, look at
it, then `deploy-site` again with no `preview` — the same `export/out`, unrebuilt.
+Deploy-only ships whatever is in `export/out`, which the basic build composes one
+site at a time into a single shared directory — so it **refuses, before starting a
+job, if `export/out` holds a build of another site** (or no build at all), naming
+the site to build first. `build-deploy` cannot hit this: it builds.
+
### Two things to know
**A preview shares the production R2 archive bucket.** R2 has no per-branch
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -0,0 +1,86 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { builtSiteIdIn, builtSiteProblem } from "./builtExport";
+
+function tempOut(siteJson?: string): { dir: string; cleanup: () => void } {
+ const dir = mkdtempSync(path.join(tmpdir(), "built-export-"));
+ const out = path.join(dir, "out");
+ mkdirSync(out, { recursive: true });
+ if (siteJson !== undefined) {
+ writeFileSync(path.join(out, "site.json"), siteJson);
+ }
+ return { dir: out, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
+}
+
+test("builtSiteIdIn reads the site id out of the composed contract", () => {
+ const { dir, cleanup } = tempOut(
+ JSON.stringify({ contract: 1, siteId: "anilyzer", siteTitle: "Anilyzer" }),
+ );
+ try {
+ assert.equal(builtSiteIdIn(dir), "anilyzer");
+ } finally {
+ cleanup();
+ }
+});
+
+test("builtSiteIdIn answers null for every flavour of 'nothing was built here'", () => {
+ // No directory at all.
+ assert.equal(builtSiteIdIn(path.join(tmpdir(), "no-such-out-dir-ever")), null);
+
+ // A directory with no site.json.
+ const empty = tempOut();
+ try {
+ assert.equal(builtSiteIdIn(empty.dir), null);
+ } finally {
+ empty.cleanup();
+ }
+
+ // Unparseable, or parseable but not a site.
+ for (const body of ["{", "null", "[]", '"anilyzer"', "{}", '{"siteId":""}', '{"siteId":3}']) {
+ const t = tempOut(body);
+ try {
+ assert.equal(builtSiteIdIn(t.dir), null, `expected null for ${body}`);
+ } finally {
+ t.cleanup();
+ }
+ }
+});
+
+test("builtSiteProblem passes a build of the site being deployed", () => {
+ const { dir, cleanup } = tempOut(JSON.stringify({ siteId: "anilyzer" }));
+ try {
+ assert.equal(builtSiteProblem(dir, "anilyzer"), null);
+ // Trimmed, so a padded id from a form is not itself the complaint.
+ assert.equal(builtSiteProblem(dir, " anilyzer "), null);
+ } finally {
+ cleanup();
+ }
+});
+
+test("builtSiteProblem names the OTHER site when out/ holds somebody else's build", () => {
+ // THE BUG: export/out is one shared directory whichever site built into it,
+ // so this is deploying jeralyzer's bundle to anilyzer's Pages project.
+ const { dir, cleanup } = tempOut(JSON.stringify({ siteId: "jeralyzer" }));
+ try {
+ const problem = builtSiteProblem(dir, "anilyzer");
+ assert.ok(problem);
+ assert.match(problem, /holds a build of "jeralyzer", not "anilyzer"/);
+ assert.match(problem, /build anilyzer first/);
+ } finally {
+ cleanup();
+ }
+});
+
+test("builtSiteProblem says nothing was built rather than naming a mismatch", () => {
+ const { dir, cleanup } = tempOut();
+ try {
+ const problem = builtSiteProblem(dir, "anilyzer");
+ assert.ok(problem);
+ assert.match(problem, /holds no built site — build anilyzer first/);
+ } finally {
+ cleanup();
+ }
+});
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -0,0 +1,60 @@
+// What is actually sitting in the built export directory.
+//
+// WHY THIS EXISTS. The basic (host) build composes into ONE shared directory,
+// `export/out`, whichever site it built — `resolveOutDir` ignores the site id on
+// purpose. A deploy-only action (the Publish tab's "Deploy static export", the
+// ops `deploy-site` route) therefore ships whatever was built LAST, and nothing
+// checked that it was built for the site being deployed. Building jeralyzer and
+// then deploying anilyzer put jeralyzer's bundle on anilyzer's Pages project —
+// in production, silently, with a green log.
+//
+// The bundle knows who it is: `compose-site.ts` writes the federation contract
+// `site.json` into the public dir, carrying `siteId`, and `next build` copies
+// public/ into out/. So the check is a file read, and it is cheap enough to do
+// before every deploy.
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+/**
+ * The site id of the build sitting in `outDir`, or null when there is no
+ * readable build there (no directory, no site.json, unparseable, or a site.json
+ * with no `siteId`). Every one of those means the same thing to a caller —
+ * "nothing deployable was built here" — so they are one answer, not four.
+ */
+export function builtSiteIdIn(outDir: string): string | null {
+ let raw: string;
+ try {
+ raw = readFileSync(path.join(outDir, "site.json"), "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const id = (parsed as { siteId?: unknown }).siteId;
+ return typeof id === "string" && id.trim() ? id.trim() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Why `outDir` may not be deployed as `siteId`, as one sentence — or null when
+ * it holds that site's build.
+ *
+ * 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.
+ */
+export function builtSiteProblem(outDir: string, siteId: string): string | null {
+ const built = builtSiteIdIn(outDir);
+ const asked = siteId.trim();
+ if (built === null) {
+ return `export/out holds no built site — build ${asked} first`;
+ }
+ if (built !== asked) {
+ return `export/out holds a build of "${built}", not "${asked}" — build ${asked} first`;
+ }
+ return null;
+}
diff --git a/common/lib/pagesDeploy.test.ts b/common/lib/pagesDeploy.test.ts
@@ -147,6 +147,41 @@ test("deploymentUrlIn returns null for lines with no URL for THIS project", () =
assert.equal(deploymentUrlIn("https://x.anilyzer.pages.dev", ""), null);
});
+test("deploymentUrlIn cannot latch onto an earlier unrelated pages.dev", () => {
+ // A dot-swallowing prefix could span from the FIRST https:// all the way to
+ // this project's suffix and hand back a host nobody deployed.
+ assert.equal(
+ deploymentUrlIn(
+ "old https://docs.pages.dev new https://c0ffee.anilyzer.pages.dev",
+ "anilyzer",
+ ),
+ "https://c0ffee.anilyzer.pages.dev",
+ );
+ // Same line, no space to stop a greedy class: the prefix is one label or it
+ // is not one of our aliases.
+ assert.equal(
+ deploymentUrlIn("https://docs.pages.dev.anilyzer.pages.dev", "anilyzer"),
+ null,
+ );
+});
+
+test("deploymentUrlIn stops at the TLD, not inside a longer hostname", () => {
+ // The nastiest one: it reads exactly like the real thing and is not it.
+ assert.equal(
+ deploymentUrlIn("https://c0ffee.anilyzer.pages.devil.com/x", "anilyzer"),
+ null,
+ );
+ // A trailing dot-path or punctuation still ends the URL cleanly.
+ assert.equal(
+ deploymentUrlIn("peek at https://c0ffee.anilyzer.pages.dev.", "anilyzer"),
+ null,
+ );
+ assert.equal(
+ deploymentUrlIn("peek at https://c0ffee.anilyzer.pages.dev!", "anilyzer"),
+ "https://c0ffee.anilyzer.pages.dev",
+ );
+});
+
test("deploymentUrlIn treats a dotted project name literally", () => {
// A regex-special character in the project name must not become a wildcard.
assert.equal(deploymentUrlIn("https://h.aXb.pages.dev", "a.b"), null);
diff --git a/common/lib/pagesDeploy.ts b/common/lib/pagesDeploy.ts
@@ -96,12 +96,19 @@ export function previewAliasUrl(project: string, branch: string): string {
* Matched against THIS project's hostname suffix so an unrelated pages.dev URL
* in the output (a doc link, another project) is never mistaken for the
* deployment we just made.
+ *
+ * TIGHT AT BOTH ENDS, deliberately. The prefix is ONE hostname label — no dots
+ * — because that is all Cloudflare ever puts there (`<hash>.` or `<branch>.`);
+ * a dot-swallowing prefix can reach back across an earlier, unrelated
+ * `pages.dev` on the same line and return a host that is not ours. And the
+ * match must END at the TLD, or `https://x.anilyzer.pages.devil.com` yields a
+ * URL that reads exactly right and points somewhere else.
*/
export function deploymentUrlIn(line: string, project: string): string | null {
const p = project.trim();
if (!p) return null;
const re = new RegExp(
- `https://[A-Za-z0-9][A-Za-z0-9.-]*\\.${escapeRe(p)}\\.pages\\.dev`,
+ `https://[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.${escapeRe(p)}\\.pages\\.dev(?![A-Za-z0-9.-])`,
);
const m = re.exec(line);
return m ? m[0] : null;
diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts
@@ -1,5 +1,6 @@
"use server";
+import { builtSiteProblem } from "yt-dlp-transcript-common/lib/builtExport";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
import { getSite } from "yt-dlp-transcript-common/lib/site";
@@ -45,6 +46,20 @@ export async function deployExportAction(
error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
};
}
+ // DEPLOY-ONLY SHIPS WHAT IS IN export/out, AND export/out IS SHARED.
+ // resolveOutDir ignores the site id — the basic build composes every site into
+ // the same directory — so without this, building jeralyzer and then deploying
+ // anilyzer put jeralyzer's bundle on anilyzer's Pages project, in production,
+ // with a green log. The bundle says who it is, in its own site.json.
+ //
+ // Refused rather than rebuilt: "deploy the export" is the operator saying
+ // ship what is there, and quietly building something else would be a far
+ // bigger surprise than a sentence naming the fix. Checked before the job, so
+ // the refusal is the action's answer rather than a failed job to go and read
+ // — and so a preview cannot waste a deploy learning it either.
+ const outDir = resolveOutDir(site.siteId, paths);
+ const builtProblem = builtSiteProblem(outDir, site.siteId);
+ if (builtProblem) return { ok: false, error: builtProblem };
return runManagedFunction({
kind: "deploy-export",
queueKey: DEPLOY_QUEUE,
@@ -67,7 +82,7 @@ export async function deployExportAction(
onLog,
signal,
site,
- resolveOutDir(site.siteId, paths),
+ outDir,
paths,
{ previewBranch: branch },
);
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -848,6 +848,44 @@ test("a valid preview is accepted and reaches the action, on both deploy routes"
}
});
+test("deploy-site refuses to ship a build of a DIFFERENT site", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("previewsite", { cloudflareProject: "proj" });
+
+ // export/out is ONE shared directory whichever site composed into it — the
+ // basic build ignores the site id — so a deploy-only action used to ship
+ // whatever was built last to whichever project was asked for. The bundle
+ // names itself in its own site.json, and the action reads it before starting.
+ //
+ // Both refusals are asserted because both are correct depending on what is
+ // on disk: a checkout that has never built has no export/out at all, and one
+ // that has built holds some OTHER site (it is never "previewsite", which
+ // exists only inside this fixture). Neither starts a job.
+ const before = await listJobIds();
+ const { status, body } = await ops(request, "deploy-site", {
+ siteId: "previewsite",
+ });
+ expect(status).toBe(400);
+ expect(body.error).toMatch(
+ /export\/out holds (no built site|a build of ".*", not "previewsite")/,
+ );
+ expect(body.error).toContain("build previewsite first");
+ expect(await listJobIds()).toEqual(before);
+
+ // A preview is refused for the same reason and just as early — it must not
+ // cost a deploy to learn the bundle is somebody else's.
+ const preview = await ops(request, "deploy-site", {
+ siteId: "previewsite",
+ preview: "tags-exclude",
+ });
+ expect(preview.status).toBe(400);
+ expect(preview.body.error).toContain("build previewsite first");
+ expect(await listJobIds()).toEqual(before);
+});
+
test("deploy-site takes siteId or siteIds, and has no all", async ({
request,
}) => {
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -5348,3 +5348,26 @@ a branch complaint. `site-publish-preview.spec.ts` covers the control itself.
**Who can open a preview is a Cloudflare project setting, not ours** — *preview
deployment access*, **public by default**. Documented in `DEPLOY_CLOUDFLARE.md` →
"Preview deployments".
+
+### Deploy-only refuses somebody else's bundle (2026-09-23)
+
+**`export/out` is ONE directory whichever site built into it** — `resolveOutDir`
+ignores its `_siteId` argument on purpose, because the basic (host) build composes
+one site at a time into the shared `export/` tree. So a deploy-ONLY action ships
+whatever was built last, to whichever project was asked for: building jeralyzer and
+then deploying anilyzer put jeralyzer's bundle on anilyzer's Pages project, in
+production, with a green log.
+
+**The bundle names itself.** `compose-site.ts` writes the federation contract
+`site.json` (carrying `siteId`) into the public dir, and `next build` copies
+`public/` into `out/`. `common/lib/builtExport.ts` reads it: `builtSiteIdIn(outDir)`
+→ id or null (missing dir, missing/unparseable file, and absent `siteId` are ONE
+answer, because they mean one thing to a caller), and `builtSiteProblem(outDir,
+siteId)` → the sentence or null. Tests: `common/lib/builtExport.test.ts`.
+
+**Called in `deployExportAction` BEFORE `runManagedFunction`**, after the
+`cloudflareProject` check (so a site that cannot be deployed at all still says
+that). It refuses rather than rebuilding: "deploy the export" is the operator
+saying *ship what is there*. `buildAndDeployAction` needs no check — it builds.
+The ops route surfaces it as the 400/`skipped` reason it already returns for any
+action error.