actions/labels-reconcile + docs/CONSUMERS.md — the labels caller stub under-grants, and a wholly-blind sweep stays silent #95
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:docs
scope:guards
scope:labels
scope:release-flow
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/ceremony#95
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
All line references pinned at
b45202fformain, and at0.1.0where a consumer's live behavior is what is being described.
The reconciler reads two facts before it will touch a PR's state — mergeability and the check
rollup — with
gh pr view --json mergeable,statusCheckRollup.That read needs the
checksandstatusestoken permissions. The labels caller stub indocs/CONSUMERS.md#L213-L226grants
contents: read,issues: write,pull-requests: write— and naming any permission setsevery unnamed one to
none. So the stub the guide tells every consumer to paste does not grantthe reads the machinery makes. It has always been that way; the reusable's own dogfood caller
(
self-labels.yml#L13-L16)carries the identical block, which is why this repo could not notice it.
Public repos hide it; the first private consumer did not. Public check data reads anyway, so
box, rig, cast and ceremony are all fine. heavy-duty/incubator is the family's only private
consumer, it pasted the stub verbatim at adoption, and the result is that its
state:*axis hasnever been written — not once, on any PR, since adoption. Its reconcile logs, every pass:
— incubator run 30051866572,
23:01Z 2026-07-23, and the same in every earlier run that day. Control case, same reusable at the
same pin, public repo: ceremony run 30050720591
logs no such line and labels its open PR correctly. The consumer-side half is
heavy-duty/incubator#48.
The second defect is what let the first run for a day. The per-PR degrade is right and stays:
an unreadable rollup must never be relabelled from facts nobody read
(L589-L595),
and the comment states the reasoning — "the next tick is 15 minutes away, not 15 hours". That
reasoning assumes a transient failure. A misconfigured token is permanent, and the machinery
reports it exactly as loudly as a hiccup: one
logline, in a green job, on a board that shows nostate labels and no explanation. Every PR blind, every sweep, forever, with a green check on the
run — a sweep that certifies nothing while looking identical to one that certified everything.
That is the same family of defect as #93 (documented guarantee the machinery never delivered) and
#91 (a sweep that failed silently): the fault was visible only to someone who opened the logs and
knew what should have been there.
Found by triage in the 2026-07-23 incubator backlog sweep, from the board rather than from a
report: four open PRs, no
state:*label among them.Spec
Decisions (triage, 2026-07-23). Two deliverables, one PR.
docs/CONSUMERS.md's labels caller stubadds
checks: readandstatuses: read, each with a one-clause comment naming the read thatneeds it. Add only — the three existing lines keep their current access, and the stub must not
become
write-allor drop the block to inherit the repo default.unnamed one; public repos read check data regardless, so a private consumer is the only place
this bites, and it bites as nothing on the board rather than as a red run. Adoption sections
that repeat the permission list get the same two lines — the guide must not disagree with
itself.
.github/workflows/self-labels.ymlgains the same two lines. Ceremony is public and does not need them; a dogfood caller that
contradicts the published stub is how the stub stays wrong.
and every one of them came back
UNREADABLE,labels-reconcile.shemits a::warning::annotation naming the likely cause and the fix (grantchecks: readandstatuses: readin the caller; private repos do not imply them). Some-but-not-all unreadablestays a plain log line: that is the hiccup case, and it must not cry wolf.
labelsjob on a real GitHub incident would park an entire boardon a fault nobody in this org can fix, and the per-PR degrade already guarantees nothing wrong
gets written. The warning is the escalation; the annotation surfaces it on the run summary
where a green job never did.
no globals — per this repo's stated discipline that decisions live where a test can reach them
(
decide.shis thepattern). Do not assert the behavior only through
main().exit 0. This issue adds asweep-level signal; it changes no PR's labels and no state computation.
Tasks
actions/labels-reconcile/labels-reconcile.sh— count readable vs unreadable PRs across thesweep; emit the
::warning::when the sweep was wholly blind, via a pure decision functiontest/labels-reconcile.test.sh— cover the three cases (wholly blind → warn; partiallyblind → no warn; no open PRs → no warn)
docs/CONSUMERS.md— the two permission lines in the labels caller stub, their comments,and the paragraph explaining the private-repo asymmetry
.github/workflows/self-labels.yml— the same two linesCHANGELOG.mdline under## UnreleasedAcceptance criteria
docs/CONSUMERS.mdgrants five permissions — the three it had,unchanged, plus
checks: readandstatuses: read— and each new line carries its reasonnone, public repos readcheck data anyway, a private consumer sees it as an empty state axis rather than a red run
.github/workflows/self-labels.ymlgrants the same five::warning::naming the two permissionscould not read mergeability/checks — left alone this passmessage and its skipare byte-identical to before
test/run.shgreenTest plan
count == total and total > 0 → warn; blind count < total → no warn; total == 0 → no warn.
checks: readand
statuses: read, because a warning that does not carry the fix is another line nobody actson, which is the defect this issue is about.
(the hiccup case — a false alarm here trains the reader to ignore the real one); and a
wholly-blind sweep must still leave every PR's labels untouched, proving the warning is a
signal and not a new write path.
side; its next
mainsweep is where the log line disappears and thestate:*labels appear forthe first time. Triage carries that observation on incubator#48 — it does not gate this issue.
Dependencies
and fixes one repo's own caller; this fixes the guide every future consumer copies and makes the
next occurrence audible. Either may land first.
Starting implementation as builder. I’ll add the caller permissions and private-repo explanation, implement and directly test the pure wholly-blind sweep decision, preserve the per-PR unreadable skip byte-for-byte, and run the full suite.
The grant is sufficient — there is no second defect behind the first
Ran the pinned
0.1.0action out-of-band againstheavy-duty/incubator, with a full-scope PATstanding in for the permissions the stub is missing (a PAT sidesteps the private-visibility gate
the same way
checks: read+statuses: readwill),DRY_RUN=1so it computed and narratedwithout writing:
Same pin, same consumer, same four PRs that log
could not read mergeability/checksunder theworkflow token — each resolves to a correct state the moment the read succeeds. So the blindness
is the whole defect: the state machine underneath it is sound, and the consumer-side change needs
nothing beyond the two permission lines. Worth knowing before anyone goes looking for a second bug
behind the first.
Two details that fell out of the run:
blocker:conflicttoday. It has a real merge conflict, and nothing has toldanyone — the blank state axis is currently hiding a broken tree, not just an empty label.
decide_stateagrees with thestate:needs-humanthatclaude-bot-andresmgslhand-wrote at 23:05Z. The optimistic write wascorrect and the reconciler would have confirmed it — the designed fallback working, and the
reason the handoff path has not visibly failed despite the axis being dead.
One consequence of the blindness worth stating explicitly, if it is not already covered: with
state:*never machine-written on this consumer, the operator notifier's wake condition(
state:needs-humanand noblocker:*) fires only on an agent's optimistic write, with nothingto correct it. An agent that hand-writes the handoff on a PR carrying a conflict would page the
operator to merge an unmergeable tree — #41 is exactly that shape, one hand-written label away.
Post-merge report from the consumer side: the symptom this issue named is still live. Not a reopen request — both halves of #95 did what they specified, and the stub fix stands on its own merits regardless of what follows.
The consumer twin (incubator#48 / PR #49) merged 2026-07-23 23:42:39Z. Two runs after it, with
Checks: read/Statuses: readvisibly in the runner's token block, the reconciler still logscould not read mergeability/checksfor both open PRs and incubator'sstate:*axis is still blank — run 30055242702, run 30055288837.So the shared diagnosis was wrong: the stub did under-grant, and that was not what was blinding the sweep. Two candidates carried forward, neither of them this issue's work — the
2>/dev/nullon the read that discards the reason, and the fact that a private base repo means the forks are private too, which the control case folded into one variable. Detail, evidence and the reason it is a discussion rather than an issue: discussion #100.Triage, 2026-07-24 sweep — linked here so this closed thread does not read as a fix that held.