The deploy guard
Connect a repository to Workers Builds, Vercel, Netlify or Amplify and you get a build on every push. What you also get, unless you go and change it, is every branch deploying to the same place — the place your users are.
The setting exists. It is one click. It is off in the direction that surprises you.
What it costs when nobody notices
Section titled “What it costs when nobody notices”This is not hypothetical; it is the afternoon that produced this page. CommitCycle’s own control plane went to production and, in a single day, a branch push reached the live domain four times:
- Unmerged, half-finished work served the real domain for an hour.
- A fix arrived in production before it had been reviewed.
- A security measurement — is the rate limiter working? — was taken against a build that a parallel session had replaced underneath it, and read as a broken limiter that was not broken.
- A deploy silently dropped configuration that only existed on an unmerged branch.
Each was found by a person noticing something odd, which is the slowest and most expensive detector there is.
The rule
Section titled “The rule”cycle guard-deploy && your-deploy-commandPut it in front of whatever you already run. It answers in one of three ways:
| Outcome | Exit | What happened |
|---|---|---|
| deploy | 0 |
You are on the trunk. Your deploy command runs. |
| skip | 2 |
You are on a branch. Nothing deployed, and the build does not go red. |
| refuse | 1 |
Nobody could say which branch this is. Loud, on purpose. |
One deliberate exception to skip: a branch whose HEAD is exactly the freshly fetched trunk tip deploys. It is the trunk’s own commit — refusing the same artifact over the ref name teaches people to delete the guard.
A wrapper that understands all three:
{ "scripts": { "deploy": "cycle guard-deploy; case $? in 0) wrangler deploy ;; 2) exit 0 ;; *) exit 1 ;; esac" }}The guard has to exist before it can guard
Section titled “The guard has to exist before it can guard”Obvious in hindsight, and it cost this project two red builds: the deploy runs
in a clean checkout, and if cycle is not installed there, the wrapper sees
“command not found” and — with a catch-all that treats anything unexpected as a
failure — turns a working deploy pipeline red.
If CC is a dependency of the project being deployed, install it in the build step. In a monorepo that builds only part of itself, make sure the part containing the CLI is one of them. And write the wrapper so the three outcomes are distinguishable in the log:
cycle guard-deploycase $? in 0) your-deploy-command ;; 2) echo "nothing deployed (not the trunk)" ;; *) echo "deploy guard could not run — deploying nothing" >&2; exit 1 ;;esacA guard that breaks deploys is worse than the default it replaced.
Why skip rather than fail
Section titled “Why skip rather than fail”Not deploying a branch is the rule, not a failure. A build that goes red on every pull request teaches a team to ignore red builds, and then the one that matters is ignored too. Exit 2 says “nothing to do here” without pretending something went wrong.
The single loud case is “I could not tell which branch this is.” Everywhere else in CC, not knowing means doing nothing quietly. Here it means the environment is not the one this guard was written for — and a silent no-op would hide that from whoever set it up.
When you mean it
Section titled “When you mean it”Preview deploys are legitimate. They are opted into out loud:
CC_ALLOW_BRANCH_DEPLOY=1 pnpm run deployA door you have to type is a different thing from a door that was never closed.
Where it gets the branch
Section titled “Where it gets the branch”In this order, first answer wins:
CC_DEPLOY_BRANCH (explicit) → WORKERS_CI_BRANCH (Cloudflare) → GITHUB_REF_NAME → VERCEL_GIT_COMMIT_REF → BRANCH (Netlify, Amplify, Render) → CI_COMMIT_REF_NAME (GitLab) → BUILDKITE_BRANCH → local git.
One name per platform, deliberately — a provider nobody added answers nothing, and nothing refuses rather than guesses.
A detached HEAD counts as no branch. CI checkouts usually are detached, and a deploy recorded under the name HEAD is a deploy nobody can trace afterwards.
Your trunk is main unless you say otherwise with CC_PRODUCTION_BRANCH.
cycle doctor tells you before it costs you
Section titled “cycle doctor tells you before it costs you”You do not have to know this page exists. cycle doctor reads the deploy-shaped scripts in your repository and warns when one can publish from anywhere:
[ warn ] deploy guard 1 deploy script(s) can publish from any branch: deploy → Most hosts build every branch into the same place by default, so a push to a feature branch becomes a production deploy. Put the guard in front: "deploy": "cycle guard-deploy && <your deploy command>"It warns, it does not fail. CC cannot know that a given script is wired to a deploy-on-push trigger, and a check that cries wolf is a check somebody disables.
Knowing what is actually live
Section titled “Knowing what is actually live”The other half of the same problem: when four builds have raced each other, “which code is in production right now?” should not be answered by poking the API and inferring from behaviour.
curl https://your-worker.example.com/health{ "ok": true, "protocol": 1, "ref": "main", "commit": "0ba2afc1", "started_at": "…" }ref and commit are stamped at deploy time. A build nobody stamped reports null — absent is honest, and an endpoint that invented a name would be worse than one that admits it does not know.
Pin it in the dashboard as well
Section titled “Pin it in the dashboard as well”The guard is the net. The setting is the floor, and it costs a minute:
Cloudflare Workers Builds — Workers & Pages → your Worker → Settings → Builds. Set Production branch to main, and decide what non-production branches do: Preview deployments sends them to their own URL, Disabled stops them building at all. Either is fine; what is not fine is the default, where every branch lands on production.
Vercel — Settings → Git → Production Branch. Other branches become Preview deployments automatically.
Netlify — Site configuration → Build & deploy → Branches and deploy contexts. Set the production branch, and choose whether branch deploys are published at all.
Amplify — App settings → Branch settings, where each branch is connected explicitly. The trap there is connecting more than you meant to.
Do both. They fail differently: the dashboard setting can be changed by anyone with access to the dashboard and leaves no trace in your repository, and the guard travels with the code and is reviewed like code. Neither alone tells you the other has drifted.