docs: CONTRIBUTING keeps only rig's specifics — the doctrine is the vendored .ceremony/ mirror (ceremony#13)
Root LABELS.md was rig's own copy of the family taxonomy doc; the vendored .ceremony/LABELS.md is the driftless replacement, so the copy goes. drills/README.md's gate pointer follows the guard upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
36da497352
commit
119fb68359
3 changed files with 49 additions and 359 deletions
218
CONTRIBUTING.md
218
CONTRIBUTING.md
|
|
@ -1,61 +1,32 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
How change lands in this repo. The short version: PRs are born as drafts,
|
This repo is governed by
|
||||||
three reviewer bots take the first rounds, a human takes the last word — and
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
|
||||||
labels tell you where everything is without opening anything.
|
read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
|
||||||
|
your role file (builder, reviewer, triage), vendored beside it,
|
||||||
|
byte-identical to ceremony at the pin named in
|
||||||
|
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
|
||||||
|
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
|
||||||
|
whole-round replies, verdicts, the handoff — lives there and in
|
||||||
|
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
|
||||||
|
genuinely rig's.
|
||||||
|
|
||||||
## The PR loop
|
## The PR loop, rig specifics
|
||||||
|
|
||||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
||||||
bots on purpose.
|
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl` —
|
||||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
the required verdicts for a PR are the panel minus its author. The
|
||||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
maintainer (`danmt`) takes the last word and merges.
|
||||||
They poll roughly every 15 minutes.
|
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||||
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** in
|
|
||||||
three acts, in this order: post the tagged round summary, request the
|
|
||||||
maintainer's review, then set `state:needs-human` yourself — removing the
|
|
||||||
state label it replaces. The review request is what *earns* the label,
|
|
||||||
provided the PR carries **no `blocker:*` label**. A blocker means the work
|
|
||||||
is still yours whatever the round said, so on a conflicted or red PR
|
|
||||||
neither the request nor your own label write will stick — the sweep takes
|
|
||||||
it straight back off. With three formal head-current approvals the labels
|
|
||||||
workflow requests the maintainer automatically; when part of the panel is
|
|
||||||
comment-only, reading their agreement is the author's judgment, so the
|
|
||||||
author makes the request.
|
|
||||||
|
|
||||||
Writing the label by hand is an **optimistic write, not a transfer of
|
|
||||||
ownership**. The machine stays the authority — but because the workflow
|
|
||||||
wakes on `labeled`, the author's own write fires the sweep that validates
|
|
||||||
it, and a handoff that had not earned the label is corrected seconds later.
|
|
||||||
Forgetting the write is not a failure either; it only means the label waits
|
|
||||||
for the cron, which is the lag this replaced.
|
|
||||||
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
|
||||||
`bash test/release.sh` locally mirror what CI runs; the db dump/restore
|
`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
|
round-trip (`test/db-integration.sh`) executes in CI where Docker is
|
||||||
present.
|
present. The release guards (`changelog-armed`, `changelog-monotonic`,
|
||||||
8. **Feature PRs land their changelog entry as part of the PR** (box's
|
`drill-recorded`, `docs-sync`) run as ceremony's pinned actions.
|
||||||
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
4. **Feature PRs land their changelog entry as part of the PR**: add it
|
||||||
that section becomes the release notes verbatim when a release is cut.
|
under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes
|
||||||
|
the release notes verbatim when a release is cut.
|
||||||
|
|
||||||
## Changelog entries
|
## Changelog entries
|
||||||
|
|
||||||
|
|
@ -91,134 +62,39 @@ Not an entry — that is a PR body:
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
A release is a PR, and merging it is the release (#47; box#96's design, on
|
A release is a PR, and merging it is the release. The ceremony — the two
|
||||||
top of #32/box#83's tag flow). It takes the ordinary PR loop above, with one
|
doors, the decide table, the stamps, the post-release re-arm — is
|
||||||
extra gate before the handoff:
|
heavy-duty/ceremony's machinery, consumed by reference:
|
||||||
|
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
|
||||||
|
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
|
||||||
|
caller pinning it, and the guards run in `ci.yml` from the same pin.
|
||||||
|
Bare `X.Y.Z` tags, no `v`; the tag's source tarball is the package
|
||||||
|
`install.sh` downloads — rig ships no other artifact.
|
||||||
|
|
||||||
**draft → ready → bot round → drill → `state:needs-human` → maintainer merge
|
What stays rig's is the **drill** — the real-hardware gate before the
|
||||||
(which IS the release).**
|
handoff of a release PR: tenant guests minted and converged via box,
|
||||||
|
`test/db-integration.sh`, the runner lifecycle against a fork, a coolify
|
||||||
The **drill** is a real-hardware run — tenant guests minted and converged via
|
install. Rig's drill asserts **convergence** (a machine reaches its role,
|
||||||
box, `test/db-integration.sh`, the GitHub runner lifecycle against a fork, a
|
idempotently), it runs `--host yes` with `BOX_REF=release/<box-version>` so
|
||||||
coolify install — recorded in **one file per version**:
|
it exercises the box that will actually ship, and drills that share a
|
||||||
|
substrate share **one run ID** so the per-repo records can be joined after
|
||||||
```
|
the fact. The full meaning — the fixed candidate-ref pinning that dissolves
|
||||||
drills/<version>.md
|
the box↔rig recursion, the per-version record files, the waiver rule — is
|
||||||
```
|
[`drills/README.md`](drills/README.md); the `drill-recorded` guard enforces
|
||||||
|
the record on every release tree.
|
||||||
named for the version exactly as `VERSION` carries it. See
|
|
||||||
[`drills/README.md`](drills/README.md) for what a record should contain.
|
|
||||||
|
|
||||||
`.github/scripts/drill-recorded.sh` enforces it on every release: a bare
|
|
||||||
`VERSION` with no non-empty `drills/<version>.md` turns CI red, naming the
|
|
||||||
version. It is **not a thing a reviewer has to remember** — that is how every
|
|
||||||
release in this family shipped undrilled until a bot finally blocked on one. On
|
|
||||||
a `-dev` tree it asserts nothing, so it is invisible to ordinary PRs. rig reads
|
|
||||||
rig's own record and never box's repo: a cross-repo lookup fails on a token,
|
|
||||||
a fork checkout or a network blip, and all of those degrade to "pass" —
|
|
||||||
the UNREADABLE-vs-NONE shape #90 fixed.
|
|
||||||
|
|
||||||
One file per version is what keeps the guard small. Records used to share a
|
|
||||||
single log, which forced a heading grammar, an optional-date tail, a
|
|
||||||
whole-version comparison and a non-blank-body rule just to read them back — and
|
|
||||||
both sibling repos shipped a defect out of that complexity in review. Now
|
|
||||||
`0.3.0.md` and `0.3.0-rc1.md` are simply different files.
|
|
||||||
|
|
||||||
**The three repos' drills are INDEPENDENT.** Run them in any order, on any
|
|
||||||
schedule, in separate sittings. What makes that safe is that every drill **pins
|
|
||||||
the same fixed set of candidate refs**: rig's drill runs `--host yes` with
|
|
||||||
`BOX_REF=release/<box-version>`, so it exercises the box that will actually
|
|
||||||
ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it exercises
|
|
||||||
the rig that will actually ship. Both measure the same pair.
|
|
||||||
|
|
||||||
That — not sequencing — is what dissolves the box↔rig recursion. box and rig
|
|
||||||
are mutually recursive (`rig bootstrap … --host yes` installs box and runs
|
|
||||||
box's `setup-host`; box's `box new` seeds converge back through rig's installer
|
|
||||||
at `@RIG_REPO@/@RIG_REF@`), but the refs are static identifiers that exist as
|
|
||||||
soon as the release branches do, long before any drill runs, so a cycle at
|
|
||||||
runtime becomes independent tests against one fixed pair. Within a single drill
|
|
||||||
you naturally bring the substrate up before probing it — a host before a guest
|
|
||||||
— but that is how you run a drill, not an ordering rule between repos.
|
|
||||||
|
|
||||||
Each repo drills in a **different way** and asserts a different thing: rig
|
|
||||||
asserts **convergence** (a machine reaches its role, idempotently), box asserts
|
|
||||||
the **isolation contract** (the VM trust boundary), cast asserts **promotion**
|
|
||||||
(A→B reproduces, the diff is idempotent). Three different exercises sharing a
|
|
||||||
substrate, not three phases of one script — which is exactly why the records
|
|
||||||
are per-repo.
|
|
||||||
|
|
||||||
It drills **candidate refs, not released artifacts.** `RIG_REPO`/`RIG_REF` are
|
|
||||||
mint-time environment variables (default `heavy-duty/rig@main`), so a run pins
|
|
||||||
the exact commits under test. That is what dissolves the chicken-and-egg: no
|
|
||||||
repo has to be released before another can be drilled.
|
|
||||||
|
|
||||||
**Drilling the candidate IS drilling the release.** A release PR's diff is
|
|
||||||
`VERSION` + `CHANGELOG.md` and nothing else — no executable difference exists
|
|
||||||
between the tree that was drilled and the tree that ships.
|
|
||||||
|
|
||||||
Drills that share a substrate share **one run ID**. Each repo records *its own*
|
|
||||||
legs in its own `drills/<version>.md`, citing that run ID and the other two
|
|
||||||
repos' commit SHAs, so the records can be joined after the fact by anyone
|
|
||||||
reading them. The guard still reads only this repo's file — there is no
|
|
||||||
cross-repo lookup anywhere in the gate. Releases do **not** have to be
|
|
||||||
published in a fixed order. If a defect shows up only in the combination:
|
|
||||||
patch, re-drill, re-record. The three releases converge on a set that holds
|
|
||||||
together; they are not required to be right in one pass.
|
|
||||||
|
|
||||||
A **maintainer waiver** is possible — a doc-only release, a hardware outage —
|
|
||||||
but it must be **recorded in `drills/<version>.md` for that version**, saying
|
|
||||||
who waived it and why. The guard asks for a *record*, not a passing result,
|
|
||||||
precisely so that skipping is a deliberate, reviewable commit instead of a
|
|
||||||
silence. Deleting the check is not the move.
|
|
||||||
|
|
||||||
The mechanics:
|
|
||||||
|
|
||||||
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`. **Then re-arm the file in the same
|
|
||||||
PR**: add a fresh, empty `## Unreleased` immediately above the section
|
|
||||||
you just stamped (#66). Stamping alone *disarms* main — a PR authored
|
|
||||||
before the release and merged after it wrote its entry under
|
|
||||||
`## Unreleased`, and with that heading gone git files the entry under
|
|
||||||
whatever now occupies the position, which is the release that already
|
|
||||||
shipped. It lands cleanly, with no conflict and nothing for the author
|
|
||||||
to notice, so the empty section is the only thing standing between a
|
|
||||||
late merge and a changelog that misattributes a shipped release. No
|
|
||||||
workflow does this for you: `release.yml` re-arms `VERSION`, never the
|
|
||||||
changelog. `test/release.sh` enforces the pairing — whenever `VERSION`
|
|
||||||
ends in `-dev` the top section must be `## Unreleased`. 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. 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, loudly). A dev install therefore never impersonates the
|
|
||||||
release in the `versions/<v>` layout. On the *manual* tag path the
|
|
||||||
bump stays yours: open the one-line PR after publishing.
|
|
||||||
|
|
||||||
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
|
## Labels — who sets what
|
||||||
|
|
||||||
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
|
The taxonomy and state machine are
|
||||||
who sets each kind — most of it is machinery, and hand-moving a
|
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); rig's `scope:*` rows live in
|
||||||
machine-owned label just gets corrected on the next pass:
|
`.github/labels.conf` (reconciled by the labels caller) and their path map
|
||||||
|
in `.github/labeler.yml`. 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 |
|
| Labels | Set by |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (step 6) and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
|
||||||
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
|
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
|
||||||
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
|
| `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 PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
||||||
|
|
|
||||||
187
LABELS.md
187
LABELS.md
|
|
@ -1,187 +0,0 @@
|
||||||
# Labels
|
|
||||||
|
|
||||||
How this repo uses GitHub labels. The taxonomy is shared across the
|
|
||||||
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
|
|
||||||
because it names this repo's actual surfaces.
|
|
||||||
|
|
||||||
## State — who is the ball with? (PRs, exactly one)
|
|
||||||
|
|
||||||
Every open PR carries exactly one `state:` label, and it answers the only
|
|
||||||
question a board scan actually asks: *who is this PR waiting on?* The states
|
|
||||||
mirror the review loop this repo runs — PRs open as drafts, three reviewer
|
|
||||||
bots pick up ready PRs with reviews requested, each round is answered in a
|
|
||||||
single reply, and a human takes the final review.
|
|
||||||
|
|
||||||
| Label | Color | Waiting on | Enters when | Leaves when |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
|
||||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
|
||||||
| `state:addressing` | `#D93F0B` | the coding agent to reply, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared |
|
|
||||||
| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | merged — or changes requested, which cycles back to `state:addressing` |
|
|
||||||
|
|
||||||
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
|
||||||
first means *poke the bots*, staleness in the second means *the agent dropped
|
|
||||||
the ball*. Collapsing them loses exactly the information a sweep needs.
|
|
||||||
`bots-reviewing` therefore means strictly *a request is live and an answer is
|
|
||||||
coming* — a PR nobody was asked to review is the agent's ball, not the bots'.
|
|
||||||
|
|
||||||
## The second axis: `blocker:*`
|
|
||||||
|
|
||||||
State answers *whose ball is it*. Blockers answer *what is in the way*, and
|
|
||||||
unlike states they are *facts about the branch* — mutually independent, so a
|
|
||||||
PR carries as many as apply.
|
|
||||||
|
|
||||||
| Label | Color | Means | Clears when |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly |
|
|
||||||
| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green |
|
|
||||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested |
|
|
||||||
| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/<version>.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the drill is run and recorded, or a maintainer waiver is recorded for that version |
|
|
||||||
|
|
||||||
`blocker:drill-pending` is the one blocker that is not about the code: the
|
|
||||||
branch merges, the checks that read the tree are green, and the release is
|
|
||||||
still not shippable because nothing says it was ever run on real hardware.
|
|
||||||
`.github/scripts/drill-recorded.sh` is the authority — the label just makes
|
|
||||||
the reason legible on the board, so a release PR sitting still reads as
|
|
||||||
"waiting on an afternoon of hardware", not as "forgotten". It only ever
|
|
||||||
appears on a `release` PR: every `-dev` tree satisfies the guard vacuously.
|
|
||||||
|
|
||||||
It is the one `blocker:*` the reconciler does **not** compute — its `BLOCKERS`
|
|
||||||
set is the three above — so it is applied by hand and, being outside that set,
|
|
||||||
is not stripped on the next sweep. (The red check itself still shows up as
|
|
||||||
`blocker:ci-red`; this label says *which* red.)
|
|
||||||
|
|
||||||
**A maintainer account must create this label.** The bot account 403s on label
|
|
||||||
creation, so until someone with push access runs the `gh label create` line
|
|
||||||
below, use plain `blocked` on such a PR — it carries the right meaning
|
|
||||||
(waiting on something else to happen first) and the staleness sweep already
|
|
||||||
exempts it.
|
|
||||||
|
|
||||||
One rule joins the axes: **`state:needs-human` requires zero blockers.** Any
|
|
||||||
blocker means the work is the agent's, whatever the review round says.
|
|
||||||
|
|
||||||
This split exists because the single-label version kept lying. Independent
|
|
||||||
facts were projected onto one totally-ordered label, so one always had to win
|
|
||||||
and the losers vanished off the board: a PR that was *both* conflicted and red
|
|
||||||
could only say one of them, and `needs-rebase` told an agent to rebase when
|
|
||||||
what it actually owed was a bug fix. Precedence between two blockers is not a
|
|
||||||
question a set has to answer, which is why every ordering bug this machine has
|
|
||||||
had — `needs-human` surviving a conflict, `MISSING` swallowing `STALE` — lived
|
|
||||||
on the axis that had to be totally ordered.
|
|
||||||
|
|
||||||
`state:needs-rebase` was the first attempt at this and is **retired**; the
|
|
||||||
reconciler strips it on sight so no PR is left carrying a label nothing
|
|
||||||
recomputes.
|
|
||||||
|
|
||||||
**`state:needs-human` means one thing: a human could merge this right now.**
|
|
||||||
The label is the only signal a maintainer scanning the board (or a phone)
|
|
||||||
actually reads, and one that says "your turn" on an unmergeable PR is worse
|
|
||||||
than no label at all. So beyond the blockers, one review fact also outranks an
|
|
||||||
explicit human request:
|
|
||||||
|
|
||||||
- **nobody reviewed *this* head** — every approval staled by a push → `state:addressing`,
|
|
||||||
because the agent owes a re-request
|
|
||||||
|
|
||||||
That case is more dangerous than any blocker: a blocked PR at least shows an X
|
|
||||||
or a disabled merge button, while a staled-approval PR reads green, mergeable
|
|
||||||
and "waiting on the human" over code no reviewer has seen.
|
|
||||||
|
|
||||||
`UNKNOWN` mergeability is deliberately **not** treated as a conflict. GitHub
|
|
||||||
reports it for about a minute after every merge while it recomputes, and
|
|
||||||
flapping every open PR through `blocker:conflict` on each merge would be worse
|
|
||||||
than the bug this fixes. A failed read of either branch fact degrades to the
|
|
||||||
same "do not know" value, for the same reason.
|
|
||||||
|
|
||||||
An *unfinished* round still yields to an explicit human request — a maintainer
|
|
||||||
pulling a PR to themselves early is a deliberate act. `MISSING` (nobody has
|
|
||||||
reviewed yet) and `STALE` (everyone reviewed something else) are different
|
|
||||||
facts and are treated differently.
|
|
||||||
|
|
||||||
## Cross-cutting (PRs and issues)
|
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
|
|
||||||
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
|
|
||||||
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
|
|
||||||
| `merge-next` | `#0E8A16` | Head of the merge queue — **merge this one next**. Queue order is *intent* (which PR lands first, given how they conflict), so the reconciler never sets it: you or the agent maintaining the queue do. The reconciler only **clears** it, the moment the PR stops being something a human could merge — so it cannot go stale the way `state:needs-human` did. |
|
|
||||||
|
|
||||||
## Scope — which surface? (PRs and issues, any number)
|
|
||||||
|
|
||||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
|
||||||
|
|
||||||
| Label | Covers |
|
|
||||||
|---|---|
|
|
||||||
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node |
|
|
||||||
| `scope:users` | `commands/users-*` — the root-door model, apply/status, close-root |
|
|
||||||
| `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status |
|
|
||||||
| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install |
|
|
||||||
| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof |
|
|
||||||
| `scope:installer` | `install.sh` — how rig itself lands on a machine |
|
|
||||||
|
|
||||||
## Issue types
|
|
||||||
|
|
||||||
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
|
|
||||||
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
|
|
||||||
would just say the same thing twice, drifting apart eventually.
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
State labels are machine-owned, with exactly one exception. Every state above
|
|
||||||
is derivable from GitHub's own facts — the draft flag, requested reviewers,
|
|
||||||
review states, push timestamps — so the labels workflow
|
|
||||||
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
|
|
||||||
state and reconciles labels statelessly, on PR events (label changes included)
|
|
||||||
plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the
|
|
||||||
workflow asserts the effective state instead.
|
|
||||||
|
|
||||||
The exception is `state:needs-human`, which the author sets at handoff
|
|
||||||
([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not
|
|
||||||
a transfer of ownership: because `pull_request_target: labeled` wakes the
|
|
||||||
workflow, the author's own label write fires the sweep that validates it, and
|
|
||||||
a handoff that had not earned the label is corrected within seconds.
|
|
||||||
|
|
||||||
It exists because the wake signal was missing. There is no
|
|
||||||
`pull_request_review_target` — on fork PRs, which is all of them here,
|
|
||||||
`pull_request_review` runs read-only and cannot label anything — so the moment
|
|
||||||
the label becomes true, the third approval landing, fired nothing at all. What
|
|
||||||
was left was the `*/15` cron, and GitHub deprioritises short intervals hard
|
|
||||||
enough that the delivered rate is closer to hourly. The label could therefore
|
|
||||||
lag the round it described by hours, worst on the quietest repo: every sweep
|
|
||||||
reconciles the whole board, so a busy repo stays fresh by piggybacking on
|
|
||||||
unrelated PR events, while a quiet one depends on the cron most and receives
|
|
||||||
it least. `scope:` labels on PRs are applied from the changed
|
|
||||||
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
|
|
||||||
|
|
||||||
The same workflow bootstraps the taxonomy: a manual dispatch creates any
|
|
||||||
missing label idempotently. To create them by hand (needs push access):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
|
|
||||||
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
|
|
||||||
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
|
|
||||||
gh label create "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force
|
|
||||||
gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force
|
|
||||||
gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force
|
|
||||||
# Needs a MAINTAINER account — the bot 403s on label creation. Until it exists, `blocked` stands in.
|
|
||||||
gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/<version>.md — correct but unevidenced" --force
|
|
||||||
# retired — the reconciler strips it; delete it once no PR carries it
|
|
||||||
# gh label delete "state:needs-rebase"
|
|
||||||
gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force
|
|
||||||
gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force
|
|
||||||
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
|
|
||||||
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
|
||||||
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
|
||||||
gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force
|
|
||||||
gh label create "scope:users" --color C5DEF5 --description "users-* — root-door model, apply/status, close-root" --force
|
|
||||||
gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force
|
|
||||||
gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force
|
|
||||||
gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force
|
|
||||||
gh label create "scope:installer" --color C5DEF5 --description "install.sh — how rig lands on a machine" --force
|
|
||||||
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
|
|
||||||
# that, so this block converges on re-run instead of erroring after first success.
|
|
||||||
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
|
|
||||||
gh label delete "$L" --yes 2>/dev/null || true
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
@ -24,7 +24,8 @@ The gate reads a file in this repo, and nothing else.
|
||||||
|
|
||||||
## What the gate requires
|
## What the gate requires
|
||||||
|
|
||||||
`.github/scripts/drill-recorded.sh` runs on every PR. On a `-dev` tree it
|
The `drill-recorded` guard (heavy-duty/ceremony's action, pinned in
|
||||||
|
`ci.yml`) runs on every PR. On a `-dev` tree it
|
||||||
asserts nothing — a development tree has no release to evidence. On a bare
|
asserts nothing — a development tree has no release to evidence. On a bare
|
||||||
`VERSION` — a release ceremony tree — it requires `drills/<version>.md` to
|
`VERSION` — a release ceremony tree — it requires `drills/<version>.md` to
|
||||||
exist and to hold at least one non-whitespace character. An empty file, or one
|
exist and to hold at least one non-whitespace character. An empty file, or one
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue