---
name: rigor-release-prep
description: Prepare a Rigor RubyGems release by bumping the version, consolidating changelog fragments, and running release gates. Use only when the user explicitly invokes `/rigor-release-prep` or asks to cut a release; not for ordinary fixes or release context alone.
metadata:
  internal: true
---

# Rigor Release Prep

Follow this workflow when preparing a new `rigor` gem release.

All commands MUST run through the Flake per `AGENTS.md`. The examples below
include the full `nix develop --command` prefix so each line is directly
runnable from outside the Flake shell. Inside the Flake shell, drop the
prefix.

## Branch: `release/x.y.z`

Release work happens on a dedicated `release/x.y.z` branch, not directly on
`master` ([ADR-50](../../../docs/adr/50-release-engineering-and-stability-strategy.md)).
Cut it from an up-to-date `master`:

```sh
git switch master && git pull
git switch -c release/x.y.z
```

Every step below — the metadata edits, the local verify, the version-bump
commit — lands on this branch. Pushing it runs `release-gate.yml` (perf
benchmark, gem-build validation, OSS-corpus sweep — advisory until it is
promoted to a required check), the **comprehensive release gate** the normal
master CI does not (see "Push and watch the release gate" below). The base CI
gate (`ci.yml`) runs on the release **PR** you open next, via its
`pull_request` event — `ci.yml` triggers on `push` to `master` only, so a
release branch is not double-run (push + pull_request) the way it would be if
`release/**` were also a `push` branch.

## Update Release Metadata

Decide the next semantic version first, then update all versioned files
together.

Update these files:

- `CHANGELOG.md`
- `lib/rigor/version.rb`
- `Gemfile.lock` (regenerated by `bundle install` after the version bump)
- `README.md` (the `## Status` line — version + date; see "Refresh the
  README status" below)

### Seal the `[Unreleased]` entries — the mandatory rewrite step

**Do this first, before the mechanical version-heading move below, and treat
the entries — not the version bump — as the deliverable of this skill.** The
intended state is that every fragment was already written release-style **at
landing** (`docs/agents/contribution-flow.md` § "Release Cadence"), so this step
is mostly **cross-entry consolidation**: fold several commits' entries into one
user-recognisable change, reorder, dedupe, and split any merge artefacts —
work that needs the cycle-wide context only release time has. In practice the
section also accumulates **commit-style** drift (detailed, implementer-facing,
multi-sentence, em-dash run-ons) that landing-time discipline missed, so this
step is also the **safety net** that rewrites that drift into release-style
before the version is sealed — it is not, however, a licence to skip
landing-time quality and rewrite commit prose wholesale at the cut.

This is the highest-value, most-skipped step in a release. A release whose
CHANGELOG still reads like commit messages is **not done**, even if every
versioned file agrees and `make verify` is green — prose quality is invisible
to `make verify`, so nothing downstream will catch a skipped rewrite. It is the
one step silently lost when the mechanical steps around it get done; do not let
"the entries are already there" stand in for reviewing and consolidating them.

**Consolidate `changelog.d/` fragments before anything else.** Entries land as
`changelog.d/<section>/<slug>.md` fragments (ADR-105), so the seal starts by
moving every fragment's line(s) under the matching `###` section of
`[Unreleased]` (creating sections in Keep a Changelog order as needed) and
deleting the fragment files (`git rm`). After this, `changelog.d/` contains
only its `README.md`, and the enumeration below runs over the consolidated
`[Unreleased]`.

`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).
**This skill is the canonical statement of the entry rules** —
`docs/agents/contribution-flow.md` carries only the landing-time one-liner,
because the full set matters to a session writing an entry, not to every
session. The rules:

- **One sentence per top-level bullet.** One period, no em-dash clauses, no
  run-ons. Self-contained enough to understand without the body.
- **One LINE per bullet, never column-wrapped.** This section is extracted
  verbatim as the GitHub Release body, and GitHub renders a newline inside a
  paragraph as `<br>` — a wrapped entry renders ragged. Same rule for any PR /
  issue / comment / wiki text (`docs/agents/contribution-flow.md` § "Commits
  and GitHub Markdown").
- **Subsystem label prefix**: `**[rigor check]**`, `**[engine]**`,
  `**[plugin contract]**`, `**[plugins/rigor-foo]**`, etc.
- **User-facing only.** Cut internal implementation detail — class renames,
  RBS plumbing for the analyzer's own surface, test/spec additions, coverage
  counts, refactor-only plugin migrations. Ask of each: "would a user care if
  they weren't reading the source?" If no, delete it.
- **Supplementary detail goes in child items** (`  - …`), two to three
  sentences max, one topic each.
- A changelog entry is **not** a commit message: many commits may collapse into
  one entry, and one fat `[Unreleased]` line may split into several.
- **Link the PR that landed it**, as a full markdown link at the end of the
  bullet: `([#170](https://github.com/rigortype/rigor/pull/170))`. Put it on
  the child item instead when one bullet consolidates several PRs and each
  detail traces to a different one. Add the reporting issue too when there is
  one, and `thank you @handle!` for an outside report. The link is how a user
  gets from "what changed" to the detail.
  - **The full form, never a bare `#170`.** This section is extracted
    **verbatim** as the GitHub Release body (`rake release:github`), where
    GitHub autolinks a bare `#170` — but the same text is also `CHANGELOG.md`
    rendered from the repo tree, where it is **not** autolinked and stays dead
    text. Only the markdown form works in both, which is why every link already
    in the file uses it.
  - Omit it when there is no PR. A small docs-only change can land straight
    on `master` (`docs/agents/contribution-flow.md` § "Branches and pull
    requests"), so it has a commit and no PR. Do not invent one, and do not
    link the commit instead — if the change is user-facing enough to have an
    entry, the entry is the record.

The three shapes to reject on sight:

```markdown
# ✗ two sentences joined by an em-dash
- **[plugins]** All bundled plugins now ship inside the `rigortype` gem — `require "rigor-foo"` resolves without any workaround. Activate with `plugins: [rigor-foo]`.

# ✗ internal implementation detail as a child item
  - Individual gemspecs inside `plugins/` are removed; the plugin family ships as a single unit.

# ✗ commit-message prose instead of a user-meaningful description
- **[baseline]** Fix `group_for_baseline` to normalise paths to relative before building bucket keys.
```

Each is fixable in place — split the em-dash into a bullet plus a child item,
delete the internal detail, and restate the third as what a user can now do:

```markdown
- **[rigor baseline generate]** Fixed a crash when `plugins:` entries in `.rigor.yml` were plain strings.

- **[plugins]** All bundled plugins now ship inside the `rigortype` gem, so `require "rigor-foo"` works without any `RUBYLIB` or `Gemfile` workaround.
  - Activate any plugin with one line in `.rigor.yml`: `plugins: [rigor-foo]`.
```

Procedure — the enumeration is what makes the rewrite un-skippable, so do not
shortcut it:

1. **Pull the cycle's merged PRs up front**, so linking is a lookup rather than
   a recall. Derive the range from the previous release's **tag**, never from
   its `## [x.y.z] - DATE` heading:

   ```sh
   git log --oneline --merges vX.Y.Z..HEAD | grep -oE '#[0-9]+'
   ```

   **The heading date is a trap.** It is a local-time date, and
   `gh pr list --search "merged:>=DATE"` filters in UTC, so every PR merged
   between the tag and local midnight falls outside the query. The tag range
   is exact, offline, and has no timezone to get wrong.

   Keep the list beside you for step 4. It is also a **completeness check**: a
   PR in it with no entry anywhere is either a user-facing change nobody wrote
   up, or correctly internal — decide which, do not skip past it.
2. Read the entire `[Unreleased]` block. For **each** top-level bullet,
   explicitly classify it: release-style (leave) or commit-style (rewrite).
3. Rewrite every commit-style bullet — lead sentence to one clause, move the
   "why / how it works / measured numbers" into a child item, delete internal
   detail outright.
4. **Check every bullet already carries its PR link**, against the step-1 list.
   The landing rule requires the link, so this is verification, not
   authoring: a bullet missing one means the landing rule was skipped, and you
   are now paying the reconstruction cost this step exists to avoid. Match on
   the change, not the title wording — a bullet that consolidates several PRs
   links each on the child item it belongs to. Leave a bullet unlinked only when
   its change genuinely had no PR (a Markdown-only push to `master`).
5. Watch for **merge artefacts**: two entries accidentally glued into one
   bullet (a stray second `**[label]**` mid-sentence, an env-var description
   hanging off an unrelated entry). Split them. `CHANGELOG.md` is `merge=union`
   (`.gitattributes`), which trades insertion-order conflicts for one artefact
   you must look for here: a **duplicated `###` heading**, where two branches
   each opened the same section. Merge the two, keeping Keep a Changelog's
   order. `spec/docs/changelog_conformance_spec.rb` fails on it, so a missed one
   is caught rather than shipped — but catching it at the cut is cheaper.
6. Re-read the sealed section top-to-bottom as a user would. If any top-level
   bullet still has two sentences or an em-dash clause, it is not done.

Worked collapse (a real shape this repo produces):

```
# ✗ commit-style, as it lands in [Unreleased]
- **[engine]** RBS-complete ancestor resolution — `rigor check` now resolves a
  Ruby subclass's inherited calls against an allow-listed RBS-only ancestor, so
  misuse is caught. Previously a Ruby class subclassing an RBS-only class
  resolved every inherited call to `Dynamic[Top]` because the dispatcher
  short-circuited when the subclass name is absent from the RBS environment, so
  no ancestor walk ran (the edge was recorded in `Scope#discovered_superclasses`
  but never consulted by `RbsDispatch`). The fix threads the analysis `scope`
  into `RbsDispatch.lookup_method` and resolves against the ancestor's RBS ...

# ✓ release-style, after the rewrite
- **[engine]** RBS-complete ancestor resolution: `rigor check` now resolves a
  Ruby subclass's inherited method calls against an allow-listed RBS-only
  ancestor, so a typo'd or renamed contract call such as `manifest.bogus` on a
  plugin fires `call.undefined-method` ([ADR-43](docs/adr/43-rbs-complete-ancestor-resolution.md)).
  - The allow-list is the false-positive boundary: a non-allow-listed ancestor
    (e.g. `ActionController::Base`) keeps the `Dynamic[Top]` fallback, so a
    controller calling a method an incomplete gem RBS omits is never flagged.
```

(ADR-43 predates public development and landed by direct commit, so it has no
PR to link — the omission above is the rule working, not a lapse. A change that
did go through one reads
`… so the scan no longer re-runs per file ([#74](https://github.com/rigortype/rigor/pull/74)).`)

### Write the release summary

Immediately under the new `## [x.y.z] - YYYY-MM-DD` heading, **before the
first `###` section**, write a short prose summary — a few simple sentences
(≈3–4), in the CHANGELOG's language (English) — that gives a reader the
*themes* of the release before the itemised entries. It complements the
bullets: the bullets are the precise "what changed", the summary is the
narrative "what this release is about".

- **Lead with the dominant theme** (the biggest performance thread, the
  headline feature, …), then name one or two secondary threads, then close
  with a short "other fixes" clause — mirroring the bullets' weight.
- **Keep it short.** It is a lead, not a recap of every bullet — one clause
  per theme, no exhaustive enumeration.
- **Link sparingly**, using the file's existing repo-relative convention
  (`[ADR-46](docs/adr/46-…)`, `[the Elixir v1.20 review](docs/notes/…)`) — a
  pointer to the driving ADR / note for a theme, not a link per bullet.
- It is **user-facing prose**: the same "would a user care?" bar as the
  bullets; no internal class names or measured-number dumps (those live in
  the bullets and their child items).

Example shape (the 0.1.17 summary):

> v0.1.17 focuses on making analysis of real projects markedly faster — an
> incremental analysis cache, an unchanged-project fast path, and a large
> allocation reduction on big Rails apps. Inspired by the Elixir v1.20 type
> system, control-flow narrowing is strengthened for several methods. It also
> folds `Data.define` value objects to precise types and adds new
> clause-reachability and conformance diagnostics; fixes include a
> block-parameter binding crash and a pathological route-helper re-read.

### Refresh the README status

Update the `## Status` block in `README.md` to the new release **as part of
this release**, not as a later follow-up. Because publish runs immediately
after the PR merges (the next section), the README on `master` is never a
window ahead of a published gem — updating it here means the moment the
release lands, the front page already names the version RubyGems serves.

- Change `Current release: **`vX.Y.Z`** (YYYY-MM-DD)` to the new version and
  date. For a patch/minor within the same leading-digit cycle that is the
  **whole** edit — the surrounding prose (the `0.x` line description, the
  compatibility-surface and hardened-against clauses) is cycle-stable, so
  leave it untouched.
- A **leading-digit bump** (or a release that changes the cycle's story) may
  also need that surrounding prose refreshed — reread the block and update
  any clause the release makes stale.
- Keep this in the `Bump up version to x.y.z` commit alongside the other
  versioned files; it is release metadata, not a separate docs pass.

### Release mechanics

- Add a new `## [x.y.z] - YYYY-MM-DD` section immediately below `[Unreleased]`.
- Open it with the release summary above, then the `### Added` / `### Changed`
  / `### Fixed` sections.
- Use Keep a Changelog section headings verbatim: `Added`, `Changed`,
  `Deprecated`, `Removed`, `Fixed`, `Security`. Do NOT inline a description
  into the heading (`### Added — feature X` is wrong; the heading is just
  `### Added`). Do NOT use `####` sub-headings inside a version block.
  **There is no `Performance` / `Internal` / `Documentation` section** — a
  speed-up is `Changed`, a docs correction is `Fixed`. Say what it is in the
  entry, not in a heading of your own invention;
  `spec/docs/changelog_conformance_spec.rb` gates this.
- Group the same kinds of changes under the same heading — one section per type
  per release, in the Keep a Changelog order above (`Added` first, `Security`
  last). Both are gated.
- Preserve the release date in every version heading and the Keep a Changelog /
  Semantic Versioning notes at the top of `CHANGELOG.md`.
- Update the `[Unreleased]` compare link and add the new release link at the
  bottom of `CHANGELOG.md`. Compare links target
  `https://github.com/rigortype/rigor/compare/...`.
- Bump `Rigor::VERSION` in `lib/rigor/version.rb` to match the new release.
- Run `bundle install` after the version bump so `Gemfile.lock` records the
  new `rigortype (x.y.z)` revision; commit the regenerated lockfile alongside
  the version bump.
- Keep the release commit message in the form `Bump up version to x.y.z`.

### Archival rule (mandatory check)

Before bumping the version, check whether the new release is the **first
release after a leading-digit bump**. The archival rule:

> At the first `a.b.c` release whose `(a, b)` pair matches the most recent
> release but DIFFERS from the most recent archive's prefix, move the
> previous-digit range out of `CHANGELOG.md` into a new
> `docs/CHANGELOG-<old-prefix>.md` archive file.

Worked example: the rule fires at `0.1.1` because `(0, 1)` matches `0.1.0`
(the previous release) but differs from the most recent archive (none yet),
so `0.0.x` moves to `docs/CHANGELOG-0.0.x.md`. The next archival fires at
the first `0.2.x` post-bump release (`0.2.1`), which moves `0.1.x` to
`docs/CHANGELOG-0.1.x.md`.

When the rule fires:

1. Create `docs/CHANGELOG-<old-prefix>.md` with a header explaining what's
   in the file and a back-link to `CHANGELOG.md`. Use the archive built at
   `0.1.1` as the template — the explanatory header on
   [`docs/CHANGELOG-0.0.x.md`](../../../docs/CHANGELOG-0.0.x.md) is the
   reference shape.
2. Move the entire `## [a.b.c] - YYYY-MM-DD` block for every version in the
   range, plus the matching `[a.b.c]: https://...` reference links from
   the bottom of `CHANGELOG.md`, into the archive.
3. Update `CHANGELOG.md`'s top-of-file "Older release notes are archived"
   pointer list to include the new archive file.
4. Verify the active `CHANGELOG.md` now holds only `[Unreleased]` and the
   most recent leading-digit cycle (`a.b.0` plus any later `a.b.c`).

The rule keeps the active `CHANGELOG.md` small enough to read top-to-bottom
without scrolling fatigue while preserving every release note's full text
and link addressability.

## Verify the Release

Run before committing:

```sh
nix develop --command make verify
nix develop --command git diff --check
```

`make verify` is the CI-equivalent gate (tests, lint, `check`, and
`check-plugins`). `git diff --check` catches whitespace mistakes in the
release diff.

**Overlap it with the sealing rather than queueing behind it.** `make verify` is
~170 s of wall time that the prose work does not depend on, and the release
commit touches no `lib/`. Land the code-affecting edits first — `version.rb`,
then `bundle install` — start `make verify` in the background, and seal the
`[Unreleased]` entries while it runs. **The gate must still see the last edit**:
a `CHANGELOG.md` tweak made after a green verify can turn CI red, and
`make docs-check` does not cover the changelog conformance spec. So after the
final edit, re-run the two cheap gates that the prose can actually break:

```sh
nix develop --command \
  bundle exec rspec spec/docs/changelog_conformance_spec.rb
nix develop --command make docs-check
```

That preserves the after-the-last-edit invariant at a fraction of a full rerun.

Also build the gem to confirm the gemspec is still valid for the bumped
version:

```sh
nix develop --command gem build rigortype.gemspec
```

The build produces `rigortype-x.y.z.gem` in the working tree. Delete it before
committing — built gems must not be checked in.

If `make verify` requires formatting fixes or other non-version cleanup,
commit that work separately before creating the version-bump commit. Do not
fold release verification cleanup into the `Bump up version to x.y.z`
commit.

### Perf-gate gotchas

- The Make target is `bench-perf`, not `bench` (the bare name collides with
  the `bench/` data directory). `release-gate.yml` is **advisory** until it
  is promoted to a required check — it reports but does not block; `ci.yml`
  is the required gate. The signal is still release-quality: review it.
- Both baselines (`bench/baseline.json`,
  `data/oss-sweep/mastodon-thresholds.json`) are exact-count / banded with
  little headroom, so a precision or allocation change flips them red **by
  design**. Any recalibration uses the **CI-measured Linux** values, and
  **`wall_s` is a rerunnable flake — `gh run rerun --failed` clears it; never
  recalibrate for wall alone.** For the OSS sweep, diff the diagnostics for
  FPs before blessing a higher count, and fix an FP at its root rather than
  blessing it in. For the perf baseline, the next bullet is the only path.
- **A red perf gate has one way through.** It runs the release candidate's
  engine over the **previous release's tree** (the `corpus` tag in
  `bench/baseline.json`, [`bench/README.md`](../../../bench/README.md)), so an
  allocations failure is engine cost with corpus growth already out of it. The
  gate is planned as a required check, which makes this the only way to clear
  it:
  1. **Attribute the rise** since the baseline's `calibrated_at`: the
     "Engine allocations" summaries of the PRs merged since, gem bumps
     (`Gemfile.lock`; the `rbs` gem supplies the core RBS), and a Ruby change
     (the gate run's Ruby against the one `calibrated_on` names; CI's
     `ruby-version: "4.0"` floats to the latest patch with no PR). The
     dependency-update and Ruby-bump PRs record their own shift.
  2. **Put the attribution to the user.**
  3. **On the user's ruling, recalibrate on the same corpus**, on the release
     branch: commit that branch's `bench-baseline-<run-id>` artifact as
     `bench/baseline.json`, with a `note` naming the ruling and the
     attribution. The push re-runs the gate.
  4. **Publish, then advance the corpus** ("Advance the perf-gate corpus"
     below).
- Perf-measurement comparability: warm numbers are only comparable within one
  process model (an in-process cold-then-warm chain is ~186k allocs cheaper
  than a fresh-process warm — a phantom regression otherwise). Self-check
  allocation numbers require `vendor/bundle` present (~8M allocs of vendored
  sigs); the perf gate's corpus runs without it by design (`bench/README.md`).
  A peak-RSS rise on a run over ~5s is the deadline **YJIT**, not a
  leak — A/B with `RIGOR_DISABLE_YJIT=1` before diagnosing. Details:
  [`docs/notes/20260713-corpus-perf-campaign.md`](../../../docs/notes/20260713-corpus-perf-campaign.md).

## Commit the Result

Prefer a single release-prep commit containing:

- the `lib/rigor/version.rb` bump
- the `CHANGELOG.md` update for the new version
- the `README.md` `## Status` line refresh
- the `Gemfile.lock` regenerated by `bundle install`

Keep release verification cleanup and version bumps in separate commits. The
final release-prep commit should be the version bump commit.

Use:

```text
Bump up version to x.y.z
```

If the user asks for separate commits, keep the release version bump as the
final commit.

## Push and watch the release gate

Push the version-bump commit on the release branch:

```sh
git push -u origin release/x.y.z
```

The push triggers `release-gate.yml`; the base `ci.yml` gate runs on the
release **PR** you open next (it triggers on `pull_request`, not on a
`release/**` push, so the branch is not double-run):

- **`ci.yml`** (base gate, required) — test / lint / self-check warm+cold /
  warm==cold diff; runs on the PR. MUST be green before the PR merge below.
- **`release-gate.yml`** (comprehensive, advisory until promoted to a
  required check) —
  the perf benchmark (`make bench-perf`, [ADR-50](../../../docs/adr/50-release-engineering-and-stability-strategy.md)
  WD4), gem-build validation, and the OSS-corpus sweep. It reports but does
  not block while the baselines calibrate; a regression here is still a
  release-quality signal worth reviewing.
  - A **`wall_s`-only** perf failure is CI wall-time noise — the deterministic
    `allocations` / `peak_rss_kb` bands are the real signal. Clear it with
    `gh run rerun --failed <run-id>`; never recalibrate for wall alone. An
    allocations failure takes the one path in "Perf-gate gotchas".
  - An OSS-sweep diagnostic-count change needs a false-positive diff before it
    is blessed into `data/oss-sweep/*-thresholds.json`.

Do not merge until the base gate is green and the advisory gate is reviewed.

## Open the release PR and merge it on green

Land the release on `master` through a CI-gated PR — not a direct push — so the
version bump, sealed CHANGELOG, and archive reach the mainline only once the
required gate is green:

```sh
gh pr create --draft --base master --head release/x.y.z \
  --title "Bump up version to x.y.z" --body "<short release summary>"
gh pr checks <pr> --watch        # wait for the required ci.yml gate
gh pr ready <pr>                 # only on the user's explicit word, CI green, no stop instruction
gh pr merge <pr> --rebase --delete-branch
```

The release PR is created `--draft` like every other PR, but lands by this
skill's own rule, which `docs/agents/contribution-flow.md` § "Landing a pull
request" defers to: the user
reviews the sealed section on the PR, and `gh pr ready` is the recorded
hand-off from review to landing.

- **Merge on the required gate; review the advisory one.** `ci.yml` is the
  merge gate; `release-gate.yml` is advisory (apply the wall-noise / sweep
  notes above). Merge once `ci.yml` is green and any advisory failure is
  explained.
- **Rebase- or merge-commit, never squash.** Keep the `Bump up version to
  x.y.z` commit intact so the tag in "Publish" lands on it. If `master`
  advanced, rebase the branch first and re-push — and **re-read the sealed
  section afterwards**. `merge=union` means a `[Unreleased]` entry that landed
  on `master` during release prep is folded in silently rather than raising a
  conflict, so it can arrive under the wrong heading or after the section was
  already sealed.
- The PR merge **is** the merge-back to `master`; publish runs from `master`
  afterward, so there is no separate merge-back step.

## Publish

After the release PR merges, publish from an up-to-date `master` via the
`bundler/gem_tasks` release task wired into `Rakefile`. Run it only after the
user explicitly authorizes publishing: it tags, pushes, and publishes to
RubyGems, and none of that can be undone.

```sh
git switch master && git pull
nix develop --command bundle exec rake release
```

`rake release` verifies a clean working tree, tags the release as `vx.y.z`,
pushes the tag to `origin`, publishes the gem to RubyGems, AND creates the
matching GitHub Release (the post-publish hook in `Rakefile` invokes
`rake release:github`). It requires:

- A clean working tree on `master` (the merged release commit at `HEAD`).
- A RubyGems API key with MFA configured — `rigortype.gemspec` sets
  `rubygems_mfa_required => "true"`, so non-MFA pushes will be rejected.
- Push access to `origin`.
- `gh auth status` clean — the GitHub Release step shells out to
  `gh release create`.

If the GitHub Release step fails after the gem is already published (transient
`gh` / network error), the standalone task retries that step alone — the gem
is not republished:

```sh
nix develop --command bundle exec rake release:github
```

The standalone task reads `Rigor::VERSION`, requires the matching `vx.y.z` tag
to exist locally, extracts the `## [x.y.z] - YYYY-MM-DD` section from
`CHANGELOG.md` verbatim as the release body, derives the previous tag via
`git describe --tags --abbrev=0 vx.y.z^`, and appends a `**Full Changelog**:
compare/v(prev)...v(this)` footer. The release title is the section heading
without the leading `## ` (e.g. `[0.1.12] - 2026-05-28`).

If publishing must be split across people or machines, build locally and
hand the artefact to the publisher:

```sh
nix develop --command gem build rigortype.gemspec
# Hand off rigortype-x.y.z.gem, then on the publisher's machine:
nix develop --command gem push rigortype-x.y.z.gem
```

In the split-publish case, push the `vx.y.z` tag manually after the gem is
accepted by RubyGems, then run `rake release:github` once the tag is on
`origin` to create the GitHub Release.

## Advance the perf-gate corpus

Once the tag exists, the perf gate's corpus moves to it, so the next cycle's
engine cost is measured on this release's tree. Recalibrate from a branch cut
at the tag, so the baseline measures exactly the released engine. Open no PR
until the last step: the first commit sets `"calibrated": false`, which passes
every run, so it must never sit at the head of a PR that could merge.

```sh
git switch -c bench-corpus-vX.Y.Z vX.Y.Z
# bench/baseline.json: "corpus": "vX.Y.Z", "calibrated": false
git commit -am "Advance the perf-gate corpus to vX.Y.Z"
git push origin HEAD:refs/heads/bench-corpus-vX.Y.Z
gh workflow run release-gate.yml --ref bench-corpus-vX.Y.Z
gh run list --workflow release-gate.yml --branch bench-corpus-vX.Y.Z --limit 1 --json databaseId
gh run watch <run-id>
gh run view <run-id> --json status   # watch can return mid-run; wait for "completed"
gh run download <run-id> --name bench-baseline-<run-id> -D <dir>
```

Commit `<dir>/baseline.updated.json` as `bench/baseline.json`, adding
`calibrated_on` (the run, and the Ruby its setup step installed) and a `note`
with the move from the previous corpus. Push, then open the PR to `master`.
`calibrated: false` in the first commit is what keeps that run from gating the
new tree against the old tree's numbers, and
`spec/tool/bench_sampling_spec.rb` fails CI if it is ever committed as the
head state.

## Quick Checklist

- Working tree starts clean or every pending change is understood.
- `Rigor::VERSION` (`lib/rigor/version.rb`) and `Gemfile.lock` agree on the
  new version.
- **Every** former `[Unreleased]` bullet was classified and, if commit-style,
  rewritten per "Seal the `[Unreleased]` entries" — no top-level bullet in the
  new `## [x.y.z]` section has two sentences, an em-dash clause, internal-only
  detail, or a merge artefact. (This is the step `make verify` cannot check;
  confirm it by eye.)
- Every bullet whose change went through a PR carries it as a full markdown
  link (`([#170](https://github.com/rigortype/rigor/pull/170))`), and the
  step-1 PR list has no merged PR that is neither linked nor deliberately
  internal. No bare `#170` anywhere — it is dead text in `CHANGELOG.md`.
- The new `## [x.y.z]` section opens with a short release-summary paragraph
  (themes, a few simple sentences) before `### Added`.
- `README.md`'s `## Status` line names the new version + date (surrounding
  cycle-stable prose left intact unless a leading-digit bump makes it stale).
- `[Unreleased]` / `[x.y.z]` compare links resolve.
- `make verify` passed.
- `gem build rigortype.gemspec` succeeded and the produced `.gem` is not
  committed.
- The final commit message follows `Bump up version to x.y.z`.
- Release work happened on a `release/x.y.z` branch (not directly on
  `master`).
- Before merge: `ci.yml` base gate is green; `release-gate.yml` (advisory) was
  reviewed — no unexplained perf or sweep regression (a `wall_s`-only perf
  failure is noise, rerun it).
- The release PR merged to `master` on a green `ci.yml`, keeping the `Bump up
  version to x.y.z` commit intact (rebase / merge, not squash).
- After publish: the `vx.y.z` tag, the RubyGems push, and the GitHub Release
  all exist; the release branch is deleted.
- After publish: `bench/baseline.json` names `vx.y.z` as its corpus and is
  recalibrated on it, from a branch cut at the tag.
