actions/labels-reconcile — the bootstrap must retire the default GitHub labels it documents #93

Closed
opened 2026-07-23 21:05:22 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-23 21:05:22 +00:00 (Migrated from github.com)

Context

LABELS.md L191-196 states, as fact:

Default GitHub labels (duplicate, invalid, question, wontfix, help wanted, good first issue) are deleted at bootstrap — a question is a discussion, not an issue.

Nothing deletes them. bootstrap_labels() L405-416 walks core_label_rows() plus the consumer's labels.conf and issues one gh label create --force per row. It upserts; it never removes. grep -rn 'label delete' actions/ .github/ lib/ bin/ returns zero hits across the tree, and #10 — the issue that built the bootstrap — never specified retirement, so this was doctrine that outran its implementation rather than a regression.

The proof is live. heavy-duty/incubator adopted the labels automation today (incubator#31incubator#32) and its bootstrap dispatch ran green at 20:15Z (run 30041309187): the full core taxonomy plus seven scope:* rows were created correctly, and good first issue is still on the repo. It is the first consumer bootstrapped since the family's other repos were cleaned by hand, so it is the first honest read of what the machine actually does. box, rig, cast and ceremony carry no defaults because a human deleted them, not because the bootstrap did.

The cost is not cosmetic. LABELS.md is vendored doctrine — it ships to every consumer through .ceremony/ and is the file an agent reads to learn what the board means. A guarantee stated there and absent from the machinery is the exact drift this repo exists to end, and every future consumer inherits the gap silently: adoption looks green, the board keeps a question label the taxonomy explicitly rejects.

The spec

Retire the six defaults in bootstrap_labels(), from one registry, tolerating both absence and refusal.

  • The list lives next to the taxonomy it belongs to — a retired_label_names() heredoc directly beside core_label_rows() in labels-reconcile.sh, carrying exactly the six names LABELS.md publishes. One registry, same file, same reason core_label_rows() is not in labels.conf: a rule that must hold in every governed repo cannot live in a per-repo file.
  • Deletion is dispatch-only, inside bootstrap_labels(), alongside the upserts. The cron sweep does not delete, for the reason the header comment already gives for not creating: ~20 mutations is too chatty for a 15-minute tick.
  • Every deletion goes through run (L59-61), so DRY_RUN=1 narrates it like every other mutation.
  • A missing label is the normal case, not a failure. gh label delete exits non-zero when the label does not exist, and the script executes under set -e. An unguarded call therefore aborts the whole dispatch on the second run of every repo — the same shape as #91, where a bare return on a correct no-op exited 1. Absence is success and is logged at most once per name.
  • A refusal is tolerated and logged, never fatal. blocker:drill-pending is already on record in LABELS.md as a row the bot bootstrap 403s on; a delete can 403 the same way. Log the name and continue to the next one — a token that cannot delete must still get the taxonomy it can create.
  • Deletion removes the label from any issue carrying it. That is intended and is what "retired" means here; these six are the set the taxonomy replaces by construction.
  • Consumers receive this at their next pin bump. Every consumer pins @0.1.0, which predates this fix, exactly like the offsite, needs-ruling and attention rows. No consumer's board changes until it bumps and re-dispatches — including incubator's surviving good first issue, whose actual cleanup is the operator button tracked on #1.
  • LABELS.md is not edited. Its sentence becomes true; changing it would be the wrong repair of the two.

Tasks

  • Add retired_label_names() beside core_label_rows() in actions/labels-reconcile/labels-reconcile.sh with the six names from LABELS.md.
  • Delete each of them in bootstrap_labels() through run, after the upsert loop, tolerating a missing label and a refusal without aborting the dispatch.
  • Extend test/labels-reconcile.test.sh with the cases in the test plan below.
  • Confirm docs/CONSUMERS.md needs no change (the adoption checklist already says defaults are retired; verify and say so in the PR rather than editing on assumption).

Acceptance criteria

  • bootstrap_labels() deletes all six of duplicate, invalid, question, wontfix, help wanted, good first issue, and no seventh name.
  • The list of six exists exactly once in the tree, in labels-reconcile.sh, and matches LABELS.md name for name.
  • A dispatch against a repo where none of the six exist completes green and creates the full taxonomy — verified by an executed subprocess, not a sourced call, so set -e is live (the #91 lesson: sourcing hides exactly this class of bug).
  • A dispatch where one delete is refused still deletes the rest and still upserts every taxonomy row.
  • DRY_RUN=1 narrates each deletion and performs none.
  • The cron and pull_request_target paths delete nothing.
  • ./test/run.sh green.

Test plan

  • Happy pathrun stubbed to record; assert the six delete calls appear, in the same dispatch as the upserts, and that the recorded upsert set is unchanged from today's.
  • Missing label — stub the delete to exit non-zero with GitHub's not-found message; assert bootstrap_labels returns 0 and the remaining deletions still ran. Must fail before the fix's guard is in place.
  • Refusal — stub one name's delete to exit non-zero with a 403; assert the other five ran, the taxonomy upserts ran, and the log names the refused label.
  • set -e under execution — invoke the script as a subprocess with GITHUB_EVENT_NAME=workflow_dispatch and a gh stub whose label delete always fails; assert exit 0. This is the case a sourced test cannot see.
  • Dry runDRY_RUN=1; assert six DRY_RUN: gh label delete lines and zero real invocations.
  • Wrong eventGITHUB_EVENT_NAME=schedule; assert zero delete calls.

Dependencies

None. Part of #1.

## Context [`LABELS.md` L191-196](https://github.com/heavy-duty/ceremony/blob/b45202f6f4fb87d3b2315fd3c501c4254b6ddee5/LABELS.md#L191-L196) states, as fact: > Default GitHub labels (`duplicate`, `invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are deleted at bootstrap — a `question` is a discussion, not an issue. Nothing deletes them. [`bootstrap_labels()` L405-416](https://github.com/heavy-duty/ceremony/blob/b45202f6f4fb87d3b2315fd3c501c4254b6ddee5/actions/labels-reconcile/labels-reconcile.sh#L405-L416) walks `core_label_rows()` plus the consumer's `labels.conf` and issues one `gh label create --force` per row. It upserts; it never removes. `grep -rn 'label delete' actions/ .github/ lib/ bin/` returns zero hits across the tree, and [#10](https://github.com/heavy-duty/ceremony/issues/10) — the issue that built the bootstrap — never specified retirement, so this was doctrine that outran its implementation rather than a regression. **The proof is live.** heavy-duty/incubator adopted the labels automation today ([incubator#31](https://github.com/heavy-duty/incubator/issues/31) → [incubator#32](https://github.com/heavy-duty/incubator/pull/32)) and its bootstrap dispatch ran green at 20:15Z ([run 30041309187](https://github.com/heavy-duty/incubator/actions/runs/30041309187)): the full core taxonomy plus seven `scope:*` rows were created correctly, and **`good first issue` is still on the repo**. It is the first consumer bootstrapped since the family's other repos were cleaned by hand, so it is the first honest read of what the machine actually does. box, rig, cast and ceremony carry no defaults because a human deleted them, not because the bootstrap did. The cost is not cosmetic. `LABELS.md` is vendored doctrine — it ships to every consumer through `.ceremony/` and is the file an agent reads to learn what the board means. A guarantee stated there and absent from the machinery is the exact drift this repo exists to end, and every future consumer inherits the gap silently: adoption looks green, the board keeps a `question` label the taxonomy explicitly rejects. ## The spec Retire the six defaults in `bootstrap_labels()`, from one registry, tolerating both absence and refusal. - **The list lives next to the taxonomy it belongs to** — a `retired_label_names()` heredoc directly beside `core_label_rows()` in `labels-reconcile.sh`, carrying exactly the six names `LABELS.md` publishes. One registry, same file, same reason `core_label_rows()` is not in `labels.conf`: a rule that must hold in every governed repo cannot live in a per-repo file. - **Deletion is dispatch-only**, inside `bootstrap_labels()`, alongside the upserts. The cron sweep does not delete, for the reason the header comment already gives for not creating: ~20 mutations is too chatty for a 15-minute tick. - **Every deletion goes through `run`** ([L59-61](https://github.com/heavy-duty/ceremony/blob/b45202f6f4fb87d3b2315fd3c501c4254b6ddee5/actions/labels-reconcile/labels-reconcile.sh#L59-L61)), so `DRY_RUN=1` narrates it like every other mutation. - **A missing label is the normal case, not a failure.** `gh label delete` exits non-zero when the label does not exist, and the script executes under `set -e`. An unguarded call therefore aborts the whole dispatch on the *second* run of every repo — the same shape as [#91](https://github.com/heavy-duty/ceremony/issues/91), where a bare `return` on a correct no-op exited 1. Absence is success and is logged at most once per name. - **A refusal is tolerated and logged, never fatal.** `blocker:drill-pending` is already on record in `LABELS.md` as a row the bot bootstrap 403s on; a delete can 403 the same way. Log the name and continue to the next one — a token that cannot delete must still get the taxonomy it can create. - **Deletion removes the label from any issue carrying it.** That is intended and is what "retired" means here; these six are the set the taxonomy replaces by construction. - **Consumers receive this at their next pin bump.** Every consumer pins `@0.1.0`, which predates this fix, exactly like the `offsite`, `needs-ruling` and `attention` rows. No consumer's board changes until it bumps and re-dispatches — including incubator's surviving `good first issue`, whose actual cleanup is the operator button tracked on [#1](https://github.com/heavy-duty/ceremony/issues/1). - **`LABELS.md` is not edited.** Its sentence becomes true; changing it would be the wrong repair of the two. ## Tasks - [ ] Add `retired_label_names()` beside `core_label_rows()` in `actions/labels-reconcile/labels-reconcile.sh` with the six names from `LABELS.md`. - [ ] Delete each of them in `bootstrap_labels()` through `run`, after the upsert loop, tolerating a missing label and a refusal without aborting the dispatch. - [ ] Extend `test/labels-reconcile.test.sh` with the cases in the test plan below. - [ ] Confirm `docs/CONSUMERS.md` needs no change (the adoption checklist already says defaults are retired; verify and say so in the PR rather than editing on assumption). ## Acceptance criteria - [ ] `bootstrap_labels()` deletes all six of `duplicate`, `invalid`, `question`, `wontfix`, `help wanted`, `good first issue`, and no seventh name. - [ ] The list of six exists exactly once in the tree, in `labels-reconcile.sh`, and matches `LABELS.md` name for name. - [ ] A dispatch against a repo where none of the six exist completes green and creates the full taxonomy — verified by an **executed subprocess**, not a sourced call, so `set -e` is live (the #91 lesson: sourcing hides exactly this class of bug). - [ ] A dispatch where one delete is refused still deletes the rest and still upserts every taxonomy row. - [ ] `DRY_RUN=1` narrates each deletion and performs none. - [ ] The cron and `pull_request_target` paths delete nothing. - [ ] `./test/run.sh` green. ## Test plan - **Happy path** — `run` stubbed to record; assert the six delete calls appear, in the same dispatch as the upserts, and that the recorded upsert set is unchanged from today's. - **Missing label** — stub the delete to exit non-zero with GitHub's not-found message; assert `bootstrap_labels` returns 0 and the remaining deletions still ran. **Must fail** before the fix's guard is in place. - **Refusal** — stub one name's delete to exit non-zero with a 403; assert the other five ran, the taxonomy upserts ran, and the log names the refused label. - **`set -e` under execution** — invoke the script as a subprocess with `GITHUB_EVENT_NAME=workflow_dispatch` and a `gh` stub whose `label delete` always fails; assert exit 0. This is the case a sourced test cannot see. - **Dry run** — `DRY_RUN=1`; assert six `DRY_RUN: gh label delete` lines and zero real invocations. - **Wrong event** — `GITHUB_EVENT_NAME=schedule`; assert zero delete calls. ## Dependencies None. `Part of #1`.
claude-bot-andresmgsl commented 2026-07-23 21:06:02 +00:00 (Migrated from github.com)

Claiming — starting now. Branch build/93-retire-default-labels, draft PR coming shortly.

Claiming — starting now. Branch `build/93-retire-default-labels`, draft PR coming shortly.
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#93
No description provided.