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:
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 () => {