changelog-armed.sh asks only whether the TOP section agrees with VERSION. A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block — git merges the edit cleanly, and a shipped section is silently absorbed into Unreleased. The damage only surfaces at the next release, when release-notes.sh cannot find the section it extracts by heading. Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must be a superset of the set at the merge base. Release headings are append-only, so the rule has no legitimate violation — and the ceremony's stamp passes by construction, adding X.Y.Z and removing none. Its own script, not a clause in changelog-armed.sh: the input is a git history rather than two files, no base ref is a SKIP rather than a failure, and changelog-armed.sh is driven by test/release.sh against constructed non-git trees that cannot express the failure at all. CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so an unreachable base ref goes red there instead of degrading to the skip a local run is allowed. Closes #122 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 KiB
Contributing
How change lands in this repo. The short version: PRs are born as drafts, three reviewer bots take the first rounds, a human takes the last word — and labels tell you where everything is without opening anything.
The PR loop
- Fork and branch. Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (
feat:,fix:,docs:), and include aCHANGELOG.mdentry under## Unreleasedwhen the change deserves one. - Open as a draft while you build. Drafts are invisible to the reviewer bots on purpose.
- When it's ready: mark ready-for-review and request all three bots —
claude-bot-andresmgsl,codex-bot-andresmgsl,grok-bot-andresmgsl. They poll roughly every 15 minutes. - Rounds are answered whole. Wait until all three have reviewed, then answer the entire round in a single reply, push the fixes, and re-request the bots that didn't approve. Prefer verification over argument: a test settles what a comment thread can't.
- Reviews end in a verdict. A reviewer — bot or human — either approves or requests changes, never a bare comment. A comment-only review is a non-verdict: it doesn't say whether the round passed, and the state machine (and anyone scanning the board) has to guess. The verdict carries blockingness only, the body carries the feedback: non-blocking nits ride an approval and the author addresses them at their discretion; anything blocking — including a question that gates the verdict — is request changes, saying what unblocks it. The reconciler treats a comment-only review as not-approved, so commenting without a verdict only stalls the PR. The machine never reads review bodies: when a comment-only reviewer's line is really an agreement, that judgment belongs to the author — escalate by requesting the maintainer's review (step 6), and the reconciler flips the label on that request, because an explicit request is a fact it can trust.
- When the round passes, the author hands the PR to the maintainer by
requesting their review — that request is what flips
state:needs-human. With three formal head-current approvals the labels workflow requests it automatically; when part of the panel is comment-only, reading their agreement is the author's judgment, so the author makes the request. - Checks must be green:
shellcheckandbash test/cli.shlocally mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
Releases
A release is a PR, and merging it ships it (#96, building on #83):
-
The release PR —
release: X.Y.Z, labeledrelease— bumpsVERSIONfromX.Y.Z-devand stamps the## Unreleasedsection with version + date (feature PRs land their changelog entry as part of the PR, so the section is already written).Stamping is two edits, not one — the second is re-arming. After rewriting
## Unreleasedinto## X.Y.Z — DATE, put an empty## Unreleasedback at the top, immediately above the section you just stamped:## Unreleased ## 0.7.1 — 2026-07-19 ### Fixed ...Not cosmetic, and not deferrable to the next PR that happens to need it. Between the stamp and the next re-creation of that heading,
mainhas no## Unreleased. A PR authored before the release wrote its entry under that heading; with the heading gone, git lands the entry under whatever now occupies the position — the section that just shipped — and it merges cleanly, no conflict, no signal. The changelog then credits a released version with a change it does not contain, and nothing but a human reading the file will ever say so (#108; confirmed in the sibling repo as heavy-duty/rig#66).CI enforces the arming rule with .github/scripts/changelog-armed.sh, keyed on
VERSION: a-devtree must carry## Unreleasedon top; a bare-VERSIONtree (the ceremony PR, and the merge that publishes it) may carry either## Unreleasedor its own stamped section. That is why the guard cannot simply demand## Unreleasedunconditionally — the unconditional form is false on the ceremony PR's own tree and makes the release unshippable, which is why rig and cast both reverted it. The practical consequence: forgetting to re-arm does not block the release PR, it turnsmainred on the very next push — the automatic-devbump the release itself makes. Do it in the ceremony PR and main is never disarmed at all.Release headings are append-only. When you add your entry under
## Unreleased, insert above the heading below it — never type over that line. Replacing## 0.8.0 — 2026-07-19with your own## Unreleasedblock deletes a shipped section: its prose is absorbed intoUnreleased,release-notes.shcan no longer find the version it extracts by heading, and the next release republishes the absorbed prose as if it were new. git merges that edit cleanly andchangelog-armed.shstays green on it — the top section is still the right one — so .github/scripts/changelog-monotonic.sh asserts the other half on every PR: the set of## X.Y.Zheadings on your branch must be a superset of the set at the merge base (#122, caught in review of #118). The ceremony's own stamp passes it by construction — rewriting## Unreleasedinto## X.Y.Z — DATEadds a heading and removes none.This PR is where the release ritual hangs: the full drill on real hardware, recorded in drill/RUNS.md — CI proves the tier's semantics on every PR, a release still proves the boundary.
-
The maintainer's merge IS the release. release.yml fires on the merged,
release-labeled PR and asserts before creating anything:VERSIONat the merge commit is non--devand changed in this PR (the-devinterlock — a mislabeled ordinary PR fails loudly and creates nothing), the version'sCHANGELOG.mdsection extracts non-empty, and no tag or release exists for it yet. Then, in the same job, it tags the merge commit bareX.Y.Z(novprefix, the0.6.0precedent) and publishes the GitHub release with that section as the body. No assets — the source tarball for the tag is the package, andinstall.shdownloads exactly that.Manual fallback/backfill: the tag-push path stays. Tagging the merge commit bare
X.Y.Zby hand and pushing the tag still publishes the same way (release.yml asserts the tag names the tree's ownVERSION) — for backfills, or the day the merge path is red. -
The release re-arms main itself: the same workflow run bumps
VERSIONtoX.Y.(Z+1)-devand pushes the commit straight to main — no follow-up PR (it opens one only if branch protection refuses the direct push, and says so loudly). Not cosmetic — the versioned layout names install trees afterVERSION, so amaininstall without the bump would land inversions/X.Y.Zand impersonate the release just cut. On the manual tag path the bump stays yours: open the one-line PR after publishing.
Labels — who sets what
The full taxonomy lives in LABELS.md. What matters day to day is who sets each kind — most of it is machinery, and hand-moving a machine-owned label just gets corrected on the next pass:
| Labels | Set by |
|---|---|
state:* |
the labels workflow (.github/workflows/labels.yml) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
stale |
the same workflow — 48h without commits, comments, or reviews. blocked PRs are exempt: they are quiet legitimately. |
scope:* on PRs |
actions/labeler, from the changed paths (.github/labeler.yml). Additive — you may add more, the machine won't remove them. |
scope:* on issues |
you, when opening or triaging — issues have no paths to derive from. |
blocked, release |
you — automation never guesses intent. |
bug / enhancement / documentation |
you, on issues only — a PR's type already lives in its title. |
Issues
Give issues the same care as PR titles: say the surface in the title, apply a
scope: label and a type label (bug / enhancement / documentation) when
you open one, and blocked when it waits on something — that is what keeps
the board navigable as the issue count grows.