actions/labels-reconcile + docs/CONSUMERS.md — the labels caller stub under-grants, and a wholly-blind sweep stays silent #95

Closed
opened 2026-07-23 23:09:26 +00:00 by dan-claude-bot · 3 comments
dan-claude-bot commented 2026-07-23 23:09:26 +00:00 (Migrated from github.com)

Context

All line references pinned at b45202f for
main, and at 0.1.0
where 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 checks and statuses token permissions. The labels caller stub in
docs/CONSUMERS.md#L213-L226
grants contents: read, issues: write, pull-requests: write — and naming any permission sets
every unnamed one to none. So the stub the guide tells every consumer to paste does not grant
the 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 has
never been written — not once, on any PR, since adoption. Its reconcile logs, every pass:

labels: #47: could not read mergeability/checks — left alone this pass
labels: #46: could not read mergeability/checks — left alone this pass
labels: #41: could not read mergeability/checks — left alone this pass
labels: #39: could not read mergeability/checks — left alone this pass
labels: reconciled.

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 log line, in a green job, on a board that shows no
state 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.

  1. The stub gains the two reads, with their reason. docs/CONSUMERS.md's labels caller stub
    adds checks: read and statuses: read, each with a one-clause comment naming the read that
    needs it. Add only — the three existing lines keep their current access, and the stub must not
    become write-all or drop the block to inherit the repo default.
  2. The prose says why, once. A sentence under the stub: naming any permission zeroes every
    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.
  3. This repo's own caller matches the guide it publishes. .github/workflows/self-labels.yml
    gains 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.
  4. A wholly-blind sweep is a warning, not a log line. When the sweep read at least one open PR
    and every one of them came back UNREADABLE, labels-reconcile.sh emits a
    ::warning:: annotation naming the likely cause and the fix (grant checks: read and
    statuses: read in the caller; private repos do not imply them). Some-but-not-all unreadable
    stays a plain log line: that is the hiccup case, and it must not cry wolf.
  5. Warn, do not fail. A red labels job on a real GitHub incident would park an entire board
    on 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.
  6. The blind-sweep decision is a function a test can drive — counts in, verdict out, no API,
    no globals — per this repo's stated discipline that decisions live where a test can reach them
    (decide.sh is the
    pattern). Do not assert the behavior only through main().
  7. The per-PR skip is untouched, including its message and its exit 0. This issue adds a
    sweep-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 the
    sweep; emit the ::warning:: when the sweep was wholly blind, via a pure decision function
  • test/labels-reconcile.test.sh — cover the three cases (wholly blind → warn; partially
    blind → 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 lines
  • CHANGELOG.md line under ## Unreleased

Acceptance criteria

  • The labels caller stub in docs/CONSUMERS.md grants five permissions — the three it had,
    unchanged, plus checks: read and statuses: read — and each new line carries its reason
  • The guide states the asymmetry once: unnamed permissions become none, public repos read
    check data anyway, a private consumer sees it as an empty state axis rather than a red run
  • .github/workflows/self-labels.yml grants the same five
  • Every open PR unreadable in one sweep produces a ::warning:: naming the two permissions
  • At least one readable PR in the sweep produces no warning
  • A sweep with no open PRs produces no warning
  • The per-PR could not read mergeability/checks — left alone this pass message and its skip
    are byte-identical to before
  • test/run.sh green

Test plan

  • The decision function, directly (the suite sources the script, so it is reachable): blind
    count == total and total > 0 → warn; blind count < total → no warn; total == 0 → no warn.
  • The annotation's content is asserted, not just its presence — it must name checks: read
    and statuses: read, because a warning that does not carry the fix is another line nobody acts
    on, which is the defect this issue is about.
  • Must-fail cases: a sweep where one PR of three is unreadable must produce no warning
    (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.
  • Live confirmation, not builder work: incubator#48 grants the permissions on the consumer
    side; its next main sweep is where the log line disappears and the state:* labels appear for
    the first time. Triage carries that observation on incubator#48 — it does not gate this issue.

Dependencies

  • Nothing blocks this, and it blocks nothing. incubator#48 is the same finding one layer down
    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.
## Context All line references pinned at [`b45202f`](https://github.com/heavy-duty/ceremony/tree/b45202f) for `main`, and at [`0.1.0`](https://github.com/heavy-duty/ceremony/tree/24ed9ad05b58c9d79727e82c50ab236ef16dcaf8) where 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`](https://github.com/heavy-duty/ceremony/blob/b45202f/actions/labels-reconcile/labels-reconcile.sh#L586). That read needs the `checks` and `statuses` token permissions. The labels caller stub in [`docs/CONSUMERS.md#L213-L226`](https://github.com/heavy-duty/ceremony/blob/b45202f/docs/CONSUMERS.md#L213-L226) grants `contents: read`, `issues: write`, `pull-requests: write` — and naming any permission sets every unnamed one to `none`. So the stub the guide tells every consumer to paste **does not grant the reads the machinery makes**. It has always been that way; the reusable's own dogfood caller ([`self-labels.yml#L13-L16`](https://github.com/heavy-duty/ceremony/blob/b45202f/.github/workflows/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 has **never** been written — not once, on any PR, since adoption. Its reconcile logs, every pass: ``` labels: #47: could not read mergeability/checks — left alone this pass labels: #46: could not read mergeability/checks — left alone this pass labels: #41: could not read mergeability/checks — left alone this pass labels: #39: could not read mergeability/checks — left alone this pass labels: reconciled. ``` — [incubator run 30051866572](https://github.com/heavy-duty/incubator/actions/runs/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](https://github.com/heavy-duty/ceremony/actions/runs/30050720591) logs no such line and labels its open PR correctly. The consumer-side half is [heavy-duty/incubator#48](https://github.com/heavy-duty/incubator/issues/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](https://github.com/heavy-duty/ceremony/blob/b45202f/actions/labels-reconcile/labels-reconcile.sh#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 `log` line, in a green job, on a board that shows no state 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. 1. **The stub gains the two reads, with their reason.** `docs/CONSUMERS.md`'s labels caller stub adds `checks: read` and `statuses: read`, each with a one-clause comment naming the read that needs it. Add only — the three existing lines keep their current access, and the stub must not become `write-all` or drop the block to inherit the repo default. 2. **The prose says why, once.** A sentence under the stub: naming any permission zeroes every 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. 3. **This repo's own caller matches the guide it publishes.** `.github/workflows/self-labels.yml` gains 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. 4. **A wholly-blind sweep is a warning, not a log line.** When the sweep read at least one open PR and **every** one of them came back `UNREADABLE`, `labels-reconcile.sh` emits a `::warning::` annotation naming the likely cause and the fix (grant `checks: read` and `statuses: read` in the caller; private repos do not imply them). Some-but-not-all unreadable stays a plain log line: that is the hiccup case, and it must not cry wolf. 5. **Warn, do not fail.** A red `labels` job on a real GitHub incident would park an entire board on 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. 6. **The blind-sweep decision is a function a test can drive** — counts in, verdict out, no API, no globals — per this repo's stated discipline that decisions live where a test can reach them ([`decide.sh`](https://github.com/heavy-duty/ceremony/blob/b45202f/lib/decide.sh) is the pattern). Do not assert the behavior only through `main()`. 7. **The per-PR skip is untouched**, including its message and its `exit 0`. This issue adds a sweep-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 the sweep; emit the `::warning::` when the sweep was wholly blind, via a pure decision function - [ ] `test/labels-reconcile.test.sh` — cover the three cases (wholly blind → warn; partially blind → 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 lines - [ ] `CHANGELOG.md` line under `## Unreleased` ## Acceptance criteria - [ ] The labels caller stub in `docs/CONSUMERS.md` grants five permissions — the three it had, unchanged, plus `checks: read` and `statuses: read` — and each new line carries its reason - [ ] The guide states the asymmetry once: unnamed permissions become `none`, public repos read check data anyway, a private consumer sees it as an empty state axis rather than a red run - [ ] `.github/workflows/self-labels.yml` grants the same five - [ ] Every open PR unreadable in one sweep produces a `::warning::` naming the two permissions - [ ] At least one readable PR in the sweep produces no warning - [ ] A sweep with no open PRs produces no warning - [ ] The per-PR `could not read mergeability/checks — left alone this pass` message and its skip are byte-identical to before - [ ] `test/run.sh` green ## Test plan - **The decision function, directly** (the suite sources the script, so it is reachable): blind count == total and total > 0 → warn; blind count < total → no warn; total == 0 → no warn. - **The annotation's content is asserted, not just its presence** — it must name `checks: read` and `statuses: read`, because a warning that does not carry the fix is another line nobody acts on, which is the defect this issue is about. - **Must-fail cases:** a sweep where one PR of three is unreadable must produce **no** warning (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. - **Live confirmation, not builder work:** incubator#48 grants the permissions on the consumer side; its next `main` sweep is where the log line disappears and the `state:*` labels appear for the first time. Triage carries that observation on incubator#48 — it does not gate this issue. ## Dependencies - **Nothing blocks this, and it blocks nothing.** incubator#48 is the same finding one layer down 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.
codex-bot-andresmgsl commented 2026-07-23 23:11:12 +00:00 (Migrated from github.com)

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.

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.
dan-claude-bot commented 2026-07-23 23:14:38 +00:00 (Migrated from github.com)

Filed from the sherpa session at danmt's request — evidence for whoever builds this, not a
triage ruling. The diagnosis in the body stands as written; this only adds a reproduction that
bounds the fix.

The grant is sufficient — there is no second defect behind the first

Ran the pinned 0.1.0 action out-of-band against heavy-duty/incubator, with a full-scope PAT
standing in for the permissions the stub is missing (a PAT sidesteps the private-visibility gate
the same way checks: read + statuses: read will), DRY_RUN=1 so it computed and narrated
without writing:

$ git archive 0.1.0 actions/labels-reconcile lib | tar -x -C /tmp/recon
$ cd /tmp/recon && GH_TOKEN="$(gh auth token)" REPO=heavy-duty/incubator \
    LABELS_CONF=.../incubator/.github/labels.conf DRY_RUN=1 \
    bash actions/labels-reconcile/labels-reconcile.sh

labels: #47: state -> state:bots-reviewing
labels: #46: state -> state:bots-reviewing
labels: #41: state -> state:bots-reviewing +blocker:conflict
labels: reconciled.

Same pin, same consumer, same four PRs that log could not read mergeability/checks under the
workflow 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:

  • #41 warrants blocker:conflict today. It has a real merge conflict, and nothing has told
    anyone — the blank state axis is currently hiding a broken tree, not just an empty label.
  • #39 produced no line at all, which is the good case: decide_state agrees with the
    state:needs-human that claude-bot-andresmgsl hand-wrote at 23:05Z. The optimistic write was
    correct 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-human and no blocker:*) fires only on an agent's optimistic write, with nothing
to 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.

> Filed from the sherpa session at danmt's request — evidence for whoever builds this, not a > triage ruling. The diagnosis in the body stands as written; this only adds a reproduction that > bounds the fix. ## The grant is sufficient — there is no second defect behind the first Ran the pinned `0.1.0` action out-of-band against `heavy-duty/incubator`, with a full-scope PAT standing in for the permissions the stub is missing (a PAT sidesteps the private-visibility gate the same way `checks: read` + `statuses: read` will), `DRY_RUN=1` so it computed and narrated without writing: ``` $ git archive 0.1.0 actions/labels-reconcile lib | tar -x -C /tmp/recon $ cd /tmp/recon && GH_TOKEN="$(gh auth token)" REPO=heavy-duty/incubator \ LABELS_CONF=.../incubator/.github/labels.conf DRY_RUN=1 \ bash actions/labels-reconcile/labels-reconcile.sh labels: #47: state -> state:bots-reviewing labels: #46: state -> state:bots-reviewing labels: #41: state -> state:bots-reviewing +blocker:conflict labels: reconciled. ``` Same pin, same consumer, same four PRs that log `could not read mergeability/checks` under the workflow 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: - **#41 warrants `blocker:conflict` today.** It has a real merge conflict, and nothing has told anyone — the blank state axis is currently hiding a broken tree, not just an empty label. - **#39 produced no line at all**, which is the good case: `decide_state` agrees with the `state:needs-human` that `claude-bot-andresmgsl` hand-wrote at 23:05Z. The optimistic write was correct 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-human` and no `blocker:*`) fires only on an agent's optimistic write, with nothing to 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.
dan-claude-bot commented 2026-07-24 00:12:45 +00:00 (Migrated from github.com)

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: read visibly in the runner's token block, the reconciler still logs could not read mergeability/checks for both open PRs and incubator's state:* 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/null on 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.

**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](https://github.com/heavy-duty/incubator/issues/48) / [PR #49](https://github.com/heavy-duty/incubator/pull/49)) merged 2026-07-23 23:42:39Z. Two runs after it, with `Checks: read` / `Statuses: read` visibly in the runner's token block, the reconciler still logs `could not read mergeability/checks` for both open PRs and incubator's `state:*` axis is still blank — [run 30055242702](https://github.com/heavy-duty/incubator/actions/runs/30055242702), [run 30055288837](https://github.com/heavy-duty/incubator/actions/runs/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/null` on 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](https://github.com/heavy-duty/ceremony/discussions/100)**. *Triage, 2026-07-24 sweep — linked here so this closed thread does not read as a fix that held.*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#95
No description provided.