Archilyzer · Source

archilyzer

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

commit 5146369ee3bdff85bc25f0a70b47817d5aee0ba6
parent 6e1f15b8597282753c6174f804bdec7b7db7d1ed
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 09:14:46 -0400

merge: deploy/preview — a site can be deployed to a Cloudflare Pages preview branch before production

`preview` on the new deploy-site route, on build-deploy, on `pnpm ops` and as a control on
the site's Publish tab; the alias https://<branch>.<project>.pages.dev is the answer. Deploy-only
now refuses to ship export/out when it was built for another site. Reviewed by Sonnet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MDEPLOY_CLOUDFLARE.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/builtExport.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/builtExport.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/pagesDeploy.test.ts | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/pagesDeploy.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/_lib.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/build-deploy/route.ts | 89++++++++++++++++++++++++++++++++++++-------------------------------------------
Aeditor/app/api/ops/deploy-site/route.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/[siteId]/publish/page.tsx | 9+++++++--
Meditor/app/sites/components/DeployButton.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/app/sites/lib/buildAction.ts | 18+++++++++++++++++-
Meditor/app/sites/lib/buildDeployCore.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Meditor/app/sites/lib/deployAction.ts | 41++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/ops-api.spec.ts | 163++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aeditor/e2e/site-publish-preview.spec.ts | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/FACTS.md | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Mscripts/archilyzer-ops.test.mjs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
18 files changed, 1438 insertions(+), 80 deletions(-)

