---
name: cb-release
description: How a Circuit Breaker release is cut, approved, published and followed up — the candidate→approval→promote flow in release.yml, the `release` environment gate, the make release-* targets, the post-release follow-up PR that bumps VERSION and rotates the CHANGELOG, and how to diagnose and recover a failed release. Use this whenever the user asks to tag, release, ship, publish or promote a version, bump VERSION, edit CHANGELOG release headings, touch release.yml or release-followup.yml, clean up draft releases, or when a Release run is red. Never push a v* tag by hand — read this first.
---

# Circuit Breaker — Releasing

A release is **one dispatch and one human approval**. The tag is the *last*
thing that happens, created when the draft is published. Nobody makes a tag
by hand.

## The flow

```
make release-candidate            (from main, HEAD must be on origin)
  └─ release.yml channel=candidate promote=true
       gate → build → artifact-smoke → installer-journey (6 distros)
       → image (amd64+arm64) → runtime-parity → Stage Draft Release
       → Discord: "vX draft staged, waiting for you"
       → release-environment-guard, promote-verify
       → promote  ⏸ waits for approval on the `release` environment
                  (Review deployments on the run page, or GitHub mobile)
       → publish draft = tag created → post-publish verifies the
         published assets, dispatches e2e.yml and release-followup.yml
       → Discord: "vX is published"
release-followup.yml (on dev)
  └─ bumps VERSION to next patch, dates the CHANGELOG heading, opens
     `## [next] — unreleased`, deletes stale drafts ≤ vX, opens a PR
     into dev, dispatches the required checks onto it
```

| Target | Does |
|---|---|
| `make release-candidate` | The normal path: build, gate, stage, then wait for approval and publish |
| `make release-stage-only` | Stop at the draft (`promote=false`), e.g. to soak it for a while |
| `make release-promote` | Publish an already-staged draft (`channel=stable`). Must run on the **same commit** the draft was built from |

Approving is the moment to soak if you want to: `gh release download vX
--pattern '*linux_amd64.tar.gz'` then `install.sh --local-bundle <tarball>
--unattended --no-tls`. The approval can wait days; nothing re-builds.

## Rules that each cost a failed release to learn

1. **Never push a `v*` tag.** A hand-pushed tag runs only `tag-verify`, which
   fails on purpose and prints the delete command. If you created a local tag,
   `git tag -d vX`.
2. **The `release` environment must exist with a required reviewer.** GitHub
   silently creates a missing environment *unprotected* and runs straight
   through, which would publish without anyone approving.
   `release-environment-guard` fails the run in its first minute unless the
   `required_reviewers` rule is there. "Prevent self-review" must stay **off**
   while there is only one maintainer, or nobody can approve.
3. **promote-verify needs `contents: write` although it only reads.** GitHub
   hides draft releases from tokens without push access; with `read`, `gh
   release view` says "release not found" for a draft that exists (v0.4.4, PR
   #161). `test_jobs_that_read_the_draft_can_see_it` guards this.
4. **Promote runs on the candidate's exact commit.** `promote-verify`
   compares `candidate.json`'s `commit` with `GITHUB_SHA`. So a fix to
   release.yml itself cannot promote an existing draft: merge the fix, then
   cut a **new** candidate from the new `main` commit. The candidate job
   deletes and replaces the old draft of the same version.
5. **A tag created by GITHUB_TOKEN fires no `push: tags:` workflow**, and a
   release it publishes fires no `release: published` workflow. That is why
   post-publish *dispatches* e2e.yml and release-followup.yml explicitly —
   `workflow_dispatch` is the one event GITHUB_TOKEN can start.
6. **VERSION is the only hand-edited version** (GOV-09). Everything else is
   generated by `scripts/check_version_parity.py --write` (`make
   version-sync`). The follow-up PR does both for you after each release.
7. **CHANGELOG headings**: released versions read `## [X.Y.Z] — YYYY-MM-DD`
   (UTC publish date); the next one reads `## [X.Y.Z] — unreleased`.
   `scripts/release_checklist.py` requires a `## [VERSION]` heading before a
   draft may be staged. `scripts/post_release_bump.py` does the rotation; a
   version bump must never rename the previous section (that is how the 0.4.3
   notes were lost into 0.4.4).
8. **Prereleases** (`X.Y.Z-rc.N`) have no mechanical next version:
   release-followup fails on them by design. Bump VERSION by hand after an rc.

## Diagnosing a red Release run

Start from the failing job, not the run conclusion:

| Failing job | Usually means | Do |
|---|---|---|
| Derive Version | `version` input ≠ VERSION at that ref | Dispatch on the right ref or fix the input |
| release-environment-guard | Environment missing or lost its reviewer | Settings → Environments → `release` → Required reviewers |
| Verify the candidate before promoting: "has no draft to promote" | No draft for VERSION, or the token cannot see drafts | `gh release list`; check rule 3 |
| … "the draft was built from X" | Promote dispatched on a different commit | Dispatch on the candidate's commit, or cut a new candidate |
| … "no longer matches candidate.json" / digest moved | Draft assets or the `:X-candidate` image changed after staging | Re-run the candidate; never hand-edit a draft |
| Stage Draft Release: "already published" | VERSION was not bumped after the last release | Merge the follow-up PR (or bump VERSION) |
| post-publish | The *published* release is broken (asset name, checksum, selftest, API discovery) | Treat as an incident: users can download it now |
| tag-verify | Someone pushed a tag | `git push origin :refs/tags/vX` |

`gh run view <id> --log-failed | tail -60` gets the error. Per CLAUDE.md,
never call a red release check flaky or stale without reproducing it.

## What a release does NOT prove

The release gates run artifact-smoke, installer-journey and runtime-parity,
which cover packaging. They do **not** run the browser E2E or the composed
agent E2E (quarantined as QUAR-001, issue #162). A release after a frontend
dependency bump or an agent change still needs `npx playwright test` or
`make e2e-local` locally first — see CLAUDE.md "What the gates do NOT cover".

## Touching release.yml

- Read `tests/build/test_release_publication_is_gated.py`,
  `test_release_approval_gate.py`, `test_workflow_job_graph.py` and
  `test_release_paths_run_before_the_tag.py` first; they encode the graph.
- No `continue-on-error` and no `always()` on release jobs. Conditions use
  `!cancelled() && !failure()` plus explicit `needs.<job>.result == 'success'`.
- Every `${{ }}` reaches shell through `env:`, quoted.
- New `workflow_dispatch` inputs need the `# checkov:skip=CKV_GHA_7` comment
  or the required Checkov check goes red.
- Only `promote` may declare `environment: release`.
- The change only takes effect for releases dispatched from a ref that
  contains it; a fix on `dev` does nothing until it reaches `main`.

Notifications and the other bots are described in **cb-automation**.
