production-release-gating · v1.0.0 · 2026-06-25 · sha256 5544b89c79bbd6cc

production-release-gating v1.0.0A

Immutable. This exact content never changes and is served at /api/v1/blob/5544b89c79bbd6cc.

---
name: production-release-gating
description: "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`, or triggering a deploy from a published GitHub Release (GKE, self-hosted, dispatchable targets). Covers the two patterns — release-event-driven deploy (`on: release`, `types: [published]`) and the Vercel `ignoreCommand` script — when to use which, branch-aware previews, and monorepo dependency-release handling."
metadata:
  author: stealth-engine
  version: "1.0.0"
---

# Gating production deploys to real releases

By default, a push/merge to `main` deploys to production. With
[semantic-release](../semantic-release-automation/SKILL.md) 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 two ways to enforce that — pick by **who deploys**.

| | **Pattern A — release-event-driven** | **Pattern B — Vercel `ignoreCommand`** |
| --- | --- | --- |
| Who deploys | a workflow **you** control (GKE, self-hosted, anything dispatchable) | the **platform** auto-deploys every push (Vercel/Netlify git integration) |
| The gate | nothing deploys on push; a **published GitHub Release** triggers the deploy | a script tells the platform to **skip** the build unless it's a release commit |
| GitHub Release role | the **trigger** (and a live deploy-status surface) | a record |
| Template | [`templates/release-production.yml`](./templates/release-production.yml) | [`templates/vercel-ignore.sh`](./templates/vercel-ignore.sh) |

**Decision rule:** does your platform force-deploy on every push? → Pattern B.
Otherwise (you own the deploy workflow) → Pattern A — it's the cleaner model.

## Pattern A — 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.

- **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.)
- `concurrency` with `cancel-in-progress: false` so a rollout is never half-killed.

## Pattern B — 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`](./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 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.
- **Pattern A needs a non-default token** on the release side, or the Release won't
  trigger the deploy (silent no-op).
- **Don't gate in two places at once.** Pick A or B per app; doubling up makes "why
  didn't it deploy?" much harder to debug.
- **`[skip ci]` in the release commit** (the monorepo semantic-release flavor) means
  push-triggered workflows won't see it — which is exactly why Pattern A keys on the
  *Release event*, not the push.

## See also

- [`semantic-release-automation`](../semantic-release-automation/SKILL.md) — produces
  the release commit / GitHub Release this gate keys on.
- [`conventional-commits`](../conventional-commits/SKILL.md) — why the release commit
  looks like `chore(release): …`.

## Sources

- Generalised from production repos: `cphk` (Pattern A — `on: release` /
  `types: [published]` dispatches a GKE deploy and annotates the Release with status)
  and `piaf-monorepo`
  (Pattern B — 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>