A beginner’s walkthrough for giving a project a real dev → test → prod
promotion ladder: a per-project ceiling that caps how far automation may push,
a test environment that mirrors prod (with PII redacted), and a single shared
deploy gate that warp-drive and you-at-the-keyboard both obey.
This guide is what you follow to do #805
(prove the test-env pipeline end-to-end on one Cloudflare project) and
#809 (roll the model out
across every managed project). Worked example: ~/Sites/seebod/seebod.
New to the model? Read the 90-second mental model first; then do Part A on
one project. Once that works, Part B is just “repeat per project”.
level 3 PR (auto) merge, no deploy deploy→test deploy→prod
external is git-only: merge but never deploy (e.g. Acquia owns the pipeline)
Pick a ceiling per project:
Project kind
Ceiling
Why
greenfield / personal
prod
move fast — merges go straight to production
deployed production app (seebod, example-awards)
test
work deploys to a test mirror and stops; you promote to prod deliberately
review-gated
pr
even autonomous coding stops at a PR
externally deployed (example-cms / Acquia)
external
BoB does git only; deploy happens out-of-band
The test environment is a real, separate set of cloud resources (its own D1
database, its own Pages/Worker) that mirrors prod. Prod data is copied in only
when you opt in, and always redacted (emails/secrets stripped) on the way.
The pieces that make this work (all already built — you’re just configuring and
running them):
Piece
Script
Role
Ceiling
scripts/promotion/promotion.js
the single source of truth for f(level, ceiling)
Deploy gate
scripts/deploy/deploy.sh (bob deploy)
refuses to deploy past the ceiling — same gate everywhere
CI workflow
templates/ci/promote.yml
push to main deploys test; the release tag deploys prod
Test resources + mirror
scripts/deploy/test-env.js
provision test D1/Pages, mirror prod→test with redaction
Fleet view
scripts/fleet/audit.js view
shows each project’s declared ceiling; flags undeclared
The project is BoB-initialised (has a .claude/ and a dev.json). If not, run
bob init in it first (Project Setup).
node, jq, and wrangler on your PATH; sqlite3 for the D1 mirror.
wrangler login (or CLOUDFLARE_API_TOKEN) authenticated on the account that
owns the project’s resources.
BoB deployed to ~/.claude (so the scripts below exist). If a command is “not
found”, run scripts/deploy.sh from ~/projects/bigbrain once.
Throughout, the scripts are referenced at their runtime path ~/.claude/scripts/….
Every wrangler-mutating command defaults to dry-run — it prints what it would
do and changes nothing until you add --apply. Get into the habit of running it
once without --apply, reading the plan, then re-running with it.
Do this end-to-end on one Cloudflare project first (this is exactly
#805). We use seebod.
seebod is a pnpm monorepo: apps/web (SvelteKit → Cloudflare Pages,
seebod-web) and apps/api (Cloudflare Worker, seebod-api, with a D1
seebod-db + R2 seebod-assets). A single-Worker project (e.g. example-awards)
is the same recipe with one wrangler config instead of two — differences are
called out inline, and the full Worker-with-assets walkthrough is
its own section.
The whole ladder is one command (#931). Before walking the six steps by
hand, know that /setup-promotion (and the terminal bob promotion setup /
bob promotion setup — engine scripts/cli/setup-promotion, bin/setup-promotion its alias shim) composes every safe step into one dry-run-first run —
detect → dev.json → wrangler → provision (handoff) → todo → verify — and
reports exactly what it changed:
Terminal window
/setup-promotion# dry-run — preview every step, write nothing
/setup-promotion--apply# write config + file the redaction/secrets gate todo
It is idempotent and stops cleanly at the human gates: test-env.js (live CF
provisioning + the prod→test mirror) and audit.js stay terminal-only and
are surfaced as a handoff — never run for you. A red verify gate blocks
“done”. The six steps below explain and finish exactly what it scaffolds.
Scaffold Steps 1–4 with one wrapper (#928). Rather than run each scaffolder
by hand, the provision wrapper composes detection (#925), the dev.jsonenvironments scaffold (#926), and the wrangler [env.test] + database_id
wire-back (#927) into a single dry-run-first operation:
Terminal window
node~/.claude/scripts/setup-promotion/orchestrate.jsplan.# preview every change; touches nothing
node~/.claude/scripts/setup-promotion/orchestrate.jsapply.# fail-closed: applies config only if the preview is clean
node~/.claude/scripts/setup-promotion/orchestrate.jsteardown.# reverse exactly what was scaffolded
apply is fail-closed — it re-runs the preview and refuses to write
anything if any step errors, so the config is never left half-scaffolded. It is
idempotent and no-clobber, and it writes config only: the live CF
provisioning and secrets below (Steps 3 & 5) stay yours to run, reported as a
handoff. The Step 5 redaction/secrets decisions are handed back as a single
todo by the human-gate emitter (#929) that resumes only on the completed
label — see Step 5. teardown round-trips the config back to its original
state. You still walk Steps 1–6 below to understand and finish what the wrapper
hands off.
status prints the effective merge= / deploy= for the current automation
level so you can see exactly what will happen. (This writes
promotion.ceiling: "test" into dev.json.)
Scaffold it instead of hand-writing it (#926). The environments block
below can be generated from the detected stack rather than typed by hand — the
scaffolder is the second step of /setup-promotion (#924):
Terminal window
node~/.claude/scripts/setup-promotion/scaffold-dev-json.js.# dry-run: print the proposed dev.json
node~/.claude/scripts/setup-promotion/scaffold-dev-json.js.--write# persist it
It derives test/prod profiles and the inferred promotion.ceiling from
detection (#925), is idempotent
(re-running produces no diff), never clobbers an existing key, and
validates against the dev.json schema before writing. Monorepos scaffold
each app’s own apps/<name>/dev.json; a non-promotable (n-a) repo is declined
rather than force-fit. You still fill in the concrete details below — URLs,
D1/Pages names, and the mirror redaction policy — which the scaffolder
deliberately leaves as null (the wrangler test target + database_id are
wired in Step 4 / #927).
Add an environments block describing test and prod. This is the BoB-level
declaration the gate, the mirror, and the audit read. For seebod’s API
(the Worker that owns the data):
prod.db.database is the existing prod D1 (seebod-db). test.db.database
is a new, separate test D1 (seebod-db-test) — never point test at prod.
provision.d1_database / provision.pages_project / provision.r2_buckets
name the resources BoB will create in Step 3 — r2_buckets is a list of test
bucket names (["seebod-assets-test"]), one per R2 binding the app writes.
mirror.enabled stays false until you’ve written a real redaction policy
and are ready (Step 5). mirror may live only on the test env (you mirror
into test from prod).
The web app (Pages) gets its own simpler block — it has no database, so it
only needs deploy: { "adapter": "cloudflare", "target": "seebod-web" } per
env and a provision: { "pages_project": "seebod-web-test" } on test.
Single-Worker project (example-awards): one environments block at the repo
root, prod.db.database: "example-awards-db", test.db.database: "example-awards-db-test".
Validate the file (terminal only — schema validation has no slash form):
Terminal:
Terminal window
node~/.claude/scripts/checks/check-schemas.js# dev.json must stay schema-valid
Step 3 — Provision the test resources (idempotent)
This creates the declared test Pages project, test D1 and every test R2 bucket
only if missing (a resource that already exists prints exists: … (no-op),
so it’s safe to re-run). For the API,
it creates seebod-db-test; for the web app, seebod-web-test.
Copy the new D1’s database_id.wrangler d1 create prints it — you need
it for the wrangler test-env block in Step 4.
Scaffold it — including the database_id wire-back (#927, #3080). The
env.test and env.prod blocks below, and the error-prone copy-paste of the
test D1’s database_id from Step 3, can be generated automatically — the
wrangler step of /setup-promotion (#924):
node~/.claude/scripts/setup-promotion/scaffold-wrangler.js.--apply# write them, wiring the test D1 id from `wrangler d1 list`
It works on wrangler.toml, wrangler.json and a commented
wrangler.jsonc alike, reading the config with the same reader the deploy
adapter plans from. The env.test block points each binding at its
<name>-test twin — D1 database_name, R2 bucket_name, Analytics Engine
dataset — copies the Images binding and the top-level vars, and sets
vars.ENVIRONMENT = "test"; the env.prod block repeats the top-level
bindings exactly and keeps the Worker name, so wrangler deploy --env prod
ships the same Worker. TOML is appended to; JSON and JSONC are edited in place
by inserting the new members — every comment survives. A binding with no
test twin (KV, Durable Objects, queues, services, …) is listed as
unmapped and left out of env.test rather than pointed at production.
Under --apply the test D1 database_id is looked up (or, if absent,
created) and written into the block, and each test R2 bucket is checked
against wrangler r2 bucket list. A bucket that is not there is reported by
name — missing: r2 "<name>" — and never created here: create it with
test-env.js provision --env test --apply, which provisions every bucket in
environments.test.provision.r2_buckets (Step 3). Like the rest of the
scaffolder it is no-clobber and idempotent (an existing env.test or
env.prod is left as-is), skips cleanly when there is no wrangler config,
and nothing touches your Cloudflare account without --apply. You still
set secrets (they never copy from prod).
BoB’s dev.json declares intent; wrangler still needs a test target to deploy
to. For a Worker (seebod-api), add a named [env.test] block to
apps/api/wrangler.toml mirroring [env.production] but bound to the test
D1, and crucially without remote = true on its bindings:
[env.test]
[env.test.vars]
ENVIRONMENT = "test"
[[env.test.d1_databases]]
binding = "DB"
database_name = "seebod-db-test"
database_id = "<id-from-step-3>"
migrations_dir = "migrations"
[[env.test.r2_buckets]]
binding = "ASSETS"
bucket_name = "seebod-assets-test"# provision this too if the app writes R2
seebod gotcha — the remote = true trap. Today seebod’s dev bindings use
remote = true, i.e. local dev reads and writes production D1/R2 directly.
A real test env (separate DB + redacted mirror) is the safe replacement: point
dev/test at the test resources and keep prod for prod. Flipping these is part of
the per-project work in #809.
For Pages (seebod-web) there’s no named-env block; the CF deploy adapter maps
--env test to a preview deployment, and --env prod to production. Nothing
extra is needed beyond the provision.pages_project.
Set any secrets the test env needs (they do not copy from prod):
Terminal window
wranglersecretputANTHROPIC_API_KEY--envtest
# …repeat for OPENAI_API_KEY, CLOUDFLARE_STREAM_TOKEN, etc.
The scaffolder hands this step back as one todo (#929). Steps 5’s two
irreducible decisions — the redaction policy and the test-env secrets —
are the only things the scaffolder will not do for you. It hands them back as
a row on ONE self-contained roll-up todo in bigbrain (assigned to you) — one
checkbox per project, never a todo per project (#3161) — rather than ever
auto-enabling the mirror:
Terminal window
node~/.claude/scripts/setup-promotion/human-gate.jsemit--apply# add this project to the gate roll-up (idempotent)
node~/.claude/scripts/setup-promotion/human-gate.jsgate# is the gate cleared? (resume vs still-blocked)
The mirror stays off until you do the work below and tick this project’s
box on the roll-up (or add the completed label to the roll-up, which
clears every listed project). That is what lets the scaffolder resume the
mirror step on its next run; closing the roll-up without completed cancels
every unticked project and never resumes. (This is the same
completed lifecycle the todo consumer drives.)
Only now turn the mirror on. First write a real redaction policy — inspect
your schema and list every PII/secret column and every table to drop:
Terminal window
wranglerd1executeseebod-db--remote--command"SELECT name FROM sqlite_master WHERE type='table'"
Edit environments.test.mirror in dev.json: set enabled: true and fill
redaction (the example in Step 2 is a starting point — make it match your real
columns). The mirror refuses to run if enabled is true but no redaction
policy is declared — that’s the safety gate, not a bug.
Preview, eyeball the redacted dump, then apply (terminal only — test-env.js has
no slash form):
Terminal:
Terminal window
node~/.claude/scripts/deploy/test-env.jsmirror--envtest--project"$(pwd)"# dry-run: shows export→redact→import plan
node~/.claude/scripts/deploy/test-env.jsmirror--envtest--project"$(pwd)"--apply# do it
# inspect the sanitised dump BEFORE trusting it:
grep-iE"@|secret|token".mirror-redacted.sql|head# should show NO real emails/secrets
Deploy to test via the shared entrypoint (it re-checks the ceiling gate, runs
check → build → migrate → deploy → verify). Both forms run the same dispatcher and
re-check the ceiling gate; the slash form acts on the current project, so it needs
no --project:
Slash command:
/deploy --env test --dry-run
/deploy --env test
Terminal:
Terminal window
bobdeploy--envtest--dry-run# preview: every step, with the exact wrangler commands for test
bobdeploy--envtest# ship to test
bob deploy (#3081) runs scripts/deploy/deploy.sh on the current directory;
--project <dir> deploys another tree. bob deploy --env prod is how TEST is
promoted to PROD by hand — main never deploys prod on a push.
The scaffolder’s final step is the verification gate (#930). Rather than
eyeballing the dry-run, run the gate — it invokes deploy.sh --env test --dry-run, emits a report (ceiling + scaffolded files + outcome), and exits
non-zero if the dry-run is not green so a red result can’t be mistaken for a
pass:
Terminal window
node~/.claude/scripts/setup-promotion/verify-gate.jsrun.# human summary; exit 0 green / 1 red
A green gate on the live seebod project is the accepted proof for
#805 — see the note at the
end of this step.
Confirm the gate is doing its job — at ceiling test, a prod deploy is
refused unless you deliberately raise the ceiling or pass --force:
Slash command:
/deploy --env prod
Terminal:
Terminal window
bobdeploy--envprod# expect: blocked (exit 3), with the gate's reason
A green verify-gate.js run (a passing --env test dry-run) on this one project
is the acceptance bar for #805. Capture the gate’s report, comment the result
on #451, and add the
completed label to #805 so downstream tooling resumes.
Step 7 — Let CI promote: test on push, prod on the release tag
bob promotion setup --apply also writes .github/workflows/promote.yml from
templates/ci/promote.yml (#3081) — its ci workflow step. It runs on a
Cloudflare project that is not a monorepo, and never over an existing file:
delete the file and re-run to re-scaffold it.
Job
Runs on
Steps
test
a push to the main branch
ceiling gate → check → test → build → backup before migrate → wrangler d1 migrations apply <DB> --env test --remote → wrangler deploy --env test → health check against environments.test.url
prod
a published GitHub Release, a completed Auto Release run, or a manual Run workflow naming a tag
checks out the tag, then the same steps against prod
Prod never deploys on a push to main. The release tag is what promotes TEST
to PROD in CI, and bob deploy --env prod is the same promotion by hand. The
tag comes from auto-release.yml (the versioning template).
It pushes the tag with GITHUB_TOKEN, and a tag pushed that way starts no other
workflow. So the prod job listens for the finished Auto Release run and
deploys the newest v* tag. To redeploy an older release, open the workflow’s
Run workflow form and give it the tag.
What the scaffolder fills in, and what the workflow reads when it runs:
The wrangler commands are planned from your wrangler config when you
scaffold, by the same planner bob deploy --dry-run prints. Compare them:
Terminal window
bobdeploy--envtest--dry-run
grep-n'npx wrangler'.github/workflows/promote.yml
Rename a wrangler env or a D1 binding, and you delete promote.yml and run
bob promotion setup --apply again.
The ceiling gate reads dev.jsonpromotion.ceiling each run, with the
same ranking bob deploy uses (unset / pr / external < test < prod).
At ceiling test the prod job stops with exit 3 and says why:
refused: dev.json promotion.ceiling is 'test', which forbids deploying to 'prod'.
Raise the ceiling to prod when you are ready to let a release ship. The
automation level is not read in CI: a tag or a manual run is a person’s act.
Backup before migrate (#3087) is a no-op until dev.json declares
environments.<env>.backup. For an env that declares one, the scaffolder
writes the backup Worker’s name, bucket and database into the step. When that
database has pending migrations, the step triggers the backup Workflow, waits
for the new dump, and prints its key the way bob deploy does:
pre-migrate backup: r2://<bucket>/<database>/<stamp>.sql. A failed backup
stops the job before migrating. If you declare a backup after scaffolding,
the job stops before migrating and tells you to re-scaffold, rather than
migrating without it. The backup step gets the Cloudflare secrets too, since
it calls wrangler.
The health check reads environments.<env>.url (plus server.health) from
dev.json, and skips with a warning when there is none.
The runner is ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }}, like every
BoB workflow (#1782) — see CI Locus.
Set the secrets the jobs read — CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID
— on the repo, or per GitHub environment (test, prod; the jobs name them, and
GitHub creates each one on first use):
Check it: push a commit to main and watch the Promote run in
gh run list --workflow promote.yml. Only the test job runs, and the prod
job shows as skipped.
Then render the backup Worker and set its bucket and retention. Read the dry
run first, then apply it:
Terminal window
bobbackupsetup# the plan: files, bucket, lifecycle rules
bobbackupsetup--apply# writes backup-worker/, creates the bucket, sets the rules
Set the Worker’s token and deploy it, as the command’s next: lines print.
The token is a Cloudflare API token with Account · D1 · Edit on the
project’s account and nothing else — the D1 export API refuses a Read token:
Check it: run bob backup setup --apply again. It prints no change.
The next morning, check the first backup landed:
Terminal window
bobbackupstatus# newest dump, its age, size and row counts; exit 0 fresh, 1 stale
A backup counts as fresh when it is no older than the run the schedule last
fired, allowing that run an hour to finish. Until the Worker’s first run, the
status is STALE with no backup yet. When the Worker ran and failed — a
missing or refused token makes every run error in seconds — the reason names
the latest run instead: the latest backup run errored — instance <id>, started <time>: <error>.
To take one now — before a risky migration, or to see the Worker work on day
one — and then prove it restores:
Terminal window
bobbackuprun# triggers the nightly Workflow and waits for the dump
bobbackuprestore# loads the newest dump into TEST and checks its row counts
bob backup restore prints the elapsed time as the measured RTO. It restores
into the declared restore_target (TEST); into PROD it is refused unless a
human passes its gate.
Worker with assets on wrangler.jsonc (example-awards, #3069)
Most of our apps are one Cloudflare Worker that serves its own assets
(SvelteKit on adapter-cloudflare), configured by a commented wrangler.jsonc,
with D1, R2, Images, Analytics Engine, cron triggers and — often — routes on a
production hostname. example-co/example-awards is the reference. The Part A steps
apply as written; this section lists what is different and the order to do it
in. Every command runs from the project root.
A Worker repo can be deployed by more than one thing. Find them all before
adding promote.yml, or a push to main ships prod twice — once correctly, once
by the old path.
Cloudflare Workers Builds. A dashboard-side Git integration builds and
deploys on every push, with no ceiling gate. Check it in the dashboard:
Workers & Pages → <worker> → Settings → Builds. If a repository is
connected, disconnect it. Check it from git: the commit’s checks should not
include Workers Builds: <worker>:
example-awards had one (example-co/example-awards#124); it was disconnected on
2026-09-30 and its pushes since show only the deploy check.
An existing deploy workflow. example-awards’ .github/workflows/deploy.yml
runs a bare wrangler deploy on every push to main — that is a prod
deploy. promote.yml replaces it: delete deploy.yml in the same commit
that adds promote.yml. Keep auto-release.yml; its tag is what the prod
job deploys (Step 7).
1. Scaffold — and fix what the scaffold cannot know
bobpromotionsetup--apply# writes them; the verify gate must say green
The --apply is the first step that touches the account: it looks up the test
D1 with wrangler d1 list and creates it when missing.
On example-awards the apply sets promotion.ceiling: "test", adds env.test and
env.prod to wrangler.jsonc with every comment kept, and writes
.github/workflows/promote.yml. env.test names the Worker
example-awards-test and points D1 at example-awards-db-test, R2 at
example-awards-images-test and Analytics Engine at example-awards_interactions-test;
version_metadata is reported unmapped.
Then edit three things by hand before the first deploy:
Routes and crons in env.test. Wrangler inheritsroutes and
triggers into a named environment. Without an override, the first test
deploy moves the production routes (www.example.com/company/awards/…,
/admin/contest*) onto example-awards-test, and the test Worker runs the
production crons. Until #4192 makes the scaffolder do it, add to env.test:
"routes": [],
"triggers": { "crons": [] },
The test Worker then answers only on its workers.dev address.
Without provision, test-env.js provision reports “declares no resources”;
without url, bob deploy skips the health check.
Private packages in CI. If npm ci needs GitHub Packages (example-awards
installs @example-co/*), copy the registry-url and NODE_AUTH_TOKEN
lines from the old deploy.yml into both install steps of promote.yml,
until #4193 makes the template do it.
node~/.claude/scripts/deploy/test-env.jsprovision--envtest--project"$(pwd)"# dry run
node~/.claude/scripts/deploy/test-env.jsprovision--envtest--project"$(pwd)"--apply# creates the test R2 bucket
The test D1 already exists by now: the --apply in step 1 looked
example-awards-db-test up — creating it when missing, which is a live change to
the account — and wrote its database_id into env.test. It also named each
test R2 bucket it could not find (missing: r2 "example-awards-images-test");
provisioning creates those. The scaffolder never rewrites an env.test that
exists, so a wrong id is fixed by hand.
Secrets never copy from prod. Set each one the test Worker reads, per
environment, and record the copy in secrets-inventory.json — example-awards’
entries already declare environments: test, prod
(the environment axis):
Terminal window
wranglersecretputSTREAM_API_TOKEN--envtest
wranglersecretputWAE_SQL_API_TOKEN--envtest
# …one per secret the inventory lists for example-awards with a test environment
Then walk the ladder:
Terminal window
bobdeploy--envtest--dry-run# check → build → migrate (d1 migrations apply DB --env test --remote) → deploy --env test
bobdeploy--envtest# ship to test; the verify step hits environments.test.url
bobdeploy--envprod# at ceiling test: refused, exit 3, unless --force
On a scratch clone of example-awards, with wrangler and gh stubbed so nothing
reached Cloudflare or GitHub (2026-10-07): bob promotion setup --apply turned
the verify gate green and wrote all three files; bob deploy --env test --dry-run planned wrangler d1 migrations apply DB --env test --remote and
wrangler deploy --env test; bob deploy --env prod was refused with exit 3.
The same run found #4192 and #4193. The live apply on the client’s Cloudflare account —
provision, secrets, the first test deploy and its health check — is the
operator’s step, and is what closes #451’s last criterion.
With the recipe proven on seebod, the rollout is mechanical — and fleet mode
(#932) performs it as one serial command over the project registry:
Terminal window
node~/.claude/scripts/setup-promotion/fleet.jsrun# dry-run preview per project
node~/.claude/scripts/setup-promotion/fleet.jsrun--apply# scaffold the whole fleet
Each project is reported as scaffolded / no-change / gated / skipped /
failed (gated = config landed, done blocked on the human handoffs);
n-a repos (a CLI/tooling repo with no deploy target) are skips, not failures,
and projects run strictly one at a time (config-sync pinch point — never fan
out). Live CF provisioning and secrets remain per-project human handoffs, so
after an --apply pass walk the emitted gate todos.
Done by hand instead, the same rollout is, for every managed project:
Set a ceiling (Step 1) — prod for greenfield, test/pr for production
apps, external for example-cms.
Commit a dev.json with the environments profiles (Step 2) where the
project actually has a higher environment. A CLI/tooling repo with no deploy
target legitimately needs none.
Migrate the four deploy-cloudflare projects to the adapter model — give
each an environments.<env>.deploy.adapter: "cloudflare" and deploy once via
deploy.sh to confirm no regression versus their old deploy path.
Then verify the whole fleet at a glance (terminal only — audit.js has no slash
form):
Terminal:
Terminal window
node~/.claude/scripts/fleet/audit.jsview
The CEILING column shows declared/total per machine and fleet-wide; a !N
suffix flags projects still undeclared. The rollout is done when every
managed project shows a declared ceiling (no !), which is the #809 / R8
acceptance check. Comment the before/after on
#453 and add completed to
#809.
Commit the config to each project’s own repo.promotion.ceiling,
environments, and the wrangler [env.test] blocks live in the project, not
in BoB — they travel with the code and CI.
bob deploy exits 3 “promotion ceiling forbids…” (the gate: line says why)
Working as designed — the env exceeds the ceiling. Raise the ceiling (promotion.js set), bump automation level, or pass --force for a deliberate one-off.
mirror exits 2 “redaction declares no policy”
mirror.enabled is true but redaction is empty. Add drop_tables/columns/patterns — sanitisation is mandatory.
mirror says “not enabled — skipping”
mirror.enabled is false (the default). Set it true once your redaction policy is ready.
Redacted dump still shows real emails
Your columns/patterns don’t match the real schema. Inspect tables with PRAGMA table_info(...) and widen the policy; re-run the dry-run and re-grep.
Dev/test reads production data
A wrangler binding has remote = true (the seebod trap). Point test bindings at the test resources and drop remote = true.
Test deploy can’t find the DB
The wrangler [env.test] block is missing or its database_id is wrong. Re-check Step 4 against the id from Step 3.
The Promote workflow’s prod job fails “refused: dev.json promotion.ceiling is ‘test’…”
Working as designed: the ceiling stops a release reaching prod. Set promotion.ceiling to prod in dev.json and commit it, then cut or re-run the release.
A release was cut but the prod job never ran
The Auto Release run did not finish green, or the workflow predates #3081. Check gh run list --workflow promote.yml, and re-run with Run workflow and the tag.
audit.js view shows !N in CEILING
N projects have no declared promotion.ceiling yet — finish Part B Step 1 for them.
Warp-Drive Guide — the promoting phase that runs Step 6
automatically after a merge, using this same gate.
CI Locus — where a project’s CI runs (fleet
runner or GitHub-hosted) as a declared _meta.ci setting, reconciled into the
repo and drift-reported by bob provision status and the fleet snapshot (#1728).