diff --git a/DEPLOY_CLOUDFLARE.md b/DEPLOY_CLOUDFLARE.md @@ -8,6 +8,7 @@ downloads can't be abused to drive up your bill**. Everything here fits inside Cloudflare's **free tier**. - [How archives are served](#how-archives-are-served) +- [Preview deployments](#preview-deployments) - [One-time R2 setup](#one-time-r2-setup) - [Securing downloads against cost-abuse](#securing-downloads-against-cost-abuse) - [Cost expectations](#cost-expectations) @@ -48,6 +49,70 @@ deploy already uses. --- +## Preview deployments + +A **preview** is the same built bundle deployed to a branch that is not the Pages +project's production branch. Cloudflare publishes it at a **branch alias** — + +``` +https://<branch>.<project>.pages.dev +``` + +— and leaves the live site alone. Each deploy also gets an immutable +per-deployment URL (`https://<hash>.<project>.pages.dev`), which wrangler prints as +"Deployment complete! Take a peek over at …"; the editor repeats both on a +`[preview]` line at the end of the job log, because the streamed log scrolls. + +The alias is a function of the project and the branch and nothing else, so it is +known *before* the deploy runs — which is why the editor can link it while you are +still typing the name. + +**Branch names** must be 1–28 lowercase letters, digits and dashes, starting and +ending with a letter or digit. That is exactly what Cloudflare's alias sanitizer +preserves verbatim, so the alias shown is the alias that resolves. `main`, `master` +and `production` are refused: a deploy to the production branch is not a preview, it +is the live site. + +### The three surfaces + +| Surface | How | +|---|---| +| Editor | A site's **Publish** tab → *Individual steps* → **Deploy a preview**: type a branch, press **Deploy preview**. The production button beside it says **Deploy to production**. | +| Ops API | `POST /api/ops/deploy-site` `{ "siteId": "...", "preview": "<branch>" }` — deploy-only, of the already-built `export/out`. `POST /api/ops/build-deploy` takes `preview` too (build *then* preview-deploy). Both answer with `previewUrl`. | +| CLI | `pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"tags-exclude"}' --wait` — the alias is printed on its own line after the log. | + +The high-value loop is **build once, preview, then promote**: `build-site` (or the +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 +namespace, and the keys are `<siteId>/archives/<file>.zip` either way. In practice +this is cheap and harmless — the upload skips any object R2 already holds at the same +size, and an unchanged channel re-zips byte-stable — but a *changed* archive +replaces the one production's manifest links to. The editor says so once at the top +of every preview deploy. + +**Who can open a preview is a Cloudflare setting, not ours.** Pages projects have a +*preview deployment access* setting (Settings → General): **public** by default, or +restricted to Cloudflare Access. A default-configured project's preview URL is +world-readable by anyone who has the link. + +> **A production deploy inherits the checkout's git branch.** `wrangler pages deploy` +> with no `--branch` infers one from the git repository it runs in, so running a +> *production* deploy from a feature-branch checkout silently produces a preview +> instead. This predates the preview feature and is unchanged: only the preview path +> passes `--branch`. If a "production" deploy did not go live, check what branch the +> editor's checkout is on. + +--- + ## One-time R2 setup ### 1. Create the bucket 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 @@ -0,0 +1,189 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + MAX_PREVIEW_BRANCH, + deploymentUrlIn, + pagesDeployArgs, + previewAliasUrl, + previewBranchProblem, +} from "./pagesDeploy"; + +test("previewBranchProblem accepts ordinary preview names", () => { + for (const ok of [ + "preview", + "p", + "7", + "tags-exclude", + "rc-2026-09-23", + "a-b-c-d", + ]) { + assert.equal(previewBranchProblem(ok), null, `expected "${ok}" to be valid`); + } +}); + +test("previewBranchProblem trims before judging", () => { + assert.equal(previewBranchProblem(" preview \n"), null); + // ...and the trimmed value, not the raw one, is what the complaint names. + assert.match(previewBranchProblem(" main ") ?? "", /"main" is the production branch/); +}); + +test("previewBranchProblem refuses the production branch names", () => { + for (const branch of ["main", "master", "production"]) { + const problem = previewBranchProblem(branch); + assert.ok(problem, `expected "${branch}" to be refused`); + assert.match(problem, /production branch/); + assert.ok(problem.includes(branch)); + } +}); + +test("previewBranchProblem refuses uppercase rather than lowercasing it", () => { + const problem = previewBranchProblem("Main"); + assert.ok(problem); + // Not the production-branch sentence: "Main" is refused for its shape, and + // the point is that we never silently rewrite it into "main" (production). + assert.match(problem, /not a valid preview branch name/); + assert.match(problem, /lowercase/); +}); + +test("previewBranchProblem refuses shapes Cloudflare's alias sanitizer would rewrite", () => { + for (const bad of ["bad name", "-lead", "trail-", "under_score", "dot.dot", "sl/ash"]) { + assert.ok(previewBranchProblem(bad), `expected "${bad}" to be refused`); + } +}); + +test("previewBranchProblem is length-bounded at 28, not 29", () => { + assert.equal(MAX_PREVIEW_BRANCH, 28); + const twentyEight = "a".repeat(28); + const twentyNine = "a".repeat(29); + assert.equal(twentyEight.length, 28); + assert.equal(previewBranchProblem(twentyEight), null); + const problem = previewBranchProblem(twentyNine); + assert.ok(problem); + assert.match(problem, /at most 28 characters/); + assert.match(problem, /is 29/); +}); + +test("previewBranchProblem refuses empty and non-strings", () => { + assert.ok(previewBranchProblem("")); + assert.ok(previewBranchProblem(" ")); + assert.ok(previewBranchProblem(undefined)); + assert.ok(previewBranchProblem(null)); + assert.ok(previewBranchProblem(42)); + assert.match(previewBranchProblem(42) ?? "", /must be a string/); +}); + +test("pagesDeployArgs without a preview is byte-identical to the production argv", () => { + assert.deepEqual( + pagesDeployArgs({ outDir: "/repo/export/out", project: "anilyzer" }), + ["wrangler", "pages", "deploy", "/repo/export/out", "--project-name", "anilyzer"], + ); + // An explicitly-empty / whitespace preview is the same thing as none: no + // --branch, so wrangler infers it exactly as it always has. + assert.deepEqual( + pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: "" }), + ["wrangler", "pages", "deploy", "/o", "--project-name", "p"], + ); + assert.deepEqual( + pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: " " }), + ["wrangler", "pages", "deploy", "/o", "--project-name", "p"], + ); +}); + +test("pagesDeployArgs appends --branch for a preview", () => { + assert.deepEqual( + pagesDeployArgs({ + outDir: "/repo/export/out", + project: "anilyzer", + previewBranch: "tags-exclude", + }), + [ + "wrangler", + "pages", + "deploy", + "/repo/export/out", + "--project-name", + "anilyzer", + "--branch", + "tags-exclude", + ], + ); + assert.deepEqual( + pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: " x " }).slice(-2), + ["--branch", "x"], + ); +}); + +test("previewAliasUrl is the branch alias hostname", () => { + assert.equal( + previewAliasUrl("anilyzer", "tags-exclude"), + "https://tags-exclude.anilyzer.pages.dev", + ); + assert.equal(previewAliasUrl(" anilyzer ", " preview "), "https://preview.anilyzer.pages.dev"); +}); + +test("deploymentUrlIn pulls the URL out of wrangler's 'Take a peek' line", () => { + const line = + "✨ Deployment complete! Take a peek over at https://a1b2c3d4.anilyzer.pages.dev"; + assert.equal(deploymentUrlIn(line, "anilyzer"), "https://a1b2c3d4.anilyzer.pages.dev"); +}); + +test("deploymentUrlIn matches the branch alias line too", () => { + assert.equal( + deploymentUrlIn("Branch alias: https://tags-exclude.anilyzer.pages.dev", "anilyzer"), + "https://tags-exclude.anilyzer.pages.dev", + ); +}); + +test("deploymentUrlIn returns null for lines with no URL for THIS project", () => { + assert.equal(deploymentUrlIn("Uploading... (12/12)", "anilyzer"), null); + assert.equal(deploymentUrlIn("", "anilyzer"), null); + // Another project's pages.dev URL is not this deployment. + assert.equal( + deploymentUrlIn("see https://deadbeef.jeralyzer.pages.dev", "anilyzer"), + null, + ); + // The bare apex is not a deployment URL either — it needs a label in front. + assert.equal(deploymentUrlIn("https://anilyzer.pages.dev", "anilyzer"), null); + 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); + assert.equal(deploymentUrlIn("https://h.a.b.pages.dev", "a.b"), "https://h.a.b.pages.dev"); +}); diff --git a/common/lib/pagesDeploy.ts b/common/lib/pagesDeploy.ts @@ -0,0 +1,119 @@ +// Cloudflare Pages deploy argv and preview-branch rules. +// +// WHY THIS IS PURE. The deploy itself is server-only (it spawns wrangler), but +// the RULES about it are needed in three places at once: the server action that +// refuses a bad name, the ops route that must 400 before any job starts, and the +// browser control that greys its button out as you type. Keeping the rules in +// one node-free module is what makes those three agree by construction instead +// of by three copies of a regex. No `node:` imports belong here. +// +// WHAT A PREVIEW IS. `wrangler pages deploy` with no `--branch` infers the +// branch from the git checkout, and Cloudflare treats a deploy to the project's +// PRODUCTION branch as production and anything else as a preview. A preview gets +// its own branch alias, https://<branch>.<project>.pages.dev, plus an immutable +// per-deployment https://<hash>.<project>.pages.dev. + +// The branch names Cloudflare Pages projects use as their production branch. +// Deploying to one of these is not a preview — it is the live site — so the +// preview path refuses them rather than quietly shipping production. +const PRODUCTION_BRANCHES = new Set(["main", "master", "production"]); + +// The maximum length of a branch name we accept. Cloudflare sanitizes a branch +// name into the alias hostname label; keeping to what the sanitizer preserves +// verbatim means the alias we PREDICT (previewAliasUrl, shown in the UI before +// the job ends) is the alias that actually resolves. +export const MAX_PREVIEW_BRANCH = 28; + +// Exactly the shape Cloudflare's alias sanitizer keeps unchanged: lowercase +// alphanumerics and inner dashes, 1..MAX_PREVIEW_BRANCH chars, no leading or +// trailing dash. Uppercase is refused rather than lowercased for us, because a +// silently-rewritten name makes the printed alias a guess. +const PREVIEW_BRANCH_RE = /^[a-z0-9](?:[a-z0-9-]{0,26}[a-z0-9])?$/; + +/** + * Why `name` may not be used as a preview branch, as ONE sentence — or null + * when it is fine. The value is trimmed first, so trailing whitespace from a + * text input is never itself the complaint. + */ +export function previewBranchProblem(name: unknown): string | null { + if (typeof name !== "string") { + return "A preview branch name must be a string."; + } + const branch = name.trim(); + if (!branch) { + return 'A preview needs a branch name (for example "preview").'; + } + if (PRODUCTION_BRANCHES.has(branch)) { + return `"${branch}" is the production branch; a preview needs another name.`; + } + if (branch.length > MAX_PREVIEW_BRANCH) { + return `A preview branch name is at most ${MAX_PREVIEW_BRANCH} characters ("${branch}" is ${branch.length}).`; + } + if (!PREVIEW_BRANCH_RE.test(branch)) { + return `"${branch}" is not a valid preview branch name — use lowercase letters, digits and dashes, starting and ending with a letter or digit.`; + } + return null; +} + +/** + * The wrangler argv (everything AFTER `pnpm dlx`) for one Pages deploy. + * + * With no `previewBranch` this is byte-identical to what the production deploy + * has always run — `--branch` absent means wrangler infers the branch from the + * checkout, which is the pre-existing behaviour and is deliberately unchanged. + */ +export function pagesDeployArgs(opts: { + outDir: string; + project: string; + previewBranch?: string; +}): string[] { + const args = [ + "wrangler", + "pages", + "deploy", + opts.outDir, + "--project-name", + opts.project, + ]; + const branch = opts.previewBranch?.trim(); + if (branch) args.push("--branch", branch); + return args; +} + +/** + * The stable branch alias a preview deploy lands on. Knowable BEFORE the deploy + * finishes (that is the point — the UI links it while the log still streams), + * because the alias is a function of the project and the branch alone. + */ +export function previewAliasUrl(project: string, branch: string): string { + return `https://${branch.trim()}.${project.trim()}.pages.dev`; +} + +/** + * The per-deployment URL wrangler prints ("Take a peek over at https://…"), + * pulled out of one log line — or null when the line carries none. + * + * 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-]{0,61}[A-Za-z0-9])?\\.${escapeRe(p)}\\.pages\\.dev(?![A-Za-z0-9.-])`, + ); + const m = re.exec(line); + return m ? m[0] : null; +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { isValidSiteId } from "yt-dlp-transcript-common/lib/site"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { QueueOutcome } from "../../channels/lib/queueForSlugs"; @@ -200,6 +201,80 @@ export function reqSiteIds(body: OpsBody): string[] { return ids; } +// A PREVIEW BRANCH, JUDGED BEFORE ANY JOB STARTS. `previewBranchProblem` is the +// same function the server action and the browser control use — this is not a +// second opinion, it is the one opinion asked earlier. Earlier matters: on +// build-deploy the action's own check happens before its build, but a `siteIds` +// fan-out would otherwise walk every site to refuse each in turn and answer +// with a joined list of the same sentence. +export function optPreviewBranch(body: OpsBody): string | undefined { + const raw = body.preview; + if (raw === undefined) return undefined; + const problem = previewBranchProblem(raw); + if (problem) throw new OpsInputError(problem); + return (raw as string).trim(); +} + +// The site fan-out the deploy routes run: start one managed job per site, +// keeping every id, and refuse with a 400 when NONE started. +// +// SHARED BECAUSE THE CAREFUL PART IS EASY TO GET WRONG TWICE. One site's throw +// cannot cost the others their job ids — an exception out of the loop becomes a +// 500 carrying no `jobs` at all, while the jobs already queued run on with +// nobody holding their ids. And asking for deploys and getting NONE is a 400 +// carrying the reason, not a cheerful `{ ok: true, jobs: [] }`: `--wait` would +// exit 0 on that and report success about a deploy that never started. +// +// `decorate` adds per-site keys to a job entry (the preview alias); they are +// repeated at the top level in the single-job case, exactly as `jobId` is, so a +// one-site caller never has to index into `jobs`. +export async function fanOutSiteJobs( + siteIds: string[], + start: (siteId: string) => Promise<StreamActionResult>, + decorate?: (siteId: string) => Record<string, unknown>, +): Promise<NextResponse> { + const jobs: Record<string, unknown>[] = []; + const extras: Record<string, unknown>[] = []; + const skipped: { siteId: string; reason: string }[] = []; + let info = false; + for (const siteId of siteIds) { + let result: StreamActionResult; + try { + result = await start(siteId); + } catch (e) { + skipped.push({ siteId, reason: (e as Error).message }); + continue; + } + if (!result.ok) { + skipped.push({ siteId, reason: result.error }); + info = info || result.info === true; + continue; + } + // The stream is cancelled, never returned — see this file's header. + void result.stream.cancel(); + const extra = decorate?.(siteId) ?? {}; + jobs.push({ siteId, jobId: result.jobId, ...extra }); + extras.push(extra); + } + if (jobs.length === 0) { + // One site asked for, one reason: the bare sentence the action gave, + // exactly as jobResponse has always returned it. + return opsFail( + skipped.length === 1 + ? skipped[0].reason + : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), + 400, + info ? { info: true } : undefined, + ); + } + return NextResponse.json({ + ok: true, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...extras[0] } : {}), + }); +} + export function oneOf<T extends string>( body: OpsBody, key: string, diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -1,23 +1,27 @@ import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite } from "yt-dlp-transcript-common/lib/site"; import { buildAndDeployAction, buildAndDeployAllSitesAction, } from "../../../sites/lib/buildAction"; import { + fanOutSiteJobs, jobResponse, OpsInputError, ops, - opsFail, optBool, + optPreviewBranch, reqSiteIds, } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteId: string | siteIds: string[], skipArchives? } +// POST { siteId: string | siteIds: string[], skipArchives?, preview? } // | { all: true, skipArchives? } -// -> { ok: true, jobs: [{ siteId, jobId }], skipped: [{ siteId, reason }], -// jobId? } +// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], +// skipped: [{ siteId, reason }], jobId?, previewUrl? } // // Build THEN deploy: one managed job per site (one log, one Cancel each), so // the caller polls /api/jobs/<jobId>/log exactly as it does for build-site. @@ -31,63 +35,52 @@ export const dynamic = "force-dynamic"; // // Asking for builds and getting NONE is still a 400 carrying the reason, not a // cheerful `{ ok: true, jobs: [] }`: --wait would exit 0 on it and report -// success about a deploy that never started. +// success about a deploy that never started. That, and the per-site error +// handling, now live in fanOutSiteJobs, shared with deploy-site. +// +// `preview` names a branch and makes the DEPLOY half a Cloudflare Pages preview +// (see deploy-site). It is refused alongside `all`: the all-sites path deploys +// every configured site through the docker phase runner, which has no per-site +// branch to thread one through, and silently building every site and shipping +// them to production would be the worst possible reading of the request. export async function POST(request: Request) { return ops( request, - ["siteId", "siteIds", "all", "skipArchives"], + ["siteId", "siteIds", "all", "skipArchives", "preview"], async (body) => { const skipArchives = optBool(body, "skipArchives"); + const preview = optPreviewBranch(body); if (optBool(body, "all")) { if (body.siteId !== undefined || body.siteIds !== undefined) { throw new OpsInputError( 'send either "siteId"/"siteIds" or "all", not both', ); } - return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); - } - const jobs: { siteId: string; jobId: string }[] = []; - const skipped: { siteId: string; reason: string }[] = []; - let info = false; - for (const siteId of reqSiteIds(body)) { - // ONE SITE'S THROW CANNOT COST THE OTHERS THEIR JOB IDS. An exception - // out of here becomes a 500 carrying no `jobs` at all, while the builds - // already queued run on with nobody holding their ids. reqSiteIds - // rejects the malformed-id case before any job starts; this catches - // whatever else the action can raise. - let result: Awaited<ReturnType<typeof buildAndDeployAction>>; - try { - result = await buildAndDeployAction(siteId, skipArchives); - } catch (e) { - skipped.push({ siteId, reason: (e as Error).message }); - continue; - } - if (!result.ok) { - skipped.push({ siteId, reason: result.error }); - info = info || result.info === true; - continue; + if (preview !== undefined) { + throw new OpsInputError( + '"preview" is not supported with "all" — name the sites to preview with "siteIds"', + ); } - // The stream is cancelled, never returned — see _lib's header. - void result.stream.cancel(); - jobs.push({ siteId, jobId: result.jobId }); - } - if (jobs.length === 0) { - // One site asked for, one reason: the bare sentence the action gave, - // exactly as jobResponse has always returned it. - return opsFail( - skipped.length === 1 - ? skipped[0].reason - : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), - 400, - info ? { info: true } : undefined, - ); + return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); } - return NextResponse.json({ - ok: true, - jobs, - skipped, - ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}), - }); + const siteIds = reqSiteIds(body); + return fanOutSiteJobs( + siteIds, + (siteId) => + buildAndDeployAction( + siteId, + skipArchives, + preview ? { previewBranch: preview } : undefined, + ), + preview + ? (siteId) => { + // Only reached for a site whose job STARTED, which the action + // does only once it has a cloudflareProject. + const project = getSite(siteId, getPaths()).cloudflareProject; + return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; + } + : undefined, + ); }, ); } diff --git a/editor/app/api/ops/deploy-site/route.ts b/editor/app/api/ops/deploy-site/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite } from "yt-dlp-transcript-common/lib/site"; +import { deployExportAction } from "../../../sites/lib/deployAction"; +import { fanOutSiteJobs, ops, optPreviewBranch, reqSiteIds } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { siteId: string | siteIds: string[], preview? } +// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], +// skipped: [{ siteId, reason }], jobId?, previewUrl? } +// +// DEPLOY ONLY — the already-built export/out, no build. build-deploy's other +// half, and the half a preview is actually for: build once, look at the preview, +// then roll the SAME bundle to production without rebuilding it. +// +// `preview` is a branch name. Cloudflare Pages treats a deploy to any branch but +// the project's production branch as a preview, reachable at the branch alias — +// which is why `previewUrl` can be in the response at all, before the job has +// done anything: the alias is a function of the project and the branch, not of +// the deployment. The immutable per-deployment URL only exists afterwards and is +// in the job's log. +// +// Response shape is build-deploy's, so a runbook can swap one for the other. +export async function POST(request: Request) { + return ops(request, ["siteId", "siteIds", "preview"], async (body) => { + const preview = optPreviewBranch(body); + const siteIds = reqSiteIds(body); + return fanOutSiteJobs( + siteIds, + (siteId) => deployExportAction(siteId, preview ? { previewBranch: preview } : undefined), + preview + ? (siteId) => { + // Only reached for a site whose job STARTED, which the action does + // only once it has a cloudflareProject — so this read cannot be the + // thing that fails. + const project = getSite(siteId, getPaths()).cloudflareProject; + return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; + } + : undefined, + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/sites/[siteId]/publish/page.tsx b/editor/app/sites/[siteId]/publish/page.tsx @@ -96,10 +96,15 @@ export default async function SitePublishPage({ <h3 className="font-semibold">Deploy static export</h3> <p className="text-sm text-muted-foreground"> Publishes the most recently built site at{" "} - <code>export/out/</code> to Cloudflare Pages. Build first. + <code>export/out/</code> to Cloudflare Pages — to production, + or to a preview branch you can look at first. Build first. </p> </div> - <DeployButton siteId={siteId} siteTitle={siteTitle} /> + <DeployButton + siteId={siteId} + siteTitle={siteTitle} + cloudflareProject={site.cloudflareProject ?? null} + /> </div> </div> </details> diff --git a/editor/app/sites/components/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx @@ -1,6 +1,12 @@ "use client"; +import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { + MAX_PREVIEW_BRANCH, + previewAliasUrl, + previewBranchProblem, +} from "yt-dlp-transcript-common/lib/pagesDeploy"; import { cancelJobAction } from "../../jobs/actions"; import { deployExportAction } from "../lib/deployAction"; @@ -8,21 +14,139 @@ type Props = { // The site whose Publish tab this is — always a real site. siteId: string; siteTitle: string; + // The Cloudflare Pages project, when the site has one. Needed to SHOW the + // preview alias, which is a function of project + branch — so with no project + // there is no preview to offer, and the control says that instead of linking + // a hostname that cannot exist. + cloudflareProject?: string | null; }; -export function DeployButton({ siteId, siteTitle }: Props) { +const DEFAULT_PREVIEW_BRANCH = "preview"; + +// Two deploys of the same already-built bundle: to production, or to a +// Cloudflare Pages preview branch. +// +// THE PRODUCTION BUTTON SAYS "PRODUCTION" NOW. With a preview control beside +// it, a button labelled only "Deploy" is the one an operator clicks by reflex +// when they meant the safe one; the extra word is the whole cost of not doing +// that. +export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) { return ( - <StreamActionLog - trigger={() => deployExportAction(siteId)} - cancelAction={cancelJobAction} - buttonLabel="Deploy" - runningLabel="Deploying…" - label="Deploy export" - extraControls={ + <div className="flex flex-col gap-6"> + <StreamActionLog + trigger={() => deployExportAction(siteId)} + cancelAction={cancelJobAction} + buttonLabel="Deploy to production" + runningLabel="Deploying…" + label="Deploy export" + extraControls={ + <p className="text-sm text-muted-foreground"> + Deploying <strong>{siteTitle}</strong> (<code>{siteId}</code>). + </p> + } + /> + <PreviewDeploy + siteId={siteId} + cloudflareProject={cloudflareProject ?? null} + /> + </div> + ); +} + +function PreviewDeploy({ + siteId, + cloudflareProject, +}: { + siteId: string; + cloudflareProject: string | null; +}) { + const [branch, setBranch] = useState(DEFAULT_PREVIEW_BRANCH); + const [deployed, setDeployed] = useState<string | null>(null); + // The SAME function the action and the ops route refuse with, so the button + // greys out on exactly the names the server would have rejected and the + // sentence an operator reads here is the sentence they would have got back. + const problem = previewBranchProblem(branch); + // The alias is knowable the moment the name is valid — project + branch and + // nothing else — so it is shown before the deploy rather than fished out of + // the finished log afterwards. + const alias = + problem === null && cloudflareProject + ? previewAliasUrl(cloudflareProject, branch) + : null; + + return ( + <section + aria-label="Deploy preview" + className="flex flex-col gap-2 border-t border-border pt-4" + > + <div> + <h4 className="text-sm font-semibold">Deploy a preview</h4> + <p className="text-sm text-muted-foreground"> + Publishes the same bundle to a Cloudflare Pages branch instead of + production, so you can look at it first. The live site is untouched. + Previews share the production archive bucket. + </p> + </div> + <StreamActionLog + trigger={() => deployExportAction(siteId, { previewBranch: branch })} + cancelAction={cancelJobAction} + buttonLabel="Deploy preview" + runningLabel="Deploying preview…" + label="Deploy preview" + disabled={problem !== null} + // `started` only says a job began — the link's wording is all that + // changes, and a failed deploy leaves the PREVIOUS preview at the same + // address, so the link is never a lie about what is there. + onSettled={(started) => { + if (started && alias) setDeployed(alias); + }} + extraControls={ + <label className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Branch</span> + <input + type="text" + aria-label="preview branch" + placeholder="preview" + value={branch} + // Twice the limit, so an over-long name is refused with the + // sentence that says WHY rather than silently truncated into a + // different branch than the one that was typed. + maxLength={MAX_PREVIEW_BRANCH * 2} + onChange={(e) => setBranch(e.target.value)} + className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm" + /> + </label> + } + /> + {problem ? ( + <p + role="status" + // NOT "preview branch problem". An accessible name that CONTAINS + // another control's name makes every by-label lookup for the input + // ambiguous — a real screen-reader confusion as much as a test one. + // Only the input answers to "preview branch". + aria-label="preview problem" + className="text-sm text-destructive" + > + {problem} + </p> + ) : alias ? ( + <p className="text-sm text-muted-foreground break-all"> + {deployed === alias ? "Deployed to " : "Will deploy to "} + <a + className="font-mono underline" + href={alias} + target="_blank" + rel="noreferrer" + > + {alias} + </a> + </p> + ) : ( <p className="text-sm text-muted-foreground"> - Deploying <strong>{siteTitle}</strong> (<code>{siteId}</code>). + Set a Cloudflare Pages project for this site to preview it. </p> - } - /> + )} + </section> ); } diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts @@ -15,12 +15,14 @@ import { archiveCombinedLiveChat, } from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { + PREVIEW_SHARES_ARCHIVES_NOTICE, dockerAvailable, dockerSiteOutDir, resolveOutDir, @@ -103,15 +105,27 @@ export async function buildExportAction( // succeeds (and wasn't cancelled), deploy it — all in ONE managed job so the UI // shows a single combined streamed log with a single Cancel. Serialized on the // deploy queue so it never overlaps a standalone deploy or another build-deploy. +// +// `opts.previewBranch` makes the deploy half a Cloudflare Pages PREVIEW (branch +// alias; production untouched) — same job kind, same queue, same single log. export async function buildAndDeployAction( siteId: string, skipArchives?: boolean, + opts?: { previewBranch?: string }, ): Promise<StreamActionResult> { const paths = getPaths(); const id = siteId.trim(); if (!id) { return { ok: false, error: "Select a site to build and deploy" }; } + // Refuse an unusable preview name BEFORE the build starts. Discovering a typo + // at the deploy step would cost a whole build to learn it. + const previewBranch = opts?.previewBranch; + if (previewBranch !== undefined) { + const problem = previewBranchProblem(previewBranch); + if (problem) return { ok: false, error: problem }; + } + const branch = previewBranch?.trim(); const site = getSite(id, paths); if (!site.cloudflareProject) { return { @@ -133,7 +147,8 @@ export async function buildAndDeployAction( if (buildCode !== 0) { throw new Error(`Build failed (exit ${buildCode}) — not deploying.`); } - onLog("\n=== Deploy ===\n"); + onLog(branch ? `\n=== Deploy (preview "${branch}") ===\n` : "\n=== Deploy ===\n"); + if (branch) onLog(PREVIEW_SHARES_ARCHIVES_NOTICE); // Push oversize archives to R2 before the Pages deploy (no-op when R2 // isn't configured), so the published manifest URLs resolve. const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths); @@ -147,6 +162,7 @@ export async function buildAndDeployAction( site, resolveOutDir(id, paths), paths, + { previewBranch: branch }, ); if (signal.aborted) return; if (deployCode !== 0) { diff --git a/editor/app/sites/lib/buildDeployCore.ts b/editor/app/sites/lib/buildDeployCore.ts @@ -10,6 +10,11 @@ import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "yt-dlp-transcript-common/jobs/runChild"; +import { + deploymentUrlIn, + pagesDeployArgs, + previewAliasUrl, +} from "yt-dlp-transcript-common/lib/pagesDeploy"; import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import type { Site } from "yt-dlp-transcript-common/lib/site"; @@ -93,6 +98,18 @@ function archiveStagingDir(siteId: string, paths: Paths): string { ); } +// Said once at the top of every preview deploy, because the one thing a preview +// does NOT isolate is the archive bucket: R2 has no per-branch namespace, so a +// preview's oversize archives overwrite the keys production's manifest points +// at. That is cheap and harmless in practice — the upload skips any object R2 +// already holds at the same size, and an unchanged channel re-zips byte-stable +// — but "harmless because of a size check" is exactly the kind of thing an +// operator should be told rather than left to discover. +export const PREVIEW_SHARES_ARCHIVES_NOTICE = + "[notice] A preview shares the production R2 archive bucket — unchanged " + + "archives are skipped, so this is cheap, but a CHANGED archive replaces the " + + "one production links to.\n"; + // Cache-Control set on every uploaded archive. Served through a Cloudflare custom // domain, this lets the CDN absorb repeated/abusive downloads at the edge instead // of hitting R2 (each origin GET is a billable Class B op), which is the main cost @@ -230,24 +247,42 @@ export async function runArchiveUploadIntoLog( // Pages project, streaming into `onLog`, returning the exit code. Runs on the // host with the host's Cloudflare credentials (process.env) — deploy never runs // inside a container, so container wrangler auth is never needed. +// +// `opts.previewBranch` makes it a PREVIEW deploy: Cloudflare treats a deploy to +// any branch but the project's production branch as a preview, reachable at the +// branch alias. Omitting it leaves the argv byte-identical to what production +// has always run — no `--branch`, so wrangler infers the branch from the +// checkout, which is the long-standing behaviour (and the long-standing hazard: +// a "production" deploy run from a non-main checkout silently becomes a +// preview). +// +// Either way, the URL wrangler prints ("Take a peek over at …") earns one +// terminal line of its own, because the streamed log scrolls and an operator +// who looked away has nowhere else to find it. A preview also gets the stable +// branch alias, which is knowable without reading the log at all. export async function runDeployIntoLog( onLog: (line: string) => void, signal: AbortSignal, site: Site, outDir: string, paths: Paths, + opts?: { previewBranch?: string }, ): Promise<number> { - return runChildIntoLog(onLog, signal, { + const project = site.cloudflareProject as string; + const previewBranch = opts?.previewBranch?.trim() || undefined; + + // Spot the deployment URL as it streams past rather than re-reading the + // finished log file: the log is the operator's too, and buffering it a second + // time to grep it would double a big deploy's memory for one line of output. + let deploymentUrl: string | null = null; + const watch = (line: string) => { + if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project); + onLog(line); + }; + + const code = await runChildIntoLog(watch, signal, { command: "pnpm", - args: [ - "dlx", - "wrangler", - "pages", - "deploy", - outDir, - "--project-name", - site.cloudflareProject as string, - ], + args: ["dlx", ...pagesDeployArgs({ outDir, project, previewBranch })], cwd: paths.exportDir, env: { ...process.env, @@ -257,6 +292,22 @@ export async function runDeployIntoLog( SITE_ID: site.siteId, }, }); + + // Only on success. A URL scraped out of a failed run points at nothing — or + // worse, at the deployment that is still live. + if (code === 0) { + if (previewBranch) { + const alias = previewAliasUrl(project, previewBranch); + onLog( + `[preview] ${alias}` + + (deploymentUrl ? ` (this deployment: ${deploymentUrl})` : "") + + "\n", + ); + } else if (deploymentUrl) { + onLog(`[deployed] ${deploymentUrl}\n`); + } + } + return code; } // --------------------------------------------------------------------------- diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts @@ -1,12 +1,15 @@ "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"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { + PREVIEW_SHARES_ARCHIVES_NOTICE, resolveOutDir, runArchiveUploadIntoLog, runDeployIntoLog, @@ -14,13 +17,28 @@ import { const DEPLOY_QUEUE = "deploy"; +// Deploy the already-built export/out. With `opts.previewBranch` it goes to a +// Cloudflare Pages PREVIEW instead of production, reachable at the branch alias +// — same job kind, same queue, same log, so nothing downstream has to learn a +// new word for it. export async function deployExportAction( siteId: string, + opts?: { previewBranch?: string }, ): Promise<StreamActionResult> { const paths = getPaths(); if (!siteId.trim()) { return { ok: false, error: "Select a site to deploy" }; } + // NAMING A BRANCH IS THE REQUEST, so an unusable name is a refusal and never + // a quiet fall-through to a production deploy — the one mistake this feature + // must not make. Checked before the job starts, with the same sentence the + // browser control and the ops route give. + const previewBranch = opts?.previewBranch; + if (previewBranch !== undefined) { + const problem = previewBranchProblem(previewBranch); + if (problem) return { ok: false, error: problem }; + } + const branch = previewBranch?.trim(); const site = getSite(siteId.trim(), paths); if (!site.cloudflareProject) { return { @@ -28,11 +46,31 @@ 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, paths, fn: async (onLog, signal) => { + // The production path logs no banner and gains none here: its log has + // always opened on wrangler's own first line. + if (branch) { + onLog(`=== Deploy (preview "${branch}") ===\n`); + onLog(PREVIEW_SHARES_ARCHIVES_NOTICE); + } // Push oversize archives to R2 first, so the manifest URLs the Pages // deploy publishes resolve immediately. No-op when R2 isn't configured. const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths); @@ -44,8 +82,9 @@ export async function deployExportAction( onLog, signal, site, - resolveOutDir(site.siteId, paths), + outDir, paths, + { previewBranch: branch }, ); if (signal.aborted) return; if (code !== 0) throw new Error(`Deploy failed (exit ${code}).`); diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -50,7 +50,8 @@ type OpsResponse = { queued?: string[]; jobIds?: string[]; skipped?: { slug?: string; siteId?: string; reason: string }[]; - jobs?: { siteId: string; jobId: string }[]; + jobs?: { siteId: string; jobId: string; previewUrl?: string }[]; + previewUrl?: string; }; async function ops( @@ -768,6 +769,166 @@ test("build-site and build-deploy each take siteId or siteIds, and refuse both o } }); +// --- preview deploys ---------------------------------------------------------- +// +// NOTHING HERE STARTS A DEPLOY JOB, and that is deliberate. Every external +// binary this suite touches is a fake in e2e/fixtures/bin (yt-dlp, whisper, +// ffmpeg, …); wrangler has no fake, because until previews existed no spec had +// any reason to reach a deploy. So these assert the half that is decided +// BEFORE a job exists — which is the whole of what the preview rules are — and +// prove it by watching the job sidecars not appear. +test("deploy-site and build-deploy refuse a preview name that is not one, before any job", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + await writeSite("previewsite", { cloudflareProject: "proj" }); + + for (const action of ["deploy-site", "build-deploy"]) { + // "main" is the production branch: deploying there is not a preview, it is + // the live site, and the refusal says so rather than shipping it. + const before = await listJobIds(); + const main = await ops(request, action, { + siteId: "previewsite", + preview: "main", + }); + expect(main.status, action).toBe(400); + expect(main.body.error, action).toContain("production branch"); + expect(await listJobIds(), action).toEqual(before); + + // Uppercase is refused rather than lowercased FOR the caller: silently + // rewriting "Main" into "main" would deploy to production. + const upper = await ops(request, action, { + siteId: "previewsite", + preview: "Main", + }); + expect(upper.status, action).toBe(400); + expect(upper.body.error, action).toContain("not a valid preview branch name"); + expect(upper.body.error, action).not.toContain("production branch"); + + const spaced = await ops(request, action, { + siteId: "previewsite", + preview: "bad name", + }); + expect(spaced.status, action).toBe(400); + expect(spaced.body.error, action).toContain("not a valid preview branch name"); + + const tooLong = await ops(request, action, { + siteId: "previewsite", + preview: "a".repeat(29), + }); + expect(tooLong.status, action).toBe(400); + expect(tooLong.body.error, action).toContain("at most 28 characters"); + + expect(await listJobIds(), action).toEqual(before); + } +}); + +test("a valid preview is accepted and reaches the action, on both deploy routes", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + // NO Cloudflare project, on purpose: the action's own refusal is the last + // gate before wrangler, so a 400 saying THAT — rather than "unknown key: + // preview" or a branch complaint — proves the name passed validation and the + // route got all the way to the action, without a deploy ever starting. + await writeSite("noproj", {}); + + for (const action of ["deploy-site", "build-deploy"]) { + const before = await listJobIds(); + const { status, body } = await ops(request, action, { + siteId: "noproj", + preview: "tags-exclude", + }); + expect(status, action).toBe(400); + expect(body.error, action).toContain("no Cloudflare Pages project"); + expect(body.error, action).not.toContain("unknown key"); + expect(await listJobIds(), action).toEqual(before); + } +}); + +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, +}) => { + await resetData("title-filter-channel"); + await settings(); + + const both = await ops(request, "deploy-site", { siteId: "a", siteIds: ["a"] }); + expect(both.status).toBe(400); + expect(both.body.error).toContain("not both"); + + const neither = await ops(request, "deploy-site", {}); + expect(neither.status).toBe(400); + expect(neither.body.error).toContain("siteId"); + + const before = await listJobIds(); + const bad = await ops(request, "deploy-site", { siteIds: ["deploysite", "BAD ID"] }); + expect(bad.status).toBe(400); + expect(bad.body.error).toContain("not a valid site id"); + expect(await listJobIds()).toEqual(before); + + // Deploy-only has no all-sites form — build-deploy owns that — so `all` is + // an unknown key here rather than a second spelling of it. + const all = await ops(request, "deploy-site", { all: true }); + expect(all.status).toBe(400); + expect(all.body.error).toContain("unknown key"); +}); + +test("build-deploy refuses a preview alongside all rather than building everything", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + + const before = await listJobIds(); + const { status, body } = await ops(request, "build-deploy", { + all: true, + preview: "tags-exclude", + }); + expect(status).toBe(400); + expect(body.error).toContain('"preview" is not supported with "all"'); + expect(await listJobIds()).toEqual(before); +}); + test("build-site with a bare siteId starts one build-export job", async ({ request, }) => { diff --git a/editor/e2e/site-publish-preview.spec.ts b/editor/e2e/site-publish-preview.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { resetData, writeSite } from "./helpers"; + +// The preview control on a site's Publish tab. +// +// NOTHING HERE CLICKS DEPLOY. Every external binary the suite touches has a +// fake in e2e/fixtures/bin; wrangler has none, because no spec has ever had a +// reason to reach a deploy. What is worth pinning anyway is everything decided +// BEFORE the job: that the two deploys are told apart by their labels, that the +// branch input refuses exactly what the server refuses and with the same +// sentence, and that the alias is shown while you type — it is a function of +// the project and the branch, so it is knowable without deploying anything. + +// The Deploy controls live under the "Individual steps" disclosure. +async function openIndividualSteps(page: Page, siteId: string) { + await page.goto(`/sites/${siteId}/publish`); + await page.getByText("Individual steps").click(); +} + +const preview = (page: Page) => + page.getByRole("region", { name: "Deploy preview" }); + +// EXACT, not the default substring match. getByLabel("preview branch") once +// matched the problem message too, whose aria-label began with the same two +// words — the ambiguity was the component's, and it is fixed there; this keeps +// the spec from being able to re-acquire it silently. +const branchInput = (page: Page) => + preview(page).getByRole("textbox", { name: "preview branch", exact: true }); + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("the production deploy says production, and a preview sits beside it", async ({ + page, +}) => { + await writeSite("testsite", { cloudflareProject: "proj" }); + await openIndividualSteps(page, "testsite"); + + // "Deploy" alone is the button somebody clicks by reflex when they meant the + // safe one, which is the whole reason for the extra word. + await expect( + page.getByRole("button", { name: "Deploy to production", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Deploy", exact: true }), + ).toHaveCount(0); + + const panel = preview(page); + await expect(panel.getByRole("button", { name: "Deploy preview" })).toBeVisible(); + // Defaulted, so the common case is one click. + await expect(branchInput(page)).toHaveValue("preview"); +}); + +test("the alias is shown before the deploy, and follows the branch name", async ({ + page, +}) => { + await writeSite("testsite", { cloudflareProject: "proj" }); + await openIndividualSteps(page, "testsite"); + const panel = preview(page); + + const link = panel.getByRole("link", { + name: "https://preview.proj.pages.dev", + }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute("href", "https://preview.proj.pages.dev"); + + const input = branchInput(page); + await input.fill("tags-exclude"); + await expect( + panel.getByRole("link", { name: "https://tags-exclude.proj.pages.dev" }), + ).toBeVisible(); +}); + +test("main disables the preview button and says why", async ({ page }) => { + await writeSite("testsite", { cloudflareProject: "proj" }); + await openIndividualSteps(page, "testsite"); + const panel = preview(page); + + const button = panel.getByRole("button", { name: "Deploy preview" }); + await expect(button).toBeEnabled(); + + await branchInput(page).fill("main"); + await expect(button).toBeDisabled(); + await expect(panel.getByText(/is the production branch/)).toBeVisible(); + // No alias while the name is refused — a link to a hostname that will not + // exist is worse than none. + await expect(panel.getByRole("link")).toHaveCount(0); + + // Uppercase is refused for its shape rather than lowercased into production. + await branchInput(page).fill("Main"); + await expect(button).toBeDisabled(); + await expect( + panel.getByText(/not a valid preview branch name/), + ).toBeVisible(); + + // And a good name re-arms it. + await branchInput(page).fill("rc-1"); + await expect(button).toBeEnabled(); + await expect( + panel.getByRole("link", { name: "https://rc-1.proj.pages.dev" }), + ).toBeVisible(); +}); + +test("a site with no Cloudflare project says so instead of linking a hostname", async ({ + page, +}) => { + await writeSite("noproj", {}); + await openIndividualSteps(page, "noproj"); + const panel = preview(page); + + await expect( + panel.getByText(/Set a Cloudflare Pages project for this site/), + ).toBeVisible(); + await expect(panel.getByRole("link")).toHaveCount(0); +}); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -5269,3 +5269,105 @@ checkout"). Verified 2026-09-22 on `main` @ `766e0873`: - What remains true and is worth keeping: a fresh `git worktree` still needs a composed fixture site copied into its `export/public`, or the export webServer 500s and Playwright dies at the 120 s `config.webServer` timeout. That was always a separate problem. + +## Cloudflare Pages preview deployments (added 2026-09-23, branch `deploy/preview`) + +**Every deploy this app ran before today was production, and not because it said +so.** `runDeployIntoLog` ran `pnpm dlx wrangler pages deploy <outDir> --project-name +<project>` with **no `--branch`**, and wrangler infers the branch from the git +checkout it runs in. Cloudflare Pages treats a deploy to the project's production +branch as production and any other branch as a **preview**. So the pre-existing +hazard is real and is unchanged: a "production" deploy run from a feature-branch +checkout silently lands as a preview. Only the preview path passes `--branch`. + +**The rules live in `common/lib/pagesDeploy.ts`, which is node-free on purpose.** +Four pure functions — `previewBranchProblem`, `pagesDeployArgs`, `previewAliasUrl`, +`deploymentUrlIn` — because the same rule is needed by the server action, by the ops +route (which must 400 before any job starts) and by the browser control that greys +its button out as you type. Tests: `common/lib/pagesDeploy.test.ts` (14). + +- **A valid branch is `/^[a-z0-9](?:[a-z0-9-]{0,26}[a-z0-9])?$/`, max 28 chars** + (`MAX_PREVIEW_BRANCH`). That is exactly what Cloudflare's alias sanitizer keeps + verbatim, which is what makes `previewAliasUrl` — shown in the UI *before* the job + ends — the alias that actually resolves. Uppercase is **refused, never + lowercased**: silently rewriting `Main` into `main` would deploy to production. +- **`main`, `master`, `production` are refused** with "…is the production branch; a + preview needs another name." +- **`pagesDeployArgs` with no `previewBranch` is byte-identical to the old argv.** + Pinned by a test; the production path was not to change. + +**The branch alias is `https://<branch>.<project>.pages.dev` and is knowable before +the deploy.** The immutable per-deployment URL (`https://<hash>.<project>.pages.dev`) +is only knowable after — wrangler prints it on its "Take a peek over at …" line, which +`deploymentUrlIn` scrapes off the stream as it goes past (not by re-reading the log +file). On exit 0 the job log gets ONE line: `[preview] <alias> (this deployment: +<hash url>)`, or `[deployed] <hash url>` for production. + +**A preview shares the production R2 archive bucket.** R2 has no per-branch +namespace and the keys are `<siteId>/archives/<file>.zip` either way. Cheap in +practice (the upload skips any object R2 already holds at the same size), but a +*changed* archive replaces the one production's manifest links to. +`PREVIEW_SHARES_ARCHIVES_NOTICE` (`editor/app/sites/lib/buildDeployCore.ts`) is +logged once at the top of every preview deploy, by both actions. + +**Three surfaces, one rule.** `deployExportAction(siteId, { previewBranch })` and +`buildAndDeployAction(siteId, skipArchives, { previewBranch })` each call +`previewBranchProblem` and return `{ ok: false, error }` — build-and-deploy checks +*before* the build, so a typo does not cost one. **Job kinds are unchanged** +(`deploy-export`, `build-deploy`): a preview is not a different operation. + +**`/api/ops/deploy-site` is new — deploy-only, of the already-built `export/out`.** +`POST { siteId | siteIds, preview? }`, answering build-deploy's shape plus +`previewUrl` per job (and at top level when exactly one job). It exists because +previewing is only useful if the SAME bundle can then go to production unrebuilt, +and `build-deploy` always builds. `build-deploy` gained `preview` too, but **refuses +it alongside `all`**: the all-sites runner has nowhere to thread a branch, and +building every site to ship it to production is the worst reading of the request. + +**The fan-out loop is `fanOutSiteJobs` in `editor/app/api/ops/_lib.ts`**, shared by +both deploy routes — extracted, not copied, because the careful parts (one site's +throw not costing the others their job ids; zero jobs being a 400 rather than a +cheerful `{ ok: true, jobs: [] }` that `--wait` exits 0 on) are exactly what a copy +gets wrong. `optPreviewBranch` is the body reader; it is the same +`previewBranchProblem`, asked earlier. + +**CLI: `pnpm ops deploy-site --json '{"siteId":"x","preview":"<branch>"}' [--wait]`.** +`previewUrlsIn` (exported for tests) reads the alias off `jobs[]`, falling back to the +top-level key, de-duplicated — the single-job response repeats it in both places. +Printed on **stderr**, last (after `--wait`'s logs), so `pnpm ops … | jq` still sees +nothing but the response. + +**No e2e spec starts a deploy job, deliberately.** Every external binary the suite +touches has a fake in `editor/e2e/fixtures/bin`; **wrangler has none**, because until +previews no spec had a reason to reach a deploy. So `ops-api.spec.ts` asserts the half +decided before a job exists and proves it by watching the `.jobs/*.meta.json` sidecars +not appear; a valid preview is shown to reach the action by the refusal it gets there +("no Cloudflare Pages project"), which is distinguishable from both "unknown key" and +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. diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -33,6 +33,7 @@ // pnpm ops relocate --json '{"slugs":["x"],"locationId":"platter"}' // pnpm ops build-site --json '{"siteId":"anilyzer"}' --wait // pnpm ops build-deploy --json '{"siteIds":["anilyzer","jeralyzer"]}' --wait +// pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"tags-exclude"}' --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json @@ -93,6 +94,10 @@ const ACTIONS = [ "build-index", "build-deploy", "build-site", + // Deploy the ALREADY-BUILT export/out — build-deploy's other half, and the + // one a preview is for: build once, look at the preview, then ship the same + // bundle to production without rebuilding it. + "deploy-site", "relocate", "relocate-back", "evict-clips", @@ -233,6 +238,32 @@ export function parseArgs(argv) { }; } +// The preview alias(es) a response carries, in job order and de-duplicated. +// +// WHY IT IS REPRINTED AT ALL. The alias is already in the JSON, but the JSON is +// what a pipe consumes and the URL is what a person needs, and with --wait it +// scrolls off behind minutes of build log. Printed on stderr, not stdout, so +// `pnpm ops … | jq` still sees nothing but the response — the same split the +// --wait log lines already use. +export function previewUrlsIn(payload) { + const urls = []; + if (payload && Array.isArray(payload.jobs)) { + for (const job of payload.jobs) { + if (job && typeof job.previewUrl === "string") urls.push(job.previewUrl); + } + } + // Only as a fallback: the single-job case repeats jobs[0].previewUrl at the + // top level, and printing it twice would read as two previews. + if (urls.length === 0 && payload && typeof payload.previewUrl === "string") { + urls.push(payload.previewUrl); + } + return [...new Set(urls)]; +} + +function printPreviewUrls(payload) { + for (const url of previewUrlsIn(payload)) console.error(`preview: ${url}`); +} + export function usage() { return [ "Usage: pnpm ops <action> [--json '<body>' | --file <path>] [--wait]", @@ -249,7 +280,14 @@ export function usage() { "--wait-timeout <seconds> gives up and exits 1 instead of waiting forever.", " Default: no timeout — the queue may legitimately hold a job for hours.", "", - 'build-site and build-deploy both take "siteId" (one) or "siteIds" (a list).', + 'build-site, build-deploy and deploy-site all take "siteId" (one) or', + ' "siteIds" (a list).', + "", + '"preview": "<branch>" on deploy-site or build-deploy makes it a Cloudflare', + " Pages PREVIEW instead of production: the same bundle goes to a branch", + " alias, https://<branch>.<project>.pages.dev, and the live site is left", + " alone. The alias is printed after the response. Lowercase letters,", + ' digits and dashes, up to 28 characters; "main" is refused.', "", "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,", " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")", @@ -459,7 +497,10 @@ async function main() { } console.log(JSON.stringify(payload, null, 2)); if (!res.ok || payload.ok === false) return 1; - if (!parsed.wait) return 0; + if (!parsed.wait) { + printPreviewUrls(payload); + return 0; + } // `jobIds` FIRST: a bulk fan-out (relocate, relocate-back) returns an array // and also a single `jobId` when it started exactly one, so reading `jobId` // first would follow one job out of five. Before `jobIds` existed those @@ -474,6 +515,7 @@ async function main() { : []; if (jobIds.length === 0) { // Not a job-starting action (or it queued nothing). --wait is satisfied. + printPreviewUrls(payload); return 0; } let worst = 0; @@ -484,6 +526,9 @@ async function main() { console.error(`[${jobId}] ${status}`); if (status !== "done") worst = 1; } + // LAST, after the logs: with --wait the response scrolled off minutes ago, + // and the alias is the one thing the operator came for. + printPreviewUrls(payload); return worst; } diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -5,7 +5,12 @@ // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; -import { followJob, parseArgs, usage } from "./archilyzer-ops.mjs"; +import { + followJob, + parseArgs, + previewUrlsIn, + usage, +} from "./archilyzer-ops.mjs"; test("no arguments prints usage", () => { assert.equal(parseArgs([]).help, true); @@ -214,9 +219,67 @@ test("--wait-timeout gives up on a job that never ends", async () => { }); // The two build routes used to disagree about the spelling of their one -// argument, so the usage text is where a reader finds out they no longer do. -test("usage says both build routes take siteId or siteIds", () => { - assert.match(usage(), /build-site and build-deploy both take "siteId".*"siteIds"/); +// argument, so the usage text is where a reader finds out they no longer do — +// and deploy-site, added later, is in the same sentence rather than a footnote. +test("usage says all three site routes take siteId or siteIds", () => { + assert.match( + usage(), + /build-site, build-deploy and deploy-site all take "siteId"[\s\S]*"siteIds"/, + ); +}); + +// A preview is the feature an operator reaches for BEFORE a production deploy, +// so the usage text has to say what it is, not merely that a key exists. +test("usage explains preview deploys and names deploy-site", () => { + const u = usage(); + assert.match(u, /Actions:.*deploy-site/); + assert.match(u, /"preview": "<branch>"/); + assert.match(u, /https:\/\/<branch>\.<project>\.pages\.dev/); + assert.match(u, /"main" is refused/); +}); + +test("deploy-site is a POST to its own route", () => { + const parsed = parseArgs([ + "deploy-site", + "--json", + '{"siteId":"anilyzer","preview":"tags-exclude"}', + ]); + assert.equal(parsed.method, "POST"); + assert.equal(parsed.path, "/api/ops/deploy-site"); + assert.deepEqual(parsed.body, { siteId: "anilyzer", preview: "tags-exclude" }); +}); + +test("previewUrlsIn reads the alias off each job", () => { + assert.deepEqual( + previewUrlsIn({ + ok: true, + jobs: [ + { siteId: "a", jobId: "j1", previewUrl: "https://p.a.pages.dev" }, + { siteId: "b", jobId: "j2", previewUrl: "https://p.b.pages.dev" }, + ], + }), + ["https://p.a.pages.dev", "https://p.b.pages.dev"], + ); +}); + +test("previewUrlsIn prints the single-job alias once, not twice", () => { + // The route repeats jobs[0].previewUrl at the top level so a one-site caller + // never indexes into `jobs`; printing both would read as two previews. + assert.deepEqual( + previewUrlsIn({ + ok: true, + jobs: [{ siteId: "a", jobId: "j1", previewUrl: "https://p.a.pages.dev" }], + jobId: "j1", + previewUrl: "https://p.a.pages.dev", + }), + ["https://p.a.pages.dev"], + ); +}); + +test("previewUrlsIn is empty for a production deploy", () => { + assert.deepEqual(previewUrlsIn({ ok: true, jobs: [{ siteId: "a", jobId: "j1" }] }), []); + assert.deepEqual(previewUrlsIn({ ok: true, jobId: "j1" }), []); + assert.deepEqual(previewUrlsIn(null), []); }); test("a recovered log endpoint answering 'running' is not an outcome", async () => {