Three Environments, One Promotion Path

Dev, staging and production, and the single path between them: you cannot deploy what you have not merged, or promote what you have not tested

Every project gets three environments and exactly one way to reach production. The design exists to make one rule unskippable: you cannot deploy what you have not merged, and you cannot promote what you have not tested.

Today that rule is an instruction, and instructions do not survive contact with a working tree. A deploy builds whatever happens to be on disk — including uncommitted changes, including a checkout six commits behind. On 2026-09-11 a live client site was deployed from a tree carrying another agent’s uncommitted config, and that repo had diverged across two machines. Neither is possible here, because the artifact is built from a ref that must exist on the remote. You cannot build a working tree.

The three environments

Dev Staging Production
Source your working tree staging branch production branch
Built by you, locally Cloudflare, on push Cloudflare, on push
Address pinned port + tunnel URL its own worker the real domain
For interactive UI work browser behaviour, real bindings users
Credentials local .env its own staging keys production secrets

Dev is a local server on a port pinned per project, fronted by a tunnel so the URL can be handed to someone else. Nothing is deployed. UI iteration happens here because that loop has to be seconds.

Staging is a real deployment on its own worker, with its own credentials — not a copy of production’s. It exists so browser behaviour can be checked against real bindings, real storage and the real Workers runtime. It is not a scratch environment: staging is always the release candidate.

Production is whatever production points at. Nothing writes to that branch except a promotion that passed.

Versions

Every push to staging increments the version. Not every commit, not every production deploy — every staging push, because that is the moment a new candidate exists. A version identifies one candidate, travels with it through testing, and arrives in production unchanged.

A commit sha alone is not enough. Redeploying the same commit is ordinary — a retry, a config-only change, a re-run — and a sha comparison passes in every one of those having verified nothing. That hole was found in this pipeline’s own verification on 2026-09-11: staging was already serving the sha being checked for, so only a fresh build timestamp revealed the new build had landed.

The health endpoint

Every site answers /api/health with its identity and a complete set of checks:

{
  "status": "ok",
  "version": "3.0.116",
  "gitSha": "9db3f02",
  "builtAt": "2026-09-11T15:59:16Z",
  "checks": {
    "database": { "status": "ok" },
    "kv":       { "status": "ok" },
    "storage":  { "status": "ok" },
    "upstream": { "status": "unmeasured", "detail": "no token configured" }
  }
}

Three rules, each because the opposite shipped:

  • status reflects the checks. An endpoint returning ok while a binding is unreachable is worse than none, because it is trusted.
  • A check that could not run is unmeasured, never ok. Could-not-ask is not it-worked.
  • Identity is part of health. A failed upload leaves the previous worker serving, healthy, and entirely the old site. Liveness cannot tell those apart; identity can.

Promotion

Two scripts. Everything else is Cloudflare.

npm run deploy:stage

  1. Refuse if the working tree is dirty, naming the files.
  2. Refuse if local is behind its remote, naming how far. This is where the merge rule is enforced — not by instruction.
  3. Bump the version, commit, push main.
  4. Fast-forward staging to that commit and push.

Cloudflare builds and deploys the staging worker. No tests run here, on purpose: staging must stay fast or UI iteration routes around it, and a staging environment nobody uses protects nothing.

npm run deploy:prod

  1. Wait for staging to actually be the new build. Poll its health endpoint until the version matches and builtAt is fresh. Both — a matching version alone can be answered by the build being replaced.
  2. Read the sha from that response. That is what gets promoted — not whatever staging points at now. Between the test and the promotion someone can push, and promoting the branch would promote an untested commit.
  3. Run the E2E suite against the deployed staging URL. Real browser, real session.
  4. On green, fast-forward production to the sha from step 2 and push.
  5. Verify production’s health endpoint reports that version. Fail loudly if not.

On red, stop and print the failures; production does not move. The suite is not a gate in front of the deploy — it is the mechanism by which promotion happens, which is why it cannot be skipped.

Emergency override exists and is loud. --force-promote skips step 3, prints a banner, and records who and why in the commit message. Staging will be broken at a bad moment eventually; the answer is a documented noisy door, not a silent flag.

What this removes

Developers need no Cloudflare access. Production secrets live on Cloudflare; the only credential anyone needs is GitHub push access. As of 2026-09-11 only three of nine projects had Cloudflare credentials on any reachable machine — six could not be deployed by anyone. This makes that irrelevant rather than requiring nine credential distributions.

It also removes deploy scripts that authenticate implicitly and fail confusingly when they cannot. One project burned eight full rebuilds waiting for a credential that was never going to appear.

Branch protection

production is fast-forward only, no force-push, linear history. Even where direct pushes are possible, linear history means git log production exposes anything that skipped the process. The branch is an audit trail: every entry is a candidate that passed.

Pre-commit hooks

A pre-commit hook must not run a build. Builds need cloud credentials in this stack, so a machine without them cannot commit at all — not a gate, a lockout, and it happened. Pre-commit runs unit tests. Build and E2E belong downstream, where the thing tested is a real deployment rather than a local approximation.