box/CONTRIBUTING.md

144 lines
7.8 KiB
Markdown
Raw Normal View History

# 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:`),
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
deserves one.
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` and `bash test/cli.sh` locally
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
## Releases
feat: merging a release-labeled PR is the release (#96) The 0.7.0 ceremony exposed the gap: the release PR merged with four approvals and nothing happened, correctly, because publishing hung off a separate, manual, silent-when-forgotten tag push — the worst failure shape, no error and no red X. The ship decision already lives in the release PR, so the merge now IS the release. release.yml grows a second door: pull_request closed on main, gated on merged == true AND the hand-set release label (read from the event payload — no extra permission). Four asserts, in order, each fail-loud and creating nothing: VERSION at the merge commit is non--dev; VERSION changed in this PR (merge vs first parent — the -dev interlock that kills a mislabeled ordinary PR); the version's CHANGELOG.md section extracts non-empty via the existing release-notes.sh; and no tag or release exists yet. Then, in the same job, it creates the tag ref at the merge commit via the API and publishes with gh release create --verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers no workflows (GitHub's anti-recursion), so the tag door can never fire off it and double-publish, and the no-existing assert covers a manual tag racing the merge. The tag-push path stays step-for-step identical as the documented manual fallback and backfill, gated to the push event so a closed PR never runs it against a branch ref. CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS the release", with the manual tag ritual kept as the fallback. test/release.sh grep-pins the merged+labeled gate, all four asserts, the same-job tag+publish, and that the tag-push trigger survives — in the same daemon-free, fail-closed style. Fixes #96 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:31 +00:00
A release is a PR, and merging it ships it
([#96](https://github.com/heavy-duty/box/issues/96), building on
[#83](https://github.com/heavy-duty/box/issues/83)):
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section 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 `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
`## Unreleased` back at the top**, immediately above the section you just
stamped:
```markdown
## 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, `main` has 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](https://github.com/heavy-duty/box/issues/108); confirmed in the
sibling repo as
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
CI enforces the arming rule with
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
carry either `## Unreleased` or its own stamped section. That is why the
guard cannot simply demand `## Unreleased` unconditionally — 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 turns `main` red on the very next push — the automatic `-dev` bump
the release itself makes. Do it in the ceremony PR and main is never
disarmed at all.
This PR is where the release ritual hangs:
the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary.
feat: merging a release-labeled PR is the release (#96) The 0.7.0 ceremony exposed the gap: the release PR merged with four approvals and nothing happened, correctly, because publishing hung off a separate, manual, silent-when-forgotten tag push — the worst failure shape, no error and no red X. The ship decision already lives in the release PR, so the merge now IS the release. release.yml grows a second door: pull_request closed on main, gated on merged == true AND the hand-set release label (read from the event payload — no extra permission). Four asserts, in order, each fail-loud and creating nothing: VERSION at the merge commit is non--dev; VERSION changed in this PR (merge vs first parent — the -dev interlock that kills a mislabeled ordinary PR); the version's CHANGELOG.md section extracts non-empty via the existing release-notes.sh; and no tag or release exists yet. Then, in the same job, it creates the tag ref at the merge commit via the API and publishes with gh release create --verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers no workflows (GitHub's anti-recursion), so the tag door can never fire off it and double-publish, and the no-existing assert covers a manual tag racing the merge. The tag-push path stays step-for-step identical as the documented manual fallback and backfill, gated to the push event so a closed PR never runs it against a branch ref. CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS the release", with the manual tag ritual kept as the fallback. test/release.sh grep-pins the merged+labeled gate, all four asserts, the same-job tag+publish, and that the tag-push trigger survives — in the same daemon-free, fail-closed style. Fixes #96 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:31 +00:00
2. **The maintainer's merge IS the release.**
[release.yml](.github/workflows/release.yml) fires on the merged,
`release`-labeled PR and asserts before creating anything: `VERSION` at
the merge commit is non-`-dev` **and changed in this PR** (the `-dev`
interlock — a mislabeled ordinary PR fails loudly and creates nothing),
the version's `CHANGELOG.md` section extracts non-empty, and no tag or
release exists for it yet. Then, in the same job, it tags the merge
commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes
the GitHub release with that section as the body. No assets — the source
tarball for the tag is the package, and `install.sh` downloads exactly
that.
*Manual fallback/backfill*: the tag-push path stays. Tagging the merge
commit bare `X.Y.Z` by hand and pushing the tag still publishes the same
way (release.yml asserts the tag names the tree's own `VERSION`) — for
backfills, or the day the merge path is red.
3. **The release re-arms main itself**: the same workflow run bumps
`VERSION` to `X.Y.(Z+1)-dev` and 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 after `VERSION`, so a `main` install without the
bump would land in `versions/X.Y.Z` and 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](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.