commit b0f48842bd3bf82e765518dba51f6140f119fab9
parent fb68a6bd3d626acf5e6537e68b657044e61bfaae
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 09:05:00 -0400
docs: preview deployments, and the branch-inference hazard that predates them
DEPLOY_CLOUDFLARE gains a Preview deployments section — what a preview is, the
alias, the three surfaces, the shared R2 bucket, and that who may open a
preview is a Cloudflare project setting that defaults to public. It also
records the hazard the feature made visible rather than created: a production
deploy passes no --branch, so wrangler infers one from the checkout and a
"production" deploy from a feature branch has always landed as a preview.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
| M | DEPLOY_CLOUDFLARE.md | | | 60 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | plans/FACTS.md | | | 79 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 139 insertions(+), 0 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,65 @@ 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.
+
+### 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/plans/FACTS.md b/plans/FACTS.md
@@ -5269,3 +5269,82 @@ 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".