Branch and deploy convention
The convention
main— default branch. Deploys the staging environment.release— deploys production. Promoted by mergingmain→release.
staging and production are environment names. They are never branch names.
A branch called staging that deploys production is worse than the name it
replaced, because it invites a "safe" push to the live site.
Write the mapping in a comment at the top of every deploy workflow, so the next reader does not have to infer it:
# main = staging, release = production.
# Merge main -> release to ship.
on:
push:
branches: [main, release]
The defect this exists to prevent
A GitHub Actions branches: filter and a github.ref comparison are bound to a
literal branch name. Neither follows a rename, and neither errors when the
name does not exist. The workflow simply never runs. Nothing turns red. The job
list just gets shorter, and no one notices for months.
Real examples found in one org sweep of 127 repos:
| Repo | Filter named | Reality | Consequence |
|---|---|---|---|
| a personal site | release |
no such branch | production deploy: 0 runs, ever |
| an app | [main, release] |
release missing |
78 runs, all staging; production never deployed |
| two repos | main |
no main branch |
lint ran on pull requests only, silently |
| a lint config | master |
renamed to main |
push half dead |
A green check list is not proof a trigger fired. gh pr checks shows what
ran, not what should have. Verify with a run list instead:
gh run list --repo OWNER/REPO --branch main --limit 10 \
--json workflowName,event,status
Sweeping an org for it
Parse each workflow's on.push / on.pull_request branches and diff against the
repo's real branch list. Run it in parallel — serial gh calls over 127 repos
take 40 minutes, 10-way xargs takes two.
gh repo list OWNER --limit 300 --json name,isArchived \
--jq '.[]|select(.isArchived|not)|.name' > repos.txt
xargs -P 10 -n 1 ./audit-one.sh < repos.txt > audit.jsonl
Per repo, audit-one.sh emits the real branch list, the default branch, and every
deploy workflow's push filter. Then look for a filter naming a branch that is not
in the list. Skip glob patterns (*, ?, !) — they are not literal names.
Classify a "deploy" by the command, not the filename. Grep for
wrangler deploy, pages deploy, netlify deploy, vercel, npm run deploy:,
and exclude --dry-run. A repo publishing to npm on push to release follows the
convention correctly even though nothing calls it a deploy.
Renaming safely
Order matters, and getting it wrong is how a deploy lands in the wrong place.
- Fix the config first, in a PR, and merge it. Every
branches:filter, everygithub.refgate,dependabot.ymltarget-branch, and the branch model in the README and CLAUDE.md. Rename first and the filters go dead in the gap. - Rename with the API, never by deleting and recreating. This preserves
history, redirects the old name, and retargets open PRs:
Never force-push. Never delete a branch to "rename" it.gh api -X POST repos/OWNER/REPO/branches/OLD/rename -f new_name=NEW - Rename the branch with no live deploy first, so the risky one is last and you have already seen the checklist work end to end.
- Verify by observing runs, per the run-list command above. Confirm both the staging and the production job appear on their respective branches.
Check the gate mapping explicitly before and after. Read the actual if:
lines and confirm each branch still reaches the environment it reached before:
grep -n -A5 'name: Deploy to' .github/workflows/deploy.yml
Watch for the else-fallback shape, which is fragile:
# Any branch that is not main deploys PRODUCTION. One filter edit away from bad.
CF_ENV: ${{ github.ref_name == 'main' && 'staging' || 'production' }}
Prefer an explicit gate per environment, so an unexpected branch deploys nothing rather than deploying production.
Four things that make a rename unsafe — check all four first
- A deploy integration bound to the branch by name. Cloudflare Pages, Workers Builds, Netlify and Vercel each store a production branch name and do not follow a GitHub rename. The site does not go down; it silently freezes at the last build, and new pushes become preview builds. Symptom to look for: the live site is older than the branch it deploys from. Repoint the integration in the same change window, or move the deploy into a workflow so the binding lives in the repo where a break is visible.
- Divergent migration history. If two branches each added a migration at the
same index, merging renumbers one of them, and the runner tracks applied
migrations by filename. The database that already ran
0032_foo.sqlwill see0033_foo.sqlas unapplied and run it again. Read the real state before resolving the journal:wrangler d1 execute DB_NAME --remote --command "SELECT name FROM d1_migrations ORDER BY id" - Unprovisioned placeholders. A production environment can be scaffolded but
never created. Grep the config for
<PLACEHOLDER>shapes before creating the branch that deploys it — otherwise you get a guaranteed red first deploy:grep -nE '<[A-Z_]+>' **/wrangler.jsonc - Forks. A fork on
masteris onmasterbecause upstream is. Renaming breaks the fork relationship and every future PR upstream. Tag them instead of touching them:gh api -X PUT repos/OWNER/REPO/topics -f 'names[]=upstream-fork'
Gotchas
- A PR that edits a
branches:filter cannot test itself. For apull_requestevent GitHub reads the filter from the head ref. Rewrite[staging, production]to[main, release]and a PR based onproductionno longer matches its own admission rule, so CI does not run on it. Only workflows with no branch filter still fire. Say so in the PR and verify after merge. gh pr editcan fail and still look fine. It fetches Projects-classic data through a deprecated GraphQL field; on an affected repo it prints an error and leaves the title and body unchanged. Read the PR back, and fall back togh api -X PATCH repos/{owner}/{repo}/pulls/N --input body.json.dependabot.ymltarget-branchis a literal branch name too. It is easy to miss and it fails silently, exactly like a workflow filter.- Repo variables are readable, secrets are not.
gh api repos/O/R/actions/variablesreturns values, so an account id stored as a variable can be copied between repos. An API token stored as a secret cannot — it has to be set by hand. - Two conventions coexisting is the real cost. Before standardising, check
which one the repos already use and follow the majority. Do not assume
mainmeans production.
Delegating the work
The audit is a deterministic script. Give it to xargs, not to a model — a
worker that silently returns "audit complete" with no findings costs more than it
saves. Delegate the bounded text edits (a README notice, a docs rewrite) where a
wrong answer is visible in a diff, and verify the files rather than the
worker's summary.