commit 1aeecab984ff4d15b430cfe7a463f3318226f815
parent 6e1f15b8597282753c6174f804bdec7b7db7d1ed
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 08:51:52 -0400
common: one place that says what a Pages preview branch is
A preview deploy is refused in three places at once — the server action, the
ops route that must 400 before a job starts, and the browser control that greys
its button as you type — so the rule lives in one node-free module rather than
as three copies of a regex that drift. The accepted shape is exactly what
Cloudflare's alias sanitizer keeps verbatim, which is what makes the alias we
predict and link before the job ends the alias that actually resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 266 insertions(+), 0 deletions(-)
diff --git a/common/lib/pagesDeploy.test.ts b/common/lib/pagesDeploy.test.ts
@@ -0,0 +1,154 @@
+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 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,112 @@
+// 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.
+ */
+export function deploymentUrlIn(line: string, project: string): string | null {
+ const p = project.trim();
+ if (!p) return null;
+ const re = new RegExp(
+ `https://[A-Za-z0-9][A-Za-z0-9.-]*\\.${escapeRe(p)}\\.pages\\.dev`,
+ );
+ const m = re.exec(line);
+ return m ? m[0] : null;
+}
+
+function escapeRe(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}