@bigin-io/site-tools
v1.0.1
Published
Operator CLIs for a site repo: provisioning, media upload, the Pagefind build step, and the G5 QA gate (ADR-14)
Readme
@bigin-io/site-tools
The operator CLIs for a site repo (ADR-14): provisioning, media upload, and the Pagefind build step.
This package has THREE purposes now. Said out loud rather than discovered, and the count is going up.
- The operator CLIs — the commands a site repo runs:
provision,upload-media,build-search,preflight-deploy,prepare-preview.- The G5 quality gate —
qa-gate, added by story 6.3, along with theaxe-coreandjsdomdependencies it needs.- The ops baseline —
d1-export, added by story 6.4, plus theops-nightly.ymltemplate it runs in. It had a second command,error-sweep; story 6.11 removed it on 2026-09-01 and the runbook says what that costs.Running the gate is an operator command, and so is a nightly export, so both additions sit naturally beside the first purpose. But each is a purpose rather than a command. The gate brought a browser-less DOM and an accessibility engine into a package that previously talked only to Cloudflare, so a site repo installing this to run
build-searchalso installs those. The ops baseline brought workflow templates, which had until now shipped only fromsite-worker— noted inops-nightly.ymlitself so a reader meeting the split is not left guessing.This is named here for the same reason
site-worker's three roles are named in its README (story 6.2). A package that acquires a purpose silently is how nobody can say what it is for, and a package whose purposes are written down can be split later on a decision rather than on an archaeology. Three purposes in two stories is a trend rather than an accident, and the obvious split —@bigin-io/site-qa, or an@bigin-io/site-opsbeside it — is a decision somebody should take deliberately rather than inherit. Nothing here depends on that not happening.
This package exists because every one of these commands used to live in a
private: true workspace package. They worked with the platform monorepo
checked out and nowhere else — so story 3.3's acceptance line, "pipeline and
editors use the same command", could not be true, because editors are not in
that monorepo.
pnpm add -D @bigin-io/site-toolsThe commands
| Command | When | Tier |
| --- | --- | --- |
| provision --phase 1 | at G4, before the build | both — the media half is content-only, the Turnstile widget is not (6.8) |
| upload-media | whenever editorial media changes | content only |
| build-search | every build, after the SSG build and before deploy | content only (a no-op on simple) |
| provision --phase 2 | at G6, before the first deploy | both tiers |
| preflight-deploy | every deploy, immediately before wrangler | both tiers |
| prepare-preview | every preview deploy | both tiers |
| qa-gate --stage static | every pull request, before the preview deploy | both tiers |
| qa-gate --stage live | every pull request, after it | both tiers |
Run any of them with --help. Each takes --config <path> to your
site.config, supports --dry-run and --json, and reports documented exit
codes so CI can act on the result.
provision --phase 2 runs on a simple-tier site too: ADR-11 removes R2,
collections and Pagefind from a simple site but keeps the forms/newsletter
Worker, so it still needs a Worker, a D1 database and secrets.
The two deploy commands (story 6.2)
They exist so the deploy workflows stay thin. YAML is the one artefact in a site repo that nothing type-checks, nothing lints and no test runs, so every decision a deploy encodes that can live in a script does.
preflight-deploy refuses rather than repairs. It checks that provisioning
has run (no __PLACEHOLDER__ left, vars.SITE_CONFIG emitted, and no secret
name inside it), that the build produced HTML, and that a content-tier site
carries a Pagefind index built from this output. Each refusal names the
command that produces the missing thing, because the alternative is a Cloudflare
API error several layers from the step that was skipped. It prints the paths
of the keys at fault and never their values.
prepare-preview derives wrangler.preview.jsonc from the site's
provisioned wrangler.jsonc, and appends the noindex rule to the built
_headers. It changes the Worker name, the D1 database, and removes routes
and triggers. The SITE_CONFIG slice is carried through byte for byte but
for one field, which is what makes "a preview shares production's slice" a
property rather than a promise: it is not a copy that could drift, it is the
same bytes read from the same file at deploy time.
The one field is forms.turnstile.siteKey (story 6.10). A Turnstile key is
bound to hostnames, so byte-identity is incoherent for it: every other
value in the slice describes the site and travels to any host it is served
from, while this one describes a binding between a site and a host — and a
preview is by construction on a host production's widget does not know. Carrying
production's key would guarantee a preview whose challenge cannot solve, which
is not the form path a preview exists to exercise. The preview carries the
preview widget's key instead. Recorded as the human's to reverse, since it
touches NFR-6 and 6.2's AC6.
The alternative, a [env.preview] block in wrangler.jsonc, is ruled out by
wrangler itself — vars and d1_databases are not inherited by named
environments, so the block would have to carry a second copy of the whole slice.
Its inputs come from the environment (PREVIEW_D1_DATABASE_ID and
PREVIEW_TURNSTILE_SITEKEY, and optionally PREVIEW_WORKER_NAME and
PREVIEW_D1_DATABASE_NAME) rather than from interpolated arguments, so a
workflow never puts a repository value on a shell line. Both required ones are
refused when absent rather than defaulted: a missing database id would mean
a preview writing into the real subscriber list, and a missing site key would
mean falling back to production's — a form on every preview that looks present
and cannot be submitted.
Both refuse with exit 3 and change nothing. prepare-preview will not write a
configuration that shares production's Worker name or its database id: the first
would share the secrets, the second would write into the real subscriber list.
pagefind is an optional peer dependency
Install it only if your site has search:
pnpm add -D pagefindADR-11 defines the simple tier as having no Pagefind. Making it a hard
dependency would have every simple-tier site download a search-index binary it
will never run — installing the very thing the tier is defined by not having.
build-search skips the simple tier entirely, so it never looks for it there.
On a content-tier site without it installed, build-search fails and names the
install command. Its absence is a configuration fact, not a crash.
Credentials
provision and upload-media read CLOUDFLARE_API_TOKEN and
CLOUDFLARE_ACCOUNT_ID from the environment, never from flags, and scrub them
from everything they print — including a failed subprocess's output.
build-search needs no credentials at all. It is a local, file-in/file-out
build step, and a search index you could only build while holding a production
API token would be a broken local build.
Command reference
Moved here from the private scripts package when ADR-14 extracted these CLIs.
It was documentation for site operators living in a package no site repo can
install — the same reachability problem, one level down, that produced the
package itself.
provision-media is the exception in the list below: it is phase 1 on its own,
kept for the pipeline, and has no bin entry because provision --phase 1 is
the command an operator should reach for.
provision-media — phase-1 Cloudflare provisioning
Creates a site's R2 media stack: the bucket, the media.<domain> custom
domain, and the Cache Rule that makes originals serve with cache headers. Runs
inside sf-scaffold at G4, before any media upload, because the build and the
G5 image/link checks need media resolving (docs/02-architecture.md §
Pipeline). Phase 2 — Worker, D1, secrets, site DNS — is story 6.1; the Turnstile widget
moved to phase 1 in story 6.8.
node node_modules/@bigin-io/site-tools/dist/bin/provision-media.js --config <path-to-site.config> [options]| Option | Effect |
| --- | --- |
| --config <path> | the site config to read: .ts, .mts, .js, .mjs, or .json |
| --dry-run | print the plan; change nothing |
| --verify | run the read-only checks only; change nothing |
| --probe-key <key> | an object that already exists, used by the cache-header and transform checks |
| --json | machine-readable summary on stdout |
| Exit | Meaning | | --- | --- | | 0 | success, or nothing to do | | 1 | unexpected failure | | 2 | usage error | | 3 | preflight refused: bad config, wrong account, unmanaged zone | | 4 | verification failed |
It is idempotent: it reads current state, plans only the difference, and a second run makes no changes. Run it as often as you like.
A simple-tier site exits 0 having done nothing (ADR-11), so the pipeline can call this unconditionally without branching on tier.
Credentials
From the environment only — never flags, and never site.config (NFR-3):
export CLOUDFLARE_API_TOKEN=...
export CLOUDFLARE_ACCOUNT_ID=...CLOUDFLARE_ACCOUNT_ID must match cloudflare.accountId in the config, or the
run aborts before touching anything. Provisioning into the wrong account is the
expensive mistake.
Token scopes. The starting set, to be narrowed once a real run confirms which are load-bearing (story 3.1, T7):
| Scope | Needed for |
| --- | --- |
| Account → Workers R2 Storage:Edit | creating the bucket, binding the domain |
| Zone → Zone:Read | resolving cloudflare.zone.name to a zone id |
| Zone → DNS:Edit | binding a custom domain writes a DNS record |
| Zone → Cache Settings:Edit | the Cache Rule |
The token is scrubbed from every line this tool prints, including the output of
a failed wrangler subprocess.
One-time prerequisites, per account and zone
These sit outside the pipeline (docs/02-architecture.md § Pipeline) and
this script does not create them:
- R2 enabled on the Cloudflare account.
- The zone on Cloudflare. If the client's DNS is elsewhere,
cloudflare.zone.managed: falseand this script refuses — use the documented fallback (CNAME toworkers.dev, media on a BigIn-owned zone) and pointmedia.zoneat that host. - Image Transformations enabled for the zone: dashboard → Images → Transformations → select the zone → enable. The script verifies this end-to-end rather than trusting a settings flag, but it cannot switch it on.
What "serves with cache headers" actually rests on
Two halves, one constant:
- This script upserts a Cache Rule on the zone, matched on the media host,
with edge and browser TTL from
MEDIA_CACHE_MAX_AGE_SECONDS. Browser TTL inoverride_originmode is what stampsCache-Controlon the response. Cloudflare caches only certain file types on a custom domain by default, so without this rule the promise would hold for JPEGs and quietly break for PDFs. - Story 3.3 stamps the same value onto each object's metadata at upload, reading the same exported constant. Change it in one place.
The rule is upserted non-destructively: the cache-rules entrypoint ruleset is zone-wide and its API replaces the whole list, so the script reads the current rules, replaces only its own (matched on a stable description), and writes the merged list back. A client's other cache rules survive.
When the custom domain looks stuck
A newly bound R2 custom domain is not instant — DNS and certificate issuance
take a few minutes. --verify will report the origin check failing until it is
ready, which is expected, not a bug. Re-run --verify rather than re-running
provisioning; the binding is already made and a second provision run is a
no-op anyway.
If it is still failing after ~15 minutes, check in the dashboard that the domain shows as active and that its certificate has issued.
Undoing a run
wrangler r2 bucket domain remove <bucket> --domain <media host>
# remove the "ssg-site-factory: cache media originals" rule from the zone's
# cache-rules ruleset (dashboard → Caching → Cache Rules)
wrangler r2 bucket delete <bucket> # only if the bucket is genuinely scratchDeleting a bucket that holds a live site's media is not recoverable. Remove the domain binding first and confirm the site is not serving from it.
upload-media — editorial media into R2
Uploads originals to the site's R2 bucket, names them by content, and records
them in an alt-text manifest. One command for both callers — an editor
adding a photo and sf-scaffold seeding a site run exactly the same thing.
# an editor, one photo, described
pnpm exec upload-media --config ./site.config.ts \
--prefix blog --alt "A warehouse robot at dusk" ./photos/hero.jpg
# the pipeline, a whole tree, refusing to proceed on a missing description
pnpm exec upload-media --config ./site.config.ts \
--prefix blog --require-alt ./photos| Option | Effect |
| --- | --- |
| --prefix <path> | key prefix, e.g. blog |
| --alt <text> | alt text to record; single file only |
| --require-alt | fail if any entry has no alt text |
| --manifest <path> | manifest location (default media/manifest.json) |
| --dry-run | show what would happen; write nothing, manifest included |
| --json | machine-readable summary |
What the key convention guarantees
Keys are <prefix>/<slug>-<8 hex of sha256>.<ext> — derived from the file's
name and its bytes, never from the directory it happened to sit in. So:
- The same image gets the same key whether an editor or CI uploads it, on Windows or Linux. That is what makes "one command for both callers" true in practice rather than only in the flags.
- Editing an image produces a new key. This is the point: 3.1 serves media
with
immutableand a one-year TTL, which is only safe if a URL's bytes never change. An overwrittenhero.jpgwould be served stale from the edge for a year with no purge path from here. - Re-running is free. An object already being served is skipped.
The manifest
Default media/manifest.json in the site repo, committed like any other gate
artifact. It records key, source name, sha256, size, content type, alt text and
upload time, sorted by key so a re-run makes no spurious diff.
It does not satisfy the contract's alt-text rule. Every content reference
still needs its own alt in frontmatter — createImageRefSchema enforces that
at parse time (NFR-2). The manifest is where sf-content finds the words to put
there, and where a QA pass can see which uploads still have none. The file says
so about itself, in case it is ever read out of context.
Re-running never overwrites human-written alt text with a blank.
What it refuses, and why
- Anything under
public/. That imagery is owned by code and ships through the build-time image pipeline (ADR-12). Uploading it would create a second, diverging copy of the same asset — this is how ADR-4's ownership boundary erodes, one convenient upload at a time. - A simple-tier site. It has no R2 media stack at all (ADR-11); the image
belongs in
public/. Note this differs fromprovision-media, which merely skips: that one is called unconditionally by the pipeline, while nobody runsupload-mediaby accident. - SVG. An SVG can carry script and would be served from
media.<domain>— inside the client's own domain. Vector art is repo-owned anyway, sopublic/is both the safer and the already-correct home. - Fonts, HTML, and unknown types. Template assets belong to the build pipeline; an unrecognised type is refused by name rather than uploaded under a guessed content type, which would otherwise be served that way forever behind an immutable cache.
A directory walk skips unsupported files and reports the count, rather than
failing the whole run over a .DS_Store. A file named explicitly is refused,
because naming it was a decision.
Removing something uploaded by mistake
wrangler r2 object delete <bucket>/<key>Then delete its entry from the manifest. Remember that content may already
reference the URL — grep content/ before removing anything.
build-search — the Pagefind index
Builds the search index for a site that has already been rendered. It sits in the middle of the chain the architecture specifies, and the order is not optional — it indexes rendered HTML, so the SSG build has to have finished:
nuxt generate | next build -> build-search -> wrangler deploypnpm exec build-search --config ./site.config.ts| Option | Effect |
| --- | --- |
| --dist <path> | built site directory (default .output/public for Nuxt, out for Next) |
| --dry-run | report what would change; write nothing |
| --json | machine-readable summary |
No Cloudflare credentials are read or needed. This is a local build step, and a search index you could only build while holding a production API token would be a broken local build.
A simple-tier site exits 0 having done nothing — it has no Pagefind at all (ADR-11) — so the build chain can call this unconditionally.
What excludeRoutes guarantees
search.excludeRoutes holds logical routes. One entry excludes that route
under every configured locale. /privacy excludes /en/privacy and
/vi/privacy, not merely a bare /privacy.
This matters more than it looks. FR-S8 makes routing locale-prefixed from launch, so on a real site the literal reading matches nothing — and every page the operator asked to hide stays in the index. The failure modes are asymmetric: over-excluding hides a page from search, under-excluding publishes a page someone chose to conceal.
Cross-lane note. Any other code that resolves
excludeRoutes— a renderer's own search work in particular — must expand it the same way, or the two renderers will disagree about which pages are public and NFR-7 parity stops being cosmetic.
Two further guarantees:
- A route that matches no built page fails the build, naming it. A typo'd exclusion that quietly matches nothing is the same leak arriving by another road, and nobody notices until the page turns up in a search engine.
- The built index is read back and checked. The step does not assume its own flags worked; it opens Pagefind's fragments and asserts the excluded URLs are absent. If the index cannot be read, that is reported as a failure — "could not check" must never look like "checked and it is absent".
Every page must declare its language
build-search refuses a multi-locale build in which any indexed page has no
<html lang>, and warns on a single-locale one.
Pagefind splits its index by the language a page declares. Without the
attribute every page lands in its unknown index, so on a multilingual site a
Vietnamese reader's search returns English results — silently, with no error
anywhere. It is also WCAG 2.1 3.1.1, a Level A criterion.
The single-locale case only warns because a site whose pages all land in one
unknown index still searches correctly; failing that build would punish it for
a defect it does not have.
The check lives here rather than in a renderer because this step reads built HTML: a fix in one renderer protects that renderer, a check here protects the property for every renderer, including ones that do not exist yet.
It writes to build output
Pagefind has no route-level exclusion: --glob takes one pattern with no
negation, and --exclude-selectors excludes elements rather than pages. So
before indexing, this step adds data-pagefind-ignore="all" to the <body> of
each excluded page. The attribute is inert when served and makes the exclusion
visible in the shipped HTML.
It only ever adds that attribute — no other byte of the document changes — and it is idempotent, so a second run leaves the file byte-identical.
Confirming a page really is absent
The step already does this and fails if it is not, but to check by hand:
node -e "const fs=require('fs'),z=require('zlib'),p=require('path');
const d=process.argv[1]+'/pagefind/fragment';
for(const f of fs.readdirSync(d)){
const t=z.gunzipSync(fs.readFileSync(p.join(d,f))).toString();
console.log(JSON.parse(t.slice(t.indexOf('{'))).url);
}" ./outEvery indexed URL is printed. A page you excluded must not appear.
provision — the two phases
Provisions a site's Cloudflare resources. Two phases, because the two halves are needed at different gates:
# G4, before the build: Turnstile widget (both tiers), then
# R2 bucket, media.<domain>, cache rule (content tier)
pnpm exec provision --config ./site.config.ts --phase 1
# G6, before deploy: D1 database + migrations, Worker secrets
pnpm exec provision --config ./site.config.ts --phase 2Either phase is idempotent. Re-run them freely: a second run reports every
resource as already-present and leaves both wrangler.jsonc and the record
byte-identical.
The tier asymmetry — the easiest thing here to get wrong
| | simple tier | content tier | | --- | --- | --- | | Phase 1 — Turnstile widget | runs | runs | | Phase 1 — R2, media., cache rule | skipped, with the reason | runs | | Phase 2 | runs | runs |
ADR-11 takes R2, collections and Pagefind away from a simple site — but it keeps the forms/newsletter Worker. So a simple site has no bucket and no search index, and still needs a widget, a Worker, a D1 database and secrets.
Phase 1 is no longer skipped outright on a simple site — story 6.8 moved the Turnstile widget into it, and forms are on every tier. Only the media half is tier-gated now, and a run reports the skipped half with its reason rather than falling silent: a green run that says "nothing to do" while it provisioned a widget is a lie, and this table used to invite one.
Environment
| Variable | Needed by |
| --- | --- |
| CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID | both phases |
| EMAIL_API_KEY | phase 2 |
| ESP_API_KEY | phase 2, only when the newsletter relays to an ESP (ADR-6) |
TURNSTILE_SECRET_KEY is not in this table since story 6.8: phase 2 reads it
back from the provisioned widget instead of asking you to carry it.
Every missing variable is reported at once, before anything is provisioned — finding the second one after the first has already created resources is how a site ends up half-built.
Secret values are read from the environment and handed to wrangler on stdin.
They never reach wrangler.jsonc, the provisioning record, a log, or any file
in the site repo — and a secret put that fails has its value scrubbed from the
error, since a failure is exactly when a value tends to get echoed back.
What lands in wrangler.jsonc
Phase 2 writes the provisioned state: the Worker name, the ASSETS binding, the
DB binding with the D1 database_id, the MEDIA bucket on the content
tier, the custom-domain route, and triggers.crons.
triggers.crons comes from dataProtection.purgeSchedule in site.config.
Never edit it by hand. The schedule has two homes and a Worker cannot read its
own trigger, so nothing at runtime can tell you they have drifted apart; change
site.config and re-run phase 2.
Edits are surgical. Any key this tool does not own — compatibility_date,
vars, observability, routes you added, and comments — survives untouched.
It will not overwrite an assets.directory you chose.
It fills in a config; it does not create one
The site's wrangler.jsonc comes from the template in @bigin-io/site-worker:
cp node_modules/@bigin-io/site-worker/wrangler.template.jsonc ./wrangler.jsoncPhase 2 refuses to run if it is missing, rather than writing one from
scratch. A generated-from-nothing config would have no main and no
compatibility_date — undeployable, but written confidently and reported as a
success, which is the worst combination available.
Reading the record
.provisioning/record.json, committed on purpose: it is the answer to "what
exists for this site?" without opening the Cloudflare dashboard. Per resource it
records the kind, name, resolved id, whether the last run created it or found
it already-present, which phase wrote it, and when.
It holds no secrets — only the names of the secrets that are set. A run that changes nothing leaves it byte-identical, so a diff always means something actually happened.
Turnstile — provisioned in phase 1, on both tiers (story 6.8)
Phase 1 creates the site's Turnstile widget, or verifies the one that is already there. On both tiers: ADR-11 takes R2, collections and Pagefind away from a simple site but keeps the forms Worker, so every site needs a widget.
That is why the tier table above says partly skipped rather than skipped. A
simple-tier provision --phase 1 does real work now, and the run says which
half it skipped and why:
created Turnstile widget: northwind.example
skipped media: tier is "simple": no R2 media stack to provision (ADR-11)
record: .provisioning/record.jsonThrough wrangler turnstile widget, not the REST API. A REST sanction was
granted for this and then handed back unused: wrangler 4.127.1 has the
command, so CLAUDE.md's "Wrangler for anything Cloudflare" applies with no
exception. It is [alpha], which is why wrangler is pinned to an exact version
here and why test/turnstile-surface.test.ts fails loudly if a bump moves the
subcommands, flags or output this package parses.
The first run of a new site fails on purpose
The schema requires forms.turnstile.siteKey from intake, and the widget does
not exist until phase 1 runs. So write the documented placeholder at intake:
forms: { turnstile: { siteKey: '0x4AAAAAAA__PROVISIONING_FILLS_THIS__' } }Phase 1 then creates the widget and stops, printing the real key and telling
you to paste it in. Re-run and it verifies. That is one deliberate non-zero exit
on a site's first provisioning, and it buys something: provisioning never
edits site.config. The config is an input to the build (NFR-6), not an
output of it, and a tool that rewrote it would make every build depend on
whether provisioning had run.
Two different messages, because they are two different situations:
| What phase 1 found | What it means | | --- | --- | | the placeholder | expected on a first run. Paste the key it printed, re-run | | a real but different key | stop and check. The config may point at another site's widget, or this account may have two widgets for this domain. The build bakes this key into every page |
The placeholder cannot be shipped. If it somehow survives to a build, the QA gate's bundle guard fails on it (story 6.3) — because an operator who never re-runs would otherwise deploy a site whose every form silently rejects every visitor.
You no longer supply TURNSTILE_SECRET_KEY
Phase 2 reads it back from Cloudflare with wrangler turnstile widget get at
the moment it hands it to wrangler secret put. It is never displayed, never
written to a file, and never reaches the provisioning record. One fewer
credential for you to carry between two gates.
EMAIL_API_KEY and (for an ESP newsletter) ESP_API_KEY still come from the
environment as before.
The API token needs the Turnstile permission
The same CLOUDFLARE_API_TOKEN provisions R2, D1 and now Turnstile — but
Turnstile is its own permission group, and a token that predates this has no
access to it. The permission is:
challenge-widgets.write — "See and change Turnstile widgets"Add it to the token, or use one that has it.
What the absence actually looks like, corrected against the live API on
2026-09-01. Story 6.8 predicted a 403 that reads like a broken endpoint, and
put the scope name here so nobody would go hunting one. It is better than
that, and the record is worth correcting rather than leaving flattering:
wrangler refuses in a pre-flight warning that names the missing scope and
tells you to re-run wrangler login — it never reaches the API, so there is no
403 to misread. The entry above is still the right entry; the symptom it guards
against is legible, not cryptic. That matters here specifically: a runbook
describing the wrong symptom sends someone hunting the wrong cause, and the
right cause is one wrangler login away.
A site has TWO widgets, and the second one is why production is safe
Phase 1 provisions two Turnstile widgets on both tiers:
| Widget | Domain list | What uses it |
| --- | --- | --- |
| production | the site's own zone | the live site |
| preview | workers.dev | preview deploys, and nothing else |
The one-widget option was considered and refused on 2026-09-01. Closing
6.3's untested live-key path needed a widget that works on a preview's
*.workers.dev hostname, and the cheap answer was to add workers.dev to the
client's production widget. Turnstile's domain list covers subdomains, so
that admits every *.workers.dev host on the platform — any other tenant's
Worker could mint tokens that validate against that client's sitekey. Cross-
tenant exposure on a live client's bot protection, in exchange for a test.
The same entry on a preview-only widget costs nothing, because that widget guards nothing: a throwaway Worker, its own database, no mail credentials, no production route. The difference is entirely what the widget stands in front of, which is worth remembering the next time one domain list looks like it could hold both hostnames.
Provisioning has no way to modify an existing widget — list, get and
create, and nothing that writes a domain list — so production's list cannot be
touched by this package even by mistake. There is a test asserting it, because
the whole basis of the choice above is that production stays as it is.
Record the preview widget's site key as the PREVIEW_TURNSTILE_SITEKEY
repository variable. It is public — served in the HTML of every preview page —
so a variable, not a secret, exactly like PREVIEW_D1_DATABASE_ID.
prepare-preview refuses to write a preview config without it rather than
falling back to production's key, which would render a challenge that cannot
solve.
If workers.dev is already on a production widget — earlier versions of the
preview-deploy template told operators to put it there — remove it from that
widget's domain list in the dashboard and re-run phase 1. Removing it cannot
break production: the site is served from its own hostname, which stays.
The widget's mode, and who should be choosing it
The widget is created with --mode managed, which lets Cloudflare pick the
lightest challenge that works. That is a default chosen in code, and it is a
product and accessibility decision rather than a technical one: an
interactive challenge is a barrier some users cannot pass, and a client may need
a different mode. site.config has no field for it, deliberately — adding
one is a contract change. Flagged for the human in story 6.8.
The QA gate — qa-gate (story 6.3, FR-P6)
The G5 gate. It runs on every pull request and a red result blocks the
merge, which is what "before deploy is offered" means in practice: production
deploys from main, so the place to stop bad output is in front of the merge,
not in front of wrangler deploy where a human can be tempted past it.
# before the preview deploy — needs only dist, no URL, no credentials
pnpm exec qa-gate --config site.config.ts --stage static --out qa-report.txt
# after it
pnpm exec qa-gate --config site.config.ts --stage live \
--url "$PREVIEW_URL" --database my-site-preview --comment qa-comment.mdExit 0 passed, 1 failed, 2 usage. Nothing else.
Why it is split in two
| Half | Needs | Runs |
| --- | --- | --- |
| static — bundle guard, link check, axe, tier surfaces | dist | before the preview deploy |
| live — Lighthouse, the Turnstile round-trip | a URL | after it |
A build that fails the static half never gets a preview deployed. For the bundle guard that matters literally rather than economically: deploying the artefact the guard exists to stop is the one deploy that must not happen.
What it checks
| Check | What fails it |
| --- | --- |
| links | an internal href that resolves to nothing the build emitted; on a multi-locale site, one resolving to an unprefixed route |
| a11y | a WCAG 2.1 AA violation (NFR-2) from axe, over documentElement, on every emitted page in every locale |
| bundle | a SQLite WASM asset in dist; a root-relative /cdn-cgi/image/ URL; a Node-only module (sharp, satori) in the emitted bundle; the Turnstile placeholder site key, which would make every form on the site reject every visitor |
| forbidden-hosts | a font or icon CDN reached from the built output — FORBIDDEN_HOSTS, imported from @bigin-io/renderer-core/policy, never copied |
| search-index | a content-tier build with no pagefind/pagefind.js |
| collection-routes | a declared collection that emitted no entry route |
| public-images | nothing — this one WARNS and never blocks (story 6.14). It reports a content-tier site whose source public/ holds more than PUBLIC_IMAGE_BUDGET_BYTES (512 KB) of images. Content tier only — see below |
| lighthouse | LCP ≥ 2.0 s, CLS ≥ 0.1, or JS ≥ 100 KB gzipped — NFR-1's numbers, as three separate assertions over a median of three runs |
| turnstile | a submission accepted without a bot-check token; a response that breaks the /api/* contract; a 200 with no row in the preview database |
Two things are warnings and never fail the gate.
- An unreachable external link. A third party's 503 is not this site's defect, and a gate that blocks on one is a gate people learn to override.
- An over-budget
public/— since story 6.14; see the next section for the reversal and its reasons.
A warning is not a quiet pass. A check that found something and did not block
reports WARNED, not PASSED, its lines print WARN rather than warn,
the closing line of the run names it — Gate passed, WITH 1 WARNING(S) THAT DID
NOT BLOCK IT: public-images — and on the pull-request comment it gets its own
⚠️ section above the collapsed details, below any failures. That is
deliberate and it is story 6.14's AC2: a warning nobody reads is the failure
mode that ruling accepts responsibility for.
public-images, and what story 6.12 changed about it
Until story 6.12 this was the second warning, and it was broken in a way worth
recording. Its constant was named PUBLIC_IMAGE_BUDGET_BYTES, its doc comment
argued about ADR-4, and its report sentence read public/ images total N bytes
— while the number came from scanning dist. It reported a measured fact
that was never measured. dist is not even a proxy for public/: ADR-12 emits
AVIF and WebP variants for every source raster and the originals never ship, so
on the parity fixtures the built output is about 4.5x the files and 9x the
bytes of the source directory.
Three things changed, on the human's ruling of 2026-09-03 — make it match its name:
- It measures source
public/, resolved from the--configpath's directory. Notprocess.cwd(), and notdist:--distcan point anywhere, so the two need not be related, and a path convention that only holds when the gate runs from the site root is a check that silently measures nothing. - It is its own check, on its own row, so a reader scanning the report sees it at all. (6.12 also made it fail. Story 6.14 reversed that half — immediately below.)
- It is content tier only, and that falls out of ADR-11 rather than taste. A
simple-tier site has no R2 — all of its imagery lives in
public/and ships through the ADR-12 pipeline — so there is nowhere else for editorial media to be and no ADR-4 boundary there to cross. It isSKIPPEDwith that reason, never a lax pass. The consequence is that a simple-tier site now has no image check at all. That is deliberate.
Story 6.14 — it warns, and it does not block
An over-budget public/ reports a finding and the gate exits 0. 6.12 made it
red; 6.14 reversed that and left everything else 6.12 changed in place. Three
reasons, so this can be argued with rather than merely appealed to:
- Story 6.3 made it a warning deliberately — a warning with a number attached, never a failure — on the argument that a warning threshold genuinely was the site's to set. 6.12 changed what is measured and where the number lives, both correctly, and never argued severity: its AC6 asked only that the over-threshold fixture go red, which was written meaning reports a finding and read — reasonably — as the gate is red. The ambiguity was the coordinator's, and 6.12's change log now records it.
- The constant says of itself that it is not measured from a real client site. A hard gate on an admittedly-underived number is how gates get turned off — the mistake story 6.13 had just finished undoing from the other side, where a permanent false positive had trained readers to skip a row.
- 6.12 moved two things at once: 2 MB to 512 KB, four times tighter, and warn to fail. Fired on the first real client site, nobody could have said which half was wrong.
The threshold did not move back. 512 KB stays with its derivation; the
tightening is the part 6.12 got right. No config field either — story 6.9
removed site.config.budgets by human decision and this does not reopen it, in
either direction.
What would justify blocking is written down as a condition, not a preference
(6.14's AC6, beside the constant in src/qa/public-images.ts): three real
content-tier client public/ directories measured, all three under the
threshold with ADR-4's set complete, and at least one recorded true positive —
after which the threshold and the severity move in separate changes. Points one
to three are measurements, not judgements.
The threshold is 512 KB, and the derivation lives beside the constant in
src/qa/public-images.ts: favicons, a logo, and an OG fallback card, because
everything editorial is R2's by ADR-4. The two values before it — a Zod default
of 1500 KB and a round binary 2 MB — traced to nothing, which is how a 37 %
loosening went unnoticed for a week. It is derived from what ADR-4 says belongs
in public/, not measured from a real client site; none exists in this
repository.
Its three green outcomes are not interchangeable. A missing public/, a
public/ with no images in it, and a public/ measured and under budget each
print a different sentence, because a zero meaning no directory and a zero
meaning no images are different facts — the same reason a skip carries a
reason rather than reading as a pass.
What was lost, stated rather than absorbed
Dropping the dist scan removed the only measurement of total shipped image
weight this repository had, and nothing replaces it. NFR-1 does not: LCP, CLS
and JS weight do not add up to image payload, and _headers only pins the
variant directory as immutable. If shipped image weight is wanted, it is a
separate check with its own name and its own number — it is deliberately not
smuggled back in under a name that says public/. This is the discipline story
6.9 applied to publicImagesKb's loosening, applied to its own removal.
What it CANNOT see
This table is the point of the gate, not an appendix to it. Both document-level accessibility failures this platform has found came from correcting the scope of a check that was already running and passing — never from adding a check. So every check is asked what it cannot see, the answers live here, and the gate prints them beside its own results on every run, passing or failing.
| Check | Cannot see |
| --- | --- |
| link check | a link that resolves but is wrong — the right shape to the wrong page |
| axe (jsdom) | anything needing layout, color-contrast above all; and anything that only exists after client JS runs — on a static export that is small but not empty: the consent banner, the search UI, the form widget |
| axe (any) | judgement — contrast inside an image, a label that is present but meaningless, a focus order that is valid but absurd |
| Lighthouse | a page nobody routed to; it measures the URLs it is given, and it is given / and (content tier) /blog; and a site that wants to be faster than the floor — since story 6.9 these are the platform's numbers and the only ones |
| bundle guard | a forbidden host reached at runtime from a string the build never resolved |
| Turnstile round-trip | siteverify against a real secret, and the production widget's own hostname binding. See below — the boundary moved in story 6.10 and it is narrower than it was |
| search-index | whether the index's content is right — only that it was emitted |
| collection-routes | a collection that emitted some entries and silently dropped others |
| public-images | whether an over-budget public/ is actually editorial media — it weighs bytes, it cannot read intent, and that is why it warns rather than blocks (story 6.14); what an image is — it weighs bytes by extension, so a 400 KB photograph committed as a logo is under budget and indistinguishable from one; anything outside public/, such as an editorial image imported through the bundler from src/; and total shipped image weight, which nothing measures any more (story 6.12, above) |
| all of them | anything only present on production: they run against a preview on *.workers.dev |
The Turnstile row needs its own paragraph, because half of it changed.
Since story 6.10 a preview has its own real widget, so the preview's HTML
carries a real site key that a real browser really solves on workers.dev — the
provisioning path and the site-key/hostname binding are exercised for real. What
is still not: siteverify against a real secret. A Turnstile token can only
be minted by a browser solving the challenge, and this gate has no browser,
so it posts a fixed dummy token that only the always-passes test secret
accepts. Pointing the preview Worker at the preview widget's real secret would
turn the accepted case into a 403 and take the stored-row check with it —
trading a proven property (a submission is not lost) for a rehearsal. And the
negative direction buys nothing back: the Worker collapses siteverify's
invalid-input-secret and invalid-input-response into one turnstile_failed,
so a bogus token fails identically against a real secret and a garbage one.
The production widget's own binding is a different value again, and the first
real submission on the client's domain is still the first test of it.
Contrast is deliberately absent, and that is not a hole. A headless DOM does
no layout, so axe cannot measure color-contrast — it is disabled, and the
disablement is reported. What covers it is site-contract, which computes WCAG
contrast at parse time: an AA failure is a config parse error before a page
is ever built. The gate says this rather than appearing to cover it, because a
gate that looks like it checks contrast is how a real contrast regression
ships.
How to read a red run
The report distinguishes five statuses, and only one of them is red:
| Status | Meaning |
| --- | --- |
| PASSED | it ran and found nothing |
| FAILED | it ran and found something — this is what blocks the merge |
| SKIPPED | the site's tier gives it no surface (ADR-11), with the reason printed |
| BLOCKED | it cannot be written yet, with the dependency named |
| DEFERRED | it belongs to the other half of the run |
Read it in this order:
- Look at the status column first, not the failure list. A
SKIPPEDorBLOCKEDrow is a check that did not run. It is not a pass, and the report never prints one as though it were. - Each
FAILline names the thing that caused it — the page and the href, the file and the rule, the URL and the measured number. If a line does not tell you where to look, that is a defect in the gate; report it. warnlines never fail the run. An external link, an axeincomplete("axe could not determine" is not "the page is wrong"), an image budget, and every passing Lighthouse assertion appear here — the last so a budget met by 5 ms is visible before it becomes someone else's failure.blindlines are what that check could not have caught. A green run with its blind spots printed under it is an honest green.
Two failures read differently from the rest:
lighthouseFAILED with "not measured" means Lighthouse did not run — a missing binary, a preview that never loaded. That is a failure and not a skip on purpose: "we could not check" and "it is fine" are different facts, and only one of them should let a merge through.
What cannot be turned off
No check has a switch. A check is skipped only because the site's tier gives
it no surface to run against, that reason is printed, and a site.config that
tries to declare qa.skipSomething is refused with a red gate rather than
ignored with a green one. A gate a site can turn off is a gate that will be
turned off on the site that needs it.
forbidden-hosts reads one list, and does not own it
The forbidden-host check imports FORBIDDEN_HOSTS from
@bigin-io/renderer-core/policy. It never holds a copy, and it waited for
that subpath to exist rather than shipping with a stopgap list — two lists
drift, and the one that drifts is the one nobody is looking at, so a font CDN
added to one and not the other would ship to production past a green gate.
Adding a host in that one place arms three checks at once: renderer-core's
guard over its own source, the Nuxt bundle-build guard over the bytes a real
nuxt generate wrote, and this gate over a site's dist.
The rule is zero external requests. A third-party origin is a DNS lookup, a TLS handshake and a cold connection on the critical path (NFR-1), and remote font loading has GDPR case law against it. Self-host through the build instead.
What it cannot see is in the table above and printed on every run: a hostname the build never resolved into one string — assembled at runtime, or read from configuration — is invisible to a scan of the bytes.
The cost, stated
Three Lighthouse runs per URL, serially. On the two URLs the gate measures that is roughly 1.5–2.5 minutes of wall-clock per pull request. The median of three rather than a single run is what stops the gate being flaky, and a flaky gate is one people re-run until it is green — which is a gate that blocks nothing. Every individual run's value is printed beside the median, so a median of 1.9 s over runs of 1.2, 1.9 and 3.4 s is visible as the warning it is.
The ops runbook (story 6.4, amended by 6.11)
The floor under a deployed site: what does not watch it, what backs it up, how to undo a deploy, and how to erase one person's data.
What this baseline does and does not buy, before anything else.
IT DETECTS NOTHING. There is no error detection at all — see Alerting, monitoring, error detection below, which is the first section for that reason. A Worker that throws is noticed by a human or not at all.
And even when there was a sweep, it saw a Worker that BREAKS and was silent about a Worker that LIES. An exception is visible; a wrong answer is not. A 200 carrying nonsense, a submission written to the wrong column, a bot check that always passes — none of them throw, so none of them ever appeared anywhere below. Every one of this project's recorded defects lied rather than broke. No amount of ops tooling fixes that, and this runbook does not pretend otherwise.
What it does buy: a verified nightly backup, a retention purge, a documented rollback, and a named GDPR erasure command.
One-time setup, per account and per site
| # | Step | Scope |
| --- | --- | --- |
| 1 | wrangler r2 bucket create <site>-ops | per site |
| 2 | pnpm exec provision --phase 2 | per site |
| 3 | Copy ops-nightly.yml into the site repo and set its repository variables | per site |
No Workers Paid plan is required. It was, until 2026-09-01, because Workers Logpush needs it — that requirement went with the error sweep (below) and there is nothing here that needs a paid base plan.
The ops bucket stays. It is an operator prerequisite on both tiers, like the preview D1 database: ADR-11 keeps media off a simple site, not data, and a simple site has forms, therefore submissions, therefore something worth backing up. Provisioning verifies it and refuses, naming it, when it is absent; it never creates it. The nightly D1 export writes there and that export was verified live on 2026-09-01, in both directions.
Alerting, monitoring, error detection — READ THIS, THERE IS NONE
Nothing detects that a Worker threw an exception. Not this package, not the nightly workflow, not Cloudflare, not anywhere else in this platform. If a Worker starts throwing on every contact-form submission at 02:00, no email is sent, no job goes red, and no dashboard changes colour. The site keeps serving its static pages, so nothing looks wrong from outside either.
Detection is a human noticing, or a reader telling you. That is the whole mechanism. This section exists because an operator who found no alerting section would reasonably assume they had not found it yet, and go on waiting for an alert that is never coming.
Not degraded — absent. If you need to know whether a Worker is throwing
right now, the only tool is wrangler tail <worker>, which is live-only: it
shows what happens while you watch and retains nothing. There is no history to
go back through.
Why, so nobody re-argues it from scratch
There is no Cloudflare notification type for Worker script errors. Not a
missing option — verified three ways on 2026-09-01: the Available Notifications
catalogue has no Workers entry at all, the alerting API's alert_type enum has
no Workers value (the nearest is pages_event_alert, a different product), and
Workers Observability has no alerting of its own. Its documented outputs are
OpenTelemetry export, Logpush, and Tail Workers.
Story 6.4 therefore shipped a scheduled error sweep instead: Workers Logpush
delivering Trace Events into this ops bucket, read on a cadence by a job that
failed loudly on exceptions so GitHub would email the repo owner. It was built,
tested, published in 0.4.0 — and it could not be provisioned. Logpush has
two halves:
| Half | Reachable? |
| --- | --- |
| Per-Worker flag — "logpush": true in wrangler.jsonc | Yes, deploys clean |
| Account-level job — dataset, filter, R2 destination, cadence | No. wrangler logpush does not exist; tail is the only log command |
Without the second, the first moves no data. The flag is a permission slip,
not a pipeline — a setting that deploys green and delivers nothing. Three
routes were put to the human on 2026-09-01: sanction one narrow REST call; poll
Workers Observability instead; or make the Logpush job an operator prerequisite,
verified rather than created, the way the ops bucket already is. The human
chose to drop the mechanism, and story 6.11 removed it: the error-sweep
bin, the workflow step, and logpush: true from provisioning.
It is reversible. The mechanism was correct; only its provisioning was blocked. A later story can restore it by making the account-level Logpush job a verified operator prerequisite. Nothing here forecloses that, and none of the alternatives above has been ruled out — they were simply not chosen now.
One residue to clear by hand. If a site was provisioned before this version,
its wrangler.jsonc still carries logpush: true. Provisioning no longer writes
that key and does not remove it either — delete the line yourself. It is inert,
but an inert flag reads as a pipeline.
And this was always true, sweep or no sweep
It detected a Worker that BREAKS. Nothing ever detected a Worker that LIES.
A 200 carrying nonsense, a submission written to the wrong column, a
siteverify that always passes — none of them throw, so none of them would have
appeared in a Trace Event either. Every one of this project's recorded defects
lied rather than broke. Losing the sweep did not open that gap; it was never
covered.
The nightly D1 export
pnpm exec d1-export --database <d1-name> --bucket <site>-opsExports the remote database, uploads it, and reads it back out of the bucket
before calling it done. That read-back is the point: wrangler r2 object put
exiting 0 says a process finished, not that an object arrived, and certainly not
that the object is a database. This project has paid for that distinction three
times.
Keys are d1-exports/<database>/<timestamp>.sql and sort chronologically, so a
listing is a timeline and finding "the one from before the thing that broke" is
a scroll rather than a search.
What verification does not prove: that the export RESTORES. It proves bytes arrived and are shaped like a dump of this database. The restore below has never been run against a real export, and the first time will be a first.
Restoring
wrangler r2 object get <site>-ops/d1-exports/<database>/<timestamp>.sql --file restore.sql
wrangler d1 execute <database> --remote --file restore.sqlThe cost, stated rather than discovered: d1 execute applies the dump on
top of what is already there. A dump containing CREATE TABLE fails against
existing tables, so a real restore means restoring into a fresh database and
repointing the binding, or dropping tables first — which is a destructive act on
the only copy. Decide which before you need it, not during. wrangler d1
time-travel is the gentler instrument for a recent mistake and does not involve
this bucket at all.
Rolling back a deploy
wrangler versions list # find the version to go back to
wrangler rollback <version-id> # roll production back to it
wrangler deployments list # confirm which version is live nowA ROLLBACK DOES NOT ROLL BACK D1. Code goes back; the schema and the data do not. A migration applied by the deploy you are undoing is still applied, and a Worker rolled back to before that migration may be talking to a schema it does not expect. This is the most dangerous gap in the whole baseline. If the deploy you are undoing added a migration, rolling back the code is not enough and may be worse than doing nothing.
Secrets and bindings do not roll back either. They are per-Worker, not per-version. A secret rotated after the version you are returning to is still the rotated one.
A PREVIEW WORKER CANNOT BE ROLLED BACK THIS WAY. Story 6.2 uses
wrangler versions upload for previews deliberately, so two pull requests
neither cancel nor clobber each other and preview traffic never shifts — which
means a preview Worker has exactly one deployment, its bootstrap, and
wrangler rollback has nothing to act on. That is a property worth keeping, not
a bug to fix. Obvious once, expensive to rediscover at the wrong moment.
wrangler rollback AUTO-CONFIRMS IN A NON-INTERACTIVE CONTEXT, AND SHIFTS
100 % OF TRAFFIC. Observed live on 2026-09-01, not inferred: it printed
Using fallback value in non-interactive context: yes and rolled production
back. A rollback run from a script, from CI, or from anything without a TTY
gets no confirmation gate whatsoever — the prompt you would rely on to catch a
wrong version id is not there. Know that before you automate one, not after.
Two wrangler confirmation traps, in opposite directions
Both found by doing the work on 2026-09-01, both true regardless of anything else in this runbook, and either one will bite whoever writes the first automation script.
| Command | Non-interactive default | What that costs you |
| --- | --- | --- |
| wrangler rollback | yes | production moves with no gate (above) |
| wrangler turnstile widget delete | no, and it exits 0 | a teardown script reports success having deleted nothing |
The second, verified deliberately against a second throwaway widget:
wrangler turnstile widget delete <sitekey> # no -y
-> "Using fallback value in non-interactive context: no"
-> "Deletion cancelled."
-> EXIT 0
-> the widget is still thereThat is this project's own rule found inside Wrangler, in a destructive
command: an exit code is evidence about a process, not about the world. Pass
-y / --skip-confirmation for any scripted teardown, and never treat a zero
exit as evidence that a resource is gone — list it back.
Erasing one person's data (GDPR, NFR-8)
pnpm exec erase-subscriber --database <d1-name> --email <address> --remoteIt has a name now rather than a node_modules path, which is the point: the
moment someone needs it is not the moment to be working out where it lives. On a
site with an ESP, it requires --esp <provider> --esp-handled, and without that
it reports INCOMPLETE and exits non-zero naming the system that still holds
data. That flag attests; it does not verify, and the tool says so.
NFR-5 note, for the human — and how it was settled
The finding, which outlives the mechanism: with today's Cloudflare surface, NFR-5 and push alerting on Worker exceptions are not both satisfiable. There is no native Workers alert to buy, and every push mechanism that exists routes through a third party. That is a property of the platform, discovered by trying, not a nuisance met while implementing.
The open decision about the Workers Paid plan is closed as moot. It was open because Workers Logpush requires that plan, and the handoff carried it as a real recurring per-site cost for whoever is paying. With the sweep withdrawn, nothing in this baseline needs it. Free-tier sites lose nothing they would have had.
What was actually given up: the only path that existed from a Worker throwing to a person knowing, without a third-party observability SaaS. See the alerting section above, which says plainly that there is now no such path at all.
What is not here
The monorepo's own release tooling. verify-publishable checks that this
repository's packages carry their build output before publishing; it means
nothing in a site repo, so it is deliberately not shipped.
