Appearance
Set Up dev → test → prod on a Project
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".
The mental model (90 seconds)
Promotion is governed by two independent dials — change one without touching the other:
| Dial | What it controls | Where it lives |
|---|---|---|
| automation level (1/2/3) | how much human ceremony — supervised → autonomous | .claude/settings.local.json → _automation.active_level (set via /automation) |
promotion ceiling (pr/external/test/prod) | how far the pipeline may travel | dev.json → promotion.ceiling (set via promotion.js set) |
The deploy behaviour is the product of the two — f(level, ceiling):
ceiling: pr external test prod
level 2 PR + confirm PR (no deploy) deploy→test (ask) deploy→prod (ask)
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 |
Full references: Deploy Adapters · Test-Env Mirror · Dev Lifecycle.
Prerequisites
- The project is BoB-initialised (has a
.claude/and adev.json). If not, runcdiin it first (Project Setup). node,jq, andwrangleron your PATH;sqlite3for the D1 mirror.wrangler login(orCLOUDFLARE_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", runscripts/deploy.shfrom~/projects/bigbrainonce.
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.
Part A — set it up on one project
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) andapps/api(Cloudflare Worker,seebod-api, with a D1seebod-db+ R2seebod-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 terminalsetup-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:bash/setup-promotion # dry-run — preview every step, write nothing /setup-promotion --apply # write config + file the redaction/secrets gate todo setup-promotion --json # terminal equivalent, machine-readableIt is idempotent and stops cleanly at the human gates:
test-env.js(live CF provisioning + the prod→test mirror) andaudit.jsstay terminal-only and are surfaced as a handoff — never run for you. A redverifygate 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.jsonenvironmentsscaffold (#926), and the wrangler[env.test]+database_idwire-back (#927) into a single dry-run-first operation:bashnode ~/.claude/scripts/setup-promotion/orchestrate.js plan . # preview every change; touches nothing node ~/.claude/scripts/setup-promotion/orchestrate.js apply . # fail-closed: applies config only if the preview is clean node ~/.claude/scripts/setup-promotion/orchestrate.js teardown . # reverse exactly what was scaffolded
applyis 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 singletodoby the human-gate emitter (#929) that resumes only on thecompletedlabel — see Step 5.teardownround-trips the config back to its original state. You still walk Steps 1–6 below to understand and finish what the wrapper hands off.
Step 1 — Choose and set the ceiling
seebod is a deployed production app, so its ceiling is test. Set and confirm it — both forms do the same thing:
Slash command:
/promotion set test
/promotion statusTerminal:
bash
cd ~/Sites/seebod/seebod
node ~/.claude/scripts/promotion/promotion.js set test
node ~/.claude/scripts/promotion/promotion.js status # confirmstatus 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.)
Step 2 — Declare the environments in dev.json
Scaffold it instead of hand-writing it (#926). The
environmentsblock below can be generated from the detected stack rather than typed by hand — the scaffolder is the second step of/setup-promotion(#924):bashnode ~/.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 itIt derives
test/prodprofiles and the inferredpromotion.ceilingfrom detection (#925), is idempotent (re-running produces no diff), never clobbers an existing key, and validates against thedev.jsonschema before writing. Monorepos scaffold each app's ownapps/<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 asnull(the wrangler test target +database_idare 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):
jsonc
{
// …existing server/migrations/access blocks…
"promotion": { "ceiling": "test" },
"environments": {
"prod": {
"url": "https://seebod-api.<account>.workers.dev",
"deploy": { "adapter": "cloudflare", "target": "seebod-api" },
"db": { "adapter": "d1", "binding": "DB", "database": "seebod-db" }
},
"test": {
"url": "https://test.seebod-api.<account>.workers.dev",
"deploy": { "adapter": "cloudflare", "target": "seebod-api" },
"db": { "adapter": "d1", "binding": "DB", "database": "seebod-db-test" },
"provision": { "d1_database": "seebod-db-test" },
"mirror": {
"enabled": false,
"source": "prod",
"redaction": {
"drop_tables": ["sessions", "api_keys"],
"columns": ["users.email", "users.phone"],
"patterns": [
{ "match": "[\\w.+-]+@[\\w.-]+\\.\\w+", "replace": "redacted@example.test" }
]
}
}
}
}
}Notes:
prod.db.databaseis the existing prod D1 (seebod-db).test.db.databaseis a new, separate test D1 (seebod-db-test) — never point test at prod.provision.d1_database/provision.pages_projectname the resources BoB will create in Step 3.mirror.enabledstaysfalseuntil you've written a real redaction policy and are ready (Step 5).mirrormay live only on thetestenv (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 aprovision: { "pages_project": "seebod-web-test" }on test. - Single-Worker project (nanaawards): one
environmentsblock 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:
bash
node ~/.claude/scripts/checks/check-schemas.js # dev.json must stay schema-validStep 3 — Provision the test resources (idempotent)
Preview, then apply (terminal only — test-env.js has no slash form):
Terminal:
bash
node ~/.claude/scripts/deploy/test-env.js provision --env test --project "$(pwd)" # dry-run
node ~/.claude/scripts/deploy/test-env.js provision --env test --project "$(pwd)" --apply # createThis 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 createprints it — you need it for the wrangler test-env block in Step 4.
Step 4 — Point wrangler at the test environment
Scaffold it — including the
database_idwire-back (#927). The[env.test]block below, and the error-prone copy-paste of the test D1'sdatabase_idfrom Step 3, can be generated automatically — the wrangler step of/setup-promotion(#924):bashnode ~/.claude/scripts/setup-promotion/scaffold-wrangler.js . # dry-run: propose [env.test], report would-create ids node ~/.claude/scripts/setup-promotion/scaffold-wrangler.js . --apply # look up / create the test D1 and wire its database_idIt mirrors the top-level d1 bindings into
[env.test](database_name → <name>-test), looks up or creates the test D1, and writes itsdatabase_idback 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 thetest-env.jssafety model: nothing touches your Cloudflare account without--apply. It skips cleanly when there is no wrangler config, and declines a commentedwrangler.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:
toml
[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 R2seebod gotcha — the
remote = truetrap. Today seebod's dev bindings useremote = true, i.e. local dev reads and writes production D1/R2 directly. A realtestenv (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):
bash
wrangler secret put ANTHROPIC_API_KEY --env test
# …repeat for OPENAI_API_KEY, CLOUDFLARE_STREAM_TOKEN, etc.Step 5 — Mirror prod → test (redacted, opt-in)
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-containedtodo(assigned to you) rather than ever auto-enabling the mirror:bashnode ~/.claude/scripts/setup-promotion/human-gate.js emit --apply # file the gate todo (idempotent) node ~/.claude/scripts/setup-promotion/human-gate.js gate # is the gate cleared? (resume vs still-blocked)The mirror stays off until you do the work below and add the
completedlabel to that todo — the canonical done-signal. Marking itcompletedis what lets the scaffolder resume the mirror step on its next run; closing the todo withoutcompletedcancels it and never resumes. (This is the samecompletedlifecycle 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:
bash
wrangler d1 execute seebod-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'"
# then, for a suspect table:
wrangler d1 execute seebod-db --remote --command "PRAGMA table_info(users)"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:
bash
node ~/.claude/scripts/deploy/test-env.js mirror --env test --project "$(pwd)" # dry-run: shows export→redact→import plan
node ~/.claude/scripts/deploy/test-env.js mirror --env test --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/secretsCheck freshness any time:
Terminal:
bash
node ~/.claude/scripts/deploy/test-env.js status --env test --project "$(pwd)"Step 6 — Deploy through the ladder and verify
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 testTerminal:
bash
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)" --dry-run # preview
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)" # ship to testThe 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:bashnode ~/.claude/scripts/setup-promotion/verify-gate.js run . # human summary; exit 0 green / 1 red node ~/.claude/scripts/setup-promotion/verify-gate.js run . --json # machine-readable reportA 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 prodTerminal:
bash
~/.claude/scripts/deploy/deploy.sh --env prod --project "$(pwd)" # expect: blocked (exit 3)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.
Part B — roll it out across the fleet (#809)
With the recipe proven on seebod, the rollout is mechanical — and fleet mode (#932) performs it as one serial command over the project registry:
bash
node ~/.claude/scripts/setup-promotion/fleet.js run # dry-run preview per project
node ~/.claude/scripts/setup-promotion/fleet.js run --apply # scaffold the whole fleetEach 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) —
prodfor greenfield,test/prfor production apps,externalfor nanawalld8. - Commit a
dev.jsonwith theenvironmentsprofiles (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-cloudflareprojects to the adapter model — give each anenvironments.<env>.deploy.adapter: "cloudflare"and deploy once viadeploy.shto 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:
bash
node ~/.claude/scripts/fleet/audit.js viewThe 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.
How the two TODOs map to these steps
| TODO | What it is | Steps |
|---|---|---|
| #805 | Prove the pipeline e2e on one CF project | Part A, Steps 3–6 (on seebod or any one CF app) |
| #809 | Declare ceilings + backfill dev.json + migrate the 4 CF projects, fleet-wide | Part B (Steps 1–2 per project, adapter migration, then the audit) |
Do #805 first — it shakes out the per-project mechanics before you repeat them.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
deploy.sh exits 3 "promotion ceiling forbids…" | 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. |
See also
- Test-Env Mirror reference — the redaction layers and the non-Cloudflare generalization path.
- Deploy Adapters — how
deploy.shresolves and runs an adapter; adding a new stack. - Dev Lifecycle —
dev.json,dev-up, seed data. - Warp-Drive Guide — the
promotingphase that runs Step 6 automatically after a merge, using this same gate.