Retires `state:needs-rebase` in favour of two independent axes: `state:*` (whose ball, exactly one) and `blocker:*` (what is in the way, additive). One rule joins them: `state:needs-human` requires zero blockers. The single-label design projected independent facts — mergeability, check status, review round — onto one totally-ordered value, so one always won and the rest vanished. Every precedence bug this machine has had lived on that ordering. Blockers are a set, so there is no precedence between them to get wrong. `state:bots-reviewing` tightens to mean strictly "a request is live"; a ready PR nobody was asked to review is now `state:addressing` + `blocker:unrequested`. The reconciler strips the retired `state:needs-rebase` on sight via a RETIRED array. Also wires test/labels-reconcile.sh into CI, where it had never run. Fixtures 51 -> 64. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.6 KiB
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 thing the blocker names is done |
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 |
somebody still owes a verdict and nobody was asked for one | reviews are requested |
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 written by automation, never by hand. 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) recomputes the
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. scope: labels on PRs are applied from the changed
paths by actions/labeler (.github/labeler.yml);
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):
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
# 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