Gating production deploys to real releases
By default, a push/merge to main deploys to production. With
semantic-release you usually want the
opposite: previews on feature branches, but production only on an actual
release (a versioned chore(release): commit / a published GitHub Release).
There are three ways to enforce that. They have names, not letters, so you can
pick one by name and record it (see "pick the flow" below):
|
Release-Triggered Deploy |
Promotion Branch |
Build-Skip Gate |
| One-liner |
CI runs the deploy when a Release publishes |
a release fast-forwards a production branch the platform watches |
a script cancels non-release builds on the platform |
| Who deploys |
a workflow you control (GKE, self-hosted, dispatchable, or a platform CLI) |
the platform's native git integration, but only off production |
the platform auto-builds every push; the script vetoes |
| The gate |
a published Release triggers the deploy; plain pushes deploy nothing |
production only advances on a published Release; main = staging |
the script skips the build unless it's a release commit |
| Portability |
any dispatchable target (must disable native push-deploy — see below) |
any host with a configurable production branch (Vercel, Netlify, CF Pages) — most portable |
Vercel/Netlify only (platform-specific script) |
| Template |
release-production.yml |
promote-to-production.yml |
vercel-ignore.sh |
Decision rule:
- Want to keep the platform's native git deploys with zero platform-specific
scripting? → Promotion Branch (set the production branch, promote on release).
Most portable; the recommended default for Vercel/Netlify/CF Pages.
- You own the deploy (GKE/self-hosted/any dispatchable target, or you want CI to
drive a platform CLI)? → Release-Triggered Deploy.
- Locked into a platform's push-deploy and can't add a branch/promotion? →
Build-Skip Gate (the ignore script) as the fallback.
Works with both per-merge and pooled releases — pick the flow, then record it
All three gates are orthogonal to release cadence: they key on the
chore(release): commit + tag + published GitHub Release, which is produced
identically whether you release on every merge
(semantic-release-automation) or in
batches (pooled-release). So the same gate works
under either workflow — you don't re-pick it if you later switch cadence.
Because the gate is a lasting repo convention (and double-gating is a real
footgun — see Gotchas), when wiring this up for a project:
- Confirm the flow with the user — don't assume. Which pattern (Release-Triggered
Deploy / Promotion Branch / Build-Skip Gate)? Which branch is production? Which
platform? Per-merge or pooled release trigger?
- Record the decision so it's durable and visible. Write a short line in the
repo's
AGENTS.md (and/or the deploy workflow header), e.g.
"Prod gating: Promotion Branch — production branch production, platform Vercel,
pooled release." Future agents and humans then follow the same flow instead of
re-deriving — or silently contradicting — it.
Release-Triggered Deploy — deploy on a published Release
semantic-release creates a GitHub Release; this workflow fires on on: release
with types: [published] and rolls out github.event.release.tag_name, then
writes the deploy status back onto the Release body. Plain pushes to main deploy
nothing.
- The deploy step is whatever command you control — that's the point of "you
deploy." The template shows a GKE/
kubectl rollout, but the same job can call a
platform CLI (vercel deploy --prod --prebuilt, netlify deploy --prod), hit
a deploy hook (curl "$DEPLOY_HOOK_URL"), wrangler deploy, flyctl deploy,
etc. The gate (fire only on the published Release) is identical; only the rollout
command differs.
- vs the platform's own git deploy: use this when you want CI to own the prod
deploy. If you'd rather keep the platform's native git integration and just gate
which branch it watches, use Promotion Branch instead.
- ⚠️ If the platform has native git auto-deploy (Vercel/Netlify), DISABLE its
default-branch prod deploy — or you double-ship. This CI deploy is in addition
to the platform's automatic push deploy, so without this the platform still ships
prod on every merge to
main. Two ways:
- Vercel —
vercel.json:
{ "git": { "deploymentEnabled": { "main": false } } } stops Vercel deploying
main (other branches still get previews; CI's --prebuilt --prod is unaffected).
Or Project → Settings → Git → turn off the production branch's auto-deploy.
- Netlify —
netlify.toml [context.production] command = "exit 0" (or "Stop
auto publishing" / lock the production deploy) so pushes to the prod branch don't
auto-build; CLI deploys still publish.
- Or reuse the Build-Skip Gate inverted: point
vercel-ignore.sh
at this app but make main always skip (CI owns prod) while feature branches
still build previews — the same script, the opposite verdict on main.
- Token caveat: a Release created with the built-in
GITHUB_TOKEN will not
trigger on: release. Have semantic-release run with a PAT/bot GH_TOKEN so
its Release fires this workflow. (See semantic-release-automation → token notes.)
- Pre-releases don't deploy: the deploy job is guarded with
if: ${{ !github.event.release.prerelease }}, so semantic-release next/beta
channel releases (published with prerelease: true) are skipped — only a stable
Release ships to prod.
- Tag shape is validated: the deploy step refuses a
tag_name that isn't SemVer
(v?MAJOR.MINOR.PATCH), so a stray/mis-shaped tag can't roll out. Adjust the regex
to your scheme.
concurrency is a global group (prod-release, not per-tag) with
cancel-in-progress: false, so deploys never run concurrently and an in-progress
rollout is never half-killed. Caveat: GitHub keeps only one pending run per
group, so a newer release can evict an older queued one — if every tag must
deploy, add an external queue/lock rather than relying on concurrency alone.
Promotion Branch — staging main, fast-forward production
The most portable gate: keep the platform's native git deploys, but point its
Production Branch at a dedicated production branch instead of main. Now main
is a preview/staging branch (deploys on every merge, but not to prod), and prod ships
only when production advances. templates/promote-to-production.yml
fires on the published Release and fast-forwards production to the released
commit; the platform's webhook then deploys it. No platform CLI, no ignore script —
just a branch update, so it works on Vercel, Netlify, Cloudflare Pages, anything with
a configurable production branch.
- Setup is one-time: create
production off main, set it as the platform's
Production Branch, and run semantic-release with a PAT/bot GH_TOKEN (same
token caveat as Release-Triggered Deploy — the built-in token's Release won't fire
on: release).
production only ever fast-forwards from main, so it stays an ancestor of the
release commit and the promotion push is always a clean fast-forward. A rejected
(non-fast-forward) push is a real divergence to inspect — never --force past it.
- Ancestry is enforced, not assumed: before pushing, the workflow fetches the
default branch and runs
git merge-base --is-ancestor "$SHA" origin/<default>,
refusing to promote a tag whose commit isn't on the default branch. This stops an
off-main or unrelated tag from shipping arbitrary code to prod. It fetches into the
same ref it validates (origin <branch>:refs/remotes/origin/<branch>). The branch
is auto-resolved from github.event.repository.default_branch, so a non-main
default needs no edit — override DEFAULT_BRANCH only if you cut releases from a
branch other than the repo default.
- Pairs naturally with
pooled-release:
semantic-release runs on main (button/cron) and tags; this promotes the tag to
prod. Merges to main keep shipping staging; prod ships on the train.
Build-Skip Gate — Vercel Ignored Build Step
Vercel runs an Ignored Build Step before building; its exit code decides:
exit 1 = build, exit 0 = skip (note the inversion). Wire
templates/vercel-ignore.sh via vercel.json
("ignoreCommand": "bash ../../scripts/vercel-ignore.sh") or Project Settings →
Git → Ignored Build Step. Logic:
- Feature branch → build (preview deployment). This is the whole point of
previews; don't gate them.
main, release commit (chore(release): … / chore(scope): release …) or
[deploy] marker → build (production).
main, anything else → skip.
[skip-deploy] on any branch → skip.
Monorepo: Vercel's "Skipping Unaffected Projects" is layer 1 (Turborepo graph);
this script is layer 2. A per-app script also builds when a dependency package
releases — add a clause like ^chore\((configs|i18n-routing)\):.*release (shown
commented in the template) so an app redeploys when its shared lib version bumps.
Gotchas
- Exit codes are inverted in the Build-Skip Gate / Vercel ignore step (0 = skip).
The single most common mistake.
- Preview vs prod: keep feature-branch previews ungated; only
main is strict.
Gating previews defeats the workflow.
- Release-Triggered Deploy needs a non-default token on the release side, or the
Release won't trigger the deploy (silent no-op).
- Release-Triggered Deploy on Vercel/Netlify double-deploys unless you disable native
prod auto-deploy. The platform ships
main on every merge and CI ships on the
Release. Turn off the platform's default-branch deploy (Vercel
git.deploymentEnabled.main: false; Netlify stop auto-publishing) so only CI ships
prod. (Not an issue on GKE/self-hosted — nothing auto-deploys there.)
- Don't gate in two places at once. Pick one pattern per app; doubling up
(e.g. a
production branch and an ignore script) makes "why didn't it deploy?"
much harder to debug.
- Promotion Branch: don't leave
main as the platform's production branch. The
whole gate is moving the Production Branch to production; forget that step and
every merge to main still ships to prod.
[skip ci] in the release commit (the monorepo semantic-release flavor) means
push-triggered workflows won't see it — which is exactly why Release-Triggered Deploy
keys on the Release event, not the push.
- Pre-releases must not reach prod. Both the deploy and promote jobs gate on
if: ${{ !github.event.release.prerelease }}; without it, a next/beta Release
would ship to production. Keep the guard if you adapt these.
- Supply chain — pin actions to commit SHAs for a hardened posture. These
workflows run with
contents: write, and @v7/@v9 are mutable tags — even
first-party actions/* tags can be re-pointed — so for strict supply-chain safety
pin every uses: (including actions/checkout/actions/github-script, not just
third-party like pnpm/action-setup) to a full commit SHA with a # vX.Y.Z comment,
and let Dependabot bump them. The templates ship readable major tags as the
convenient default; tighten to SHAs where the risk warrants.
See also
Sources
- Generalised from production repos:
cphk (Release-Triggered Deploy — on: release /
types: [published] dispatches a GKE deploy and annotates the Release with status)
and piaf-monorepo
(Build-Skip Gate — per-app vercel.json ignoreCommand → vercel-ignore-<app>.sh
with branch/release/dependency-aware exit codes).
- Vercel Ignored Build Step: https://vercel.com/docs/projects/overview#ignored-build-step
- Promotion Branch builds on each platform's configurable production branch
primitive: Vercel (Project → Settings → Git → Production Branch) and Netlify (Site
configuration → Build & deploy → Branches → Production branch). Promoting via a
fast-forward of
production is a generalisation of that native feature, so the gate
stays platform-agnostic.
1---2name: production-release-gating3description: Stop every push/merge to main from shipping to production — deploy only on a real release. Use when a merge to main unexpectedly deploys to prod, when you want production to deploy only on a semantic-release version (not every commit), when setting up preview-on-feature-branch but gated-prod-on-main, wiring a Vercel Ignored Build Step / `ignoreCommand`, promoting to a staging/`production` branch, or triggering a deploy from a published GitHub Release (GKE, self-hosted, dispatchable targets). Covers three named patterns — Release-Triggered Deploy (`on: release`, `types: [published]`), Promotion Branch (staging/`production`, most portable), and Build-Skip Gate (the Vercel `ignoreCommand` script) — when to use which, works with both per-merge and pooled releases, branch-aware previews, and monorepo dependency-release handling.4---56# Gating production deploys to real releases78By default, a push/merge to `main` deploys to production. With9[semantic-release](../semantic-release-automation/SKILL.md) you usually want the10opposite: **previews on feature branches, but production only on an actual11release** (a versioned `chore(release):` commit / a published GitHub Release).1213There are three ways to enforce that. They have **names, not letters**, so you can14pick one by name and record it (see "pick the flow" below):1516| | **Release-Triggered Deploy** | **Promotion Branch** | **Build-Skip Gate** |17| --- | --- | --- | --- |18| One-liner | CI runs the deploy when a Release publishes | a release fast-forwards a `production` branch the platform watches | a script cancels non-release builds on the platform |19| Who deploys | a workflow **you** control (GKE, self-hosted, dispatchable, or a platform CLI) | the platform's **native git integration**, but only off `production` | the **platform** auto-builds every push; the script vetoes |20| The gate | a **published Release** triggers the deploy; plain pushes deploy nothing | `production` only **advances on a published Release**; `main` = staging | the script **skips** the build unless it's a release commit |21| Portability | any dispatchable target (must disable native push-deploy — see below) | **any host with a configurable production branch** (Vercel, Netlify, CF Pages) — most portable | **Vercel/Netlify only** (platform-specific script) |22| Template | [`release-production.yml`](./templates/release-production.yml) | [`promote-to-production.yml`](./templates/promote-to-production.yml) | [`vercel-ignore.sh`](./templates/vercel-ignore.sh) |2324**Decision rule:**25- Want to keep the platform's **native git deploys** with **zero platform-specific26 scripting**? → **Promotion Branch** (set the production branch, promote on release).27 Most portable; the recommended default for Vercel/Netlify/CF Pages.28- **You** own the deploy (GKE/self-hosted/any dispatchable target, or you *want* CI to29 drive a platform CLI)? → **Release-Triggered Deploy**.30- Locked into a platform's push-deploy and **can't** add a branch/promotion? →31 **Build-Skip Gate** (the ignore script) as the fallback.3233## Works with both per-merge and pooled releases — pick the flow, then record it3435All three gates are **orthogonal to release cadence**: they key on the36`chore(release):` commit + tag + published GitHub Release, which is produced37identically whether you release on **every merge**38([`semantic-release-automation`](../semantic-release-automation/SKILL.md)) or in39**batches** ([`pooled-release`](../pooled-release/SKILL.md)). So the same gate works40under either workflow — you don't re-pick it if you later switch cadence.4142Because the gate is a **lasting repo convention** (and double-gating is a real43footgun — see Gotchas), when wiring this up for a project:44451. **Confirm the flow with the user — don't assume.** Which pattern (Release-Triggered46 Deploy / Promotion Branch / Build-Skip Gate)? Which branch is production? Which47 platform? Per-merge or pooled release trigger?482. **Record the decision so it's durable and visible.** Write a short line in the49 repo's `AGENTS.md` (and/or the deploy workflow header), e.g.50 *"Prod gating: Promotion Branch — production branch `production`, platform Vercel,51 pooled release."* Future agents and humans then follow the same flow instead of52 re-deriving — or silently contradicting — it.5354## Release-Triggered Deploy — deploy on a published Release5556semantic-release creates a GitHub Release; this workflow fires on `on: release`57with `types: [published]` and rolls out `github.event.release.tag_name`, then58writes the deploy status back onto the Release body. Plain pushes to `main` deploy59nothing.6061- **The deploy step is whatever command you control** — that's the point of "you62 deploy." The template shows a GKE/`kubectl` rollout, but the same job can call a63 **platform CLI** (`vercel deploy --prod --prebuilt`, `netlify deploy --prod`), hit64 a **deploy hook** (`curl "$DEPLOY_HOOK_URL"`), `wrangler deploy`, `flyctl deploy`,65 etc. The gate (fire only on the published Release) is identical; only the rollout66 command differs.67- **vs the platform's own git deploy:** use this when you want CI to *own* the prod68 deploy. If you'd rather keep the platform's **native** git integration and just gate69 which branch it watches, use **Promotion Branch** instead.70- **⚠️ If the platform has native git auto-deploy (Vercel/Netlify), DISABLE its71 default-branch prod deploy — or you double-ship.** This CI deploy is in *addition*72 to the platform's automatic push deploy, so without this the platform still ships73 prod on every merge to `main`. Two ways:74 - **Vercel** — `vercel.json`:75 `{ "git": { "deploymentEnabled": { "main": false } } }` stops Vercel deploying76 `main` (other branches still get previews; CI's `--prebuilt --prod` is unaffected).77 Or Project → Settings → Git → turn off the production branch's auto-deploy.78 - **Netlify** — `netlify.toml` `[context.production] command = "exit 0"` (or "Stop79 auto publishing" / lock the production deploy) so pushes to the prod branch don't80 auto-build; CLI deploys still publish.81 - **Or reuse the Build-Skip Gate inverted:** point [`vercel-ignore.sh`](./templates/vercel-ignore.sh)82 at this app but make `main` **always skip** (CI owns prod) while feature branches83 still build previews — the same script, the opposite verdict on `main`.84- **Token caveat:** a Release created with the built-in `GITHUB_TOKEN` will **not**85 trigger `on: release`. Have semantic-release run with a **PAT/bot `GH_TOKEN`** so86 its Release fires this workflow. (See `semantic-release-automation` → token notes.)87- **Pre-releases don't deploy:** the deploy job is guarded with88 `if: ${{ !github.event.release.prerelease }}`, so semantic-release `next`/`beta`89 channel releases (published with `prerelease: true`) are skipped — only a **stable**90 Release ships to prod.91- **Tag shape is validated:** the deploy step refuses a `tag_name` that isn't SemVer92 (`v?MAJOR.MINOR.PATCH`), so a stray/mis-shaped tag can't roll out. Adjust the regex93 to your scheme.94- `concurrency` is a **global** group (`prod-release`, not per-tag) with95 `cancel-in-progress: false`, so deploys never run concurrently and an in-progress96 rollout is never half-killed. **Caveat:** GitHub keeps only one *pending* run per97 group, so a newer release can evict an older **queued** one — if every tag must98 deploy, add an external queue/lock rather than relying on concurrency alone.99100## Promotion Branch — staging `main`, fast-forward `production`101102The most **portable** gate: keep the platform's native git deploys, but point its103**Production Branch at a dedicated `production` branch** instead of `main`. Now `main`104is a preview/staging branch (deploys on every merge, but not to prod), and prod ships105only when `production` advances. [`templates/promote-to-production.yml`](./templates/promote-to-production.yml)106fires on the published Release and **fast-forwards `production` to the released107commit**; the platform's webhook then deploys it. No platform CLI, no ignore script —108just a branch update, so it works on Vercel, Netlify, Cloudflare Pages, anything with109a configurable production branch.110111- **Setup is one-time:** create `production` off `main`, set it as the platform's112 Production Branch, and run semantic-release with a **PAT/bot `GH_TOKEN`** (same113 token caveat as Release-Triggered Deploy — the built-in token's Release won't fire114 `on: release`).115- **`production` only ever fast-forwards** from `main`, so it stays an ancestor of the116 release commit and the promotion push is always a clean fast-forward. A rejected117 (non-fast-forward) push is a real divergence to inspect — **never `--force` past it.**118- **Ancestry is enforced, not assumed:** before pushing, the workflow fetches the119 default branch and runs `git merge-base --is-ancestor "$SHA" origin/<default>`,120 refusing to promote a tag whose commit isn't on the default branch. This stops an121 off-main or unrelated tag from shipping arbitrary code to prod. It fetches into the122 **same ref it validates** (`origin <branch>:refs/remotes/origin/<branch>`). The branch123 is auto-resolved from `github.event.repository.default_branch`, so a non-`main`124 default needs no edit — override `DEFAULT_BRANCH` only if you cut releases from a125 branch *other* than the repo default.126- **Pairs naturally with [`pooled-release`](../pooled-release/SKILL.md):**127 semantic-release runs on `main` (button/cron) and tags; this promotes the tag to128 prod. Merges to `main` keep shipping staging; prod ships on the train.129130## Build-Skip Gate — Vercel Ignored Build Step131132Vercel runs an **Ignored Build Step** before building; its exit code decides:133**`exit 1` = build, `exit 0` = skip** (note the inversion). Wire134[`templates/vercel-ignore.sh`](./templates/vercel-ignore.sh) via `vercel.json`135(`"ignoreCommand": "bash ../../scripts/vercel-ignore.sh"`) or Project Settings →136Git → Ignored Build Step. Logic:137138- **Feature branch →** build (preview deployment). This is the whole point of139 previews; don't gate them.140- **`main`, release commit** (`chore(release): …` / `chore(scope): release …`) **or141 `[deploy]` marker →** build (production).142- **`main`, anything else →** skip.143- **`[skip-deploy]`** on any branch → skip.144145**Monorepo:** Vercel's "Skipping Unaffected Projects" is layer 1 (Turborepo graph);146this script is layer 2. A per-app script also builds when a **dependency package**147releases — add a clause like `^chore\((configs|i18n-routing)\):.*release` (shown148commented in the template) so an app redeploys when its shared lib version bumps.149150## Gotchas151152- **Exit codes are inverted** in the Build-Skip Gate / Vercel ignore step (0 = skip).153 The single most common mistake.154- **Preview vs prod:** keep feature-branch previews ungated; only `main` is strict.155 Gating previews defeats the workflow.156- **Release-Triggered Deploy needs a non-default token** on the release side, or the157 Release won't trigger the deploy (silent no-op).158- **Release-Triggered Deploy on Vercel/Netlify double-deploys unless you disable native159 prod auto-deploy.** The platform ships `main` on every merge *and* CI ships on the160 Release. Turn off the platform's default-branch deploy (Vercel161 `git.deploymentEnabled.main: false`; Netlify stop auto-publishing) so only CI ships162 prod. (Not an issue on GKE/self-hosted — nothing auto-deploys there.)163- **Don't gate in two places at once.** Pick **one** pattern per app; doubling up164 (e.g. a `production` branch *and* an ignore script) makes "why didn't it deploy?"165 much harder to debug.166- **Promotion Branch: don't leave `main` as the platform's production branch.** The167 whole gate is moving the Production Branch to `production`; forget that step and168 every merge to `main` still ships to prod.169- **`[skip ci]` in the release commit** (the monorepo semantic-release flavor) means170 push-triggered workflows won't see it — which is exactly why Release-Triggered Deploy171 keys on the *Release event*, not the push.172- **Pre-releases must not reach prod.** Both the deploy and promote jobs gate on173 `if: ${{ !github.event.release.prerelease }}`; without it, a `next`/`beta` Release174 would ship to production. Keep the guard if you adapt these.175- **Supply chain — pin actions to commit SHAs for a hardened posture.** These176 workflows run with `contents: write`, and `@v7`/`@v9` are **mutable tags** — even177 first-party `actions/*` tags can be re-pointed — so for strict supply-chain safety178 pin **every** `uses:` (including `actions/checkout`/`actions/github-script`, not just179 third-party like `pnpm/action-setup`) to a full commit SHA with a `# vX.Y.Z` comment,180 and let Dependabot bump them. The templates ship readable major tags as the181 convenient default; tighten to SHAs where the risk warrants.182183## See also184185- [`semantic-release-automation`](../semantic-release-automation/SKILL.md) — produces186 the release commit / GitHub Release this gate keys on.187- [`pooled-release`](../pooled-release/SKILL.md) — batched releases; the same gate188 ships prod on the train, not on merges.189- [`conventional-commits`](../conventional-commits/SKILL.md) — why the release commit190 looks like `chore(release): …`.191192## Sources193194- Generalised from production repos: `cphk` (Release-Triggered Deploy — `on: release` /195 `types: [published]` dispatches a GKE deploy and annotates the Release with status)196 and `piaf-monorepo`197 (Build-Skip Gate — per-app `vercel.json` `ignoreCommand` → `vercel-ignore-<app>.sh`198 with branch/release/dependency-aware exit codes).199- Vercel Ignored Build Step: <https://vercel.com/docs/projects/overview#ignored-build-step>200- Promotion Branch builds on each platform's configurable **production branch**201 primitive: Vercel (Project → Settings → Git → Production Branch) and Netlify (Site202 configuration → Build & deploy → Branches → Production branch). Promoting via a203 fast-forward of `production` is a generalisation of that native feature, so the gate204 stays platform-agnostic.