| name | rigor-release-prep |
| description | Prepare a rigor RubyGems release by bumping the gem version, updating the changelog, and running release verification commands. Use when the user asks to prepare the next version, cut a release, refresh release metadata, or make sure versioned files are consistent before tagging. |
| 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).
Cut it from an up-to-date master:
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 during the
v0.2.0 trial), 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 [Unreleased] entry was already written
release-style at landing (per AGENTS.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.
CHANGELOG.md follows Keep a Changelog 1.1.0.
This skill is the canonical statement of the entry rules — AGENTS.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.
- 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 — AGENTS.md says the git log is
where detail lives, and this is the door to it.
- 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 Markdown-only change lands straight on
master (AGENTS.md § "Commit and PR Etiquette"), 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:
# ✗ 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:
- **[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:
-
Pull the cycle's merged PRs up front, so linking is a lookup rather than
a recall. Take the previous release's date from its ## [x.y.z] - DATE
heading:
gh pr list --state merged --limit 200 --search "merged:>=<prev-release-date>" \
--json number,title,mergedAt --jq 'sort_by(.mergedAt) | .[] | "#\(.number) \(.title)"'
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.
-
Read the entire [Unreleased] block. For each top-level bullet,
explicitly classify it: release-style (leave) or commit-style (rewrite).
-
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.
-
Link each bullet to the PR that landed it, from the step-1 list. 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).
-
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.
-
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)).)
This step is judgment-heavy — deciding what is user-facing, and preserving
facts while compressing. It rewards a capable model and full attention; if you
are running on a smaller / faster model, slow down here rather than racing to
the version bump, because it is the one release step a later make verify
cannot rescue.
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.
- Group the same kinds of changes under the same heading.
- 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:
- 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 is the
reference shape.
- 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.
- Update
CHANGELOG.md's top-of-file "Older release notes are archived"
pointer list to include the new archive file.
- 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:
nix --extra-experimental-features 'nix-command flakes' develop --command make verify
nix --extra-experimental-features 'nix-command flakes' develop --command git diff --check
make verify runs make test, make lint, and make check in sequence —
the same set CI runs. git diff --check catches whitespace mistakes in the
release diff.
Also build the gem to confirm the gemspec is still valid for the bumped
version:
nix --extra-experimental-features 'nix-command flakes' 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 (learned the hard way)
- The Make target is
bench-perf, not bench (the bare name collides with
the bench/ data directory). release-gate.yml is advisory during the
evaluation line — 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 until recalibrated. Recalibrate from the CI-measured Linux
values: allocations is the deterministic signal; wall_s is a rerunnable
flake — gh run rerun --failed clears it; never recalibrate for wall
alone. Diff the OSS-sweep diagnostics for FPs before blessing a higher
count (the v0.2.0 recalibration found 3 StringScanner#[] FPs → fixed at
root, not blessed in).
- 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). 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.
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:
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:
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 during the v0.2.0 trial) —
the perf benchmark (make bench-perf, ADR-50
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>; recalibrate bench/baseline.json only
when allocations or RSS actually breach their band, never for wall alone.
- 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:
gh pr create --base master --head release/x.y.z \
--title "Bump up version to x.y.z" --body "<short release summary>"
gh pr checks <pr> --watch
gh pr merge <pr> --rebase --delete-branch
- 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.
- 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:
git switch master && git pull
nix --extra-experimental-features 'nix-command flakes' 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:
nix --extra-experimental-features 'nix-command flakes' 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:
nix --extra-experimental-features 'nix-command flakes' develop --command gem build rigortype.gemspec
nix --extra-experimental-features 'nix-command flakes' 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.
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.