---
title: "The deploy guard: no branch reaches production | CommitCycle"
description: "Every hosted platform builds every branch into the same place by default. One command in front of your deploy makes a branch unable to reach production."
url: https://commitcycle.com/docs/operations/deploy-guard/
language: en
---

# The deploy guard

> Every hosted platform builds every branch into the same place by default. One command in front of your deploy makes a branch unable to reach production.

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

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

```bash
cycle guard-deploy && your-deploy-command
```

Put 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, its HEAD is the tip of `origin` fetched just now, and the working tree is clean. Your deploy command runs.           |
| **skip**   | `2`  | You are on a branch — or on the trunk while it is ahead of, behind or diverged from `origin`. Nothing deployed.                            |
| **refuse** | `1`  | Nobody could say which branch this is, or the working tree holds uncommitted or untracked changes the deploy would ship. 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. When the fetch cannot run — no remote, or offline — the branch name decides alone.

With a bare `&&`, a skip fails the build: `&&` treats the `2` as an error, so the build goes red for obeying the rule. A wrapper that understands all three, and maps a skip to success:

```json
{
  "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

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:

```bash
cycle guard-deploy
case $? in
  0) your-deploy-command ;;
  2) echo "nothing deployed (not the trunk)" ;;
  *) echo "deploy guard could not run — deploying nothing" >&2; exit 1 ;;
esac
```

A guard that breaks deploys is worse than the default it replaced.

### 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 first 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.

The second is a working tree that does not match HEAD (CC-1249). The checks compare commits, and a deploy bundles files: a checkout on the trunk’s tip with an uncommitted edit, or an untracked source file, would ship bytes `origin` does not hold. Commit and push them, or stash them, and deploy again. Ignored files, where build output lives, do not count.

### When you mean it

Preview deploys are legitimate. They are opted into out loud:

```bash
CC_ALLOW_BRANCH_DEPLOY=1 pnpm run deploy
```

A door you have to type is a different thing from a door that was never closed.

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

**`CC_DEPLOY_BRANCH` can never make a build the trunk when the build itself says otherwise.** It fills a gap: a platform the guard does not know, or a detached checkout. It can also name any non-trunk branch, which only ever makes a build less deployable. But if it names the trunk while the platform’s variable or local git names another branch, the build’s own answer stands, nothing deploys, and the log names both answers. This matters because most hosts that build every branch apply one set of build variables to all of them. `CC_DEPLOY_BRANCH=main` set there would otherwise call every branch build `main`.

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

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:

```plaintext
[ 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

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.

```bash
curl https://your-worker.example.com/health
```

```json
{ "ok": true, "protocol": 1, "min_protocol": 1, "latest": "0.1.25", "started_at": "…", "ref": "main", "commit": "0ba2afc1" }
```

That is CommitCycle’s own board (`apps/api/src/routes.ts`); `latest` is the newest published CLI. `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

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.

A control that depends on somebody remembering a checkbox is not a control. The day the checkbox is wrong — a new Worker, a migrated project, a colleague who reconnected the repo — you would rather find out from a skipped deploy than from your users.
