rig/CONTRIBUTING.md
dan-claude-bot 4ce1ab50aa feat: merging a release-labeled PR is the release (#47)
The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.

release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.

test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.

Fixes #47

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:55 +00:00

94 lines
5.4 KiB
Markdown

# 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
1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
bots on purpose.
3. **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.
4. **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.
5. **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.
6. **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.
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
`bash test/release.sh` locally mirror what CI runs; the db dump/restore
round-trip (`test/db-integration.sh`) executes in CI where Docker is
present.
8. **Feature PRs land their changelog entry as part of the PR** (box's
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut.
## Releasing
A release is a PR, and merging it is the release (#47; box#96's design, on
top of #32/box#83's tag flow):
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased
section as `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR.
2. Merge it — that IS the ship decision. `release.yml`'s
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
changed it (a mislabeled ordinary PR fails here); the changelog section
for that version extracts non-empty; no tag or release exists yet.
Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix —
box's tag scheme) and publishes the GitHub release with that section as
the body. No assets — the source tarball for the tag is the package
`install.sh` downloads.
3. A follow-up (or the next feature PR) bumps main's `VERSION` to
`X.Y.(Z+1)-dev`, so a dev install never impersonates the release in the
`versions/<v>` layout.
Manual fallback (and backfill): if the merge-path run fails, fix what it
named, then tag the merge commit `X.Y.Z` by hand and push the tag — the
original tag-push job still turns any correct tag into the release, and
the merge path's nothing-exists-yet assert keeps the two from
double-publishing.
## Labels — who sets what
The full taxonomy lives in [LABELS.md](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](.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](.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.