Skip to content

Set Up dev → test → prod

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”.


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, 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

Full references: Deploy Adapters · Test-Env Mirror · Dev Lifecycle.


  • 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
setup-promotion --json # terminal equivalent, machine-readable

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.json environments 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.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

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.

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 status

Terminal:

Terminal window
cd ~/Sites/seebod/seebod
node ~/.claude/scripts/promotion/promotion.js set test
node ~/.claude/scripts/promotion/promotion.js status # confirm

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.)

Step 2 — Declare the environments in dev.json

Section titled “Step 2 — Declare the environments in 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):

{
// …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.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)

Section titled “Step 3 — Provision the test resources (idempotent)”

Preview, then apply (terminal only — test-env.js has no slash form):

Terminal:

Terminal window
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 # create

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.

Step 4 — Point wrangler at the test environment

Section titled “Step 4 — Point wrangler at the test environment”

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):

Terminal window
node ~/.claude/scripts/setup-promotion/scaffold-wrangler.js . # dry-run: propose env.test + env.prod, report ids and missing resources
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
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)

Section titled “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 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.js emit --apply # add this project to the gate roll-up (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 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
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:

Terminal window
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/secrets

Check freshness any time:

Terminal:

Terminal window
node ~/.claude/scripts/deploy/test-env.js status --env test --project "$(pwd)"

Step 6 — Deploy through the ladder and verify

Section titled “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 test

Terminal:

Terminal window
bob deploy --env test --dry-run # preview: every step, with the exact wrangler commands for test
bob deploy --env test # 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.js run . # human summary; exit 0 green / 1 red
node ~/.claude/scripts/setup-promotion/verify-gate.js run . --json # machine-readable report

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
bob deploy --env prod # 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

Section titled “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
    bob deploy --env test --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.json promotion.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):

Terminal window
gh secret set CLOUDFLARE_API_TOKEN --env test
gh secret set CLOUDFLARE_API_TOKEN --env prod
gh secret set CLOUDFLARE_ACCOUNT_ID --body "<account id>"

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.

Declare the backup on prod in dev.json (the full block is in dev-lifecycle → Declared backup):

"prod": { "backup": { "database": "DB", "retention": { "dailies": 7, "monthlies": 12 } } }

Then render the backup Worker and set its bucket and retention. Read the dry run first, then apply it:

Terminal window
bob backup setup # the plan: files, bucket, lifecycle rules
bob backup setup --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:

Terminal window
wrangler secret put D1_REST_API_TOKEN --config backup-worker/wrangler.jsonc
wrangler deploy --config backup-worker/wrangler.jsonc

Check it: run bob backup setup --apply again. It prints no change.

The next morning, check the first backup landed:

Terminal window
bob backup status # 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
bob backup run # triggers the nightly Workflow and waits for the dump
bob backup restore # 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)

Section titled “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.

  1. 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>:
    Terminal window
    sha=$(git rev-parse HEAD)
    gh api repos/<owner>/<repo>/commits/$sha/check-runs --jq '.check_runs[].name'
    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.
  2. 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

Section titled “1. Scaffold — and fix what the scaffold cannot know”
Terminal window
bob promotion setup # dry run: dev.json, wrangler.jsonc, promote.yml, verify gate
bob promotion setup --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 inherits routes 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.
  • What test provisions, and its URL — in dev.json:
    "test": {
    "url": "https://example-awards-test.<subdomain>.workers.dev",
    "provision": { "d1_database": "example-awards-db-test", "r2_buckets": ["example-awards-images-test"] }
    }
    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.
Terminal window
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 # 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
wrangler secret put STREAM_API_TOKEN --env test
wrangler secret put WAE_SQL_API_TOKEN --env test
# …one per secret the inventory lists for example-awards with a test environment

Then walk the ladder:

Terminal window
bob deploy --env test --dry-run # check → build → migrate (d1 migrations apply DB --env test --remote) → deploy --env test
bob deploy --env test # ship to test; the verify step hits environments.test.url
bob deploy --env prod # 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.


Part B — roll it out across the fleet (#809)

Section titled “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:

Terminal window
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 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:

  1. Set a ceiling (Step 1) — prod for greenfield, test/pr for production apps, external for example-cms.
  2. 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.
  3. 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.js view

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.


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.


Symptom Cause / fix
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.

  • Test-Env Mirror reference — the redaction layers and the non-Cloudflare generalization path.
  • Deploy Adapters — how deploy.sh resolves and runs an adapter; adding a new stack.
  • Dev Lifecycle — dev.json, bob dev up, seed data.
  • 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).