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, nanaawards)
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 (nanawalld8 / 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
refuses to deploy past the ceiling — same gate everywhere
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
cdi 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. nanaawards)
is the same recipe with one wrangler config instead of two — differences are
called out inline.
The whole ladder is one command (#931). Before walking the six steps by
hand, know that /setup-promotion (and the terminal setup-promotion /
bin/setup-promotion) 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 name the resources BoB will
create in Step 3.
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 (nanaawards): one environments block at the repo
root, prod.db.database: "nanaawards-db", test.db.database: "nanaawards-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 and test D1 only if missing (a
resource that already exists is a 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). The [env.test]
block 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# look up / create the test D1 and wire its database_id
It mirrors the top-level d1 bindings into [env.test]
(database_name → <name>-test), looks up or creates the test D1, and writes its database_id
back into the block — no hand-editing. Like the rest of the scaffolder it is
no-clobber and idempotent (an existing [env.test] is left as-is), and it
follows the test-env.js safety model:
nothing touches your Cloudflare account without --apply. It skips
cleanly when there is no wrangler config, and declines a commented
wrangler.jsonc (which can’t be reserialized without losing comments) — do
those by hand below. You still add any non-D1 bindings (R2, vars) and 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 emits them as a
single, self-contained todo (assigned to you) rather than ever auto-enabling
the mirror:
Terminal window
node~/.claude/scripts/setup-promotion/human-gate.jsemit--apply# file the gate todo (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 add the completed
label to that todo — the canonical done-signal. Marking it completed is what
lets the scaffolder resume the mirror step on its next run; closing the todo
without completed cancels it 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
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)" --dry-run # preview
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)"# ship to test
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 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.
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 nanawalld8.
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.
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.
audit.js view shows !N in CEILING
N projects have no declared promotion.ceiling yet — finish Part B Step 1 for them.