Archilyzer · Source

archilyzer

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

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:
MDEPLOY_CLOUDFLARE.md | 5+++++
Acommon/lib/builtExport.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/builtExport.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/pagesDeploy.test.ts | 35+++++++++++++++++++++++++++++++++++
Mcommon/lib/pagesDeploy.ts | 9++++++++-
Meditor/app/sites/lib/deployAction.ts | 17++++++++++++++++-
Meditor/e2e/ops-api.spec.ts | 38++++++++++++++++++++++++++++++++++++++
Mplans/FACTS.md | 23+++++++++++++++++++++++
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.