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