ceremony/changelog.d/192.md

48 lines
2.2 KiB
Markdown
Raw Normal View History

fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192) Two defects, one cause, and the second is why the first survived a week. THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500 for every removal under the token the sweep actually holds — measured inside Actions, probe run 701, where the same `PUT .../labels` with the desired full set returns 200 including the empty set for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it went unseen: it fails only for `${{ github.token }}`. Net effect before this: on Forgejo the state machine could only ever ADD labels. Every `state:*` transition needing the previous state cleared and every `blocker:*` that should lift was inert. Both PRs open right now carry stale `blocker:*` labels that are false and that nothing can remove. So the removal path is read-current, compute-wanted, one PUT — the same shape the assignee branch beside it already used. An ADD-ONLY call keeps its additive POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered a concurrent set, and forge_labels_add stays pinned against ever doing that. The window is accepted here and only here, where the caller asked to REMOVE and no additive verb can say that. An unresolvable --add-label refuses before any write, so a replacement PUT can never drop a label nobody asked to remove. THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell through, and `main` printed `reconciled.` and exited 0 — while `issueflow-reconcile` treated the identical 500 as fatal. One cause, two contradictory policies, and the wrong one hid the write defect. A failed write is fatal now, and the tally reaches main's exit code. That second half is load-bearing: making reconcile_pr fatal alone is not enough, because the loop swallows a per-PR non-zero into a log line and finishes. The per-PR tolerance is right and stays — one bad PR must not blind the board — but it now applies to READS. A sweep that could not write exits non-zero and never prints `reconciled.` The diagnostic says what was attempted and that it did not happen. The old text blamed a missing label and told the operator to bootstrap, when the label was present and the call returned 500 — #101's rule is report, do not diagnose. Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases, removing the tally reds 2, restoring the DELETE loop reds 7. test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and actionlint clean. Refs #192
2026-08-05 12:48:09 +00:00
### Fixed
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
instance, so the state machine could only ever ADD labels (#192).
- Every `state:*` transition that needs the previous state cleared, and every
`blocker:*` that should lift, can now actually clear. They were inert (#192).
- A label edit that fails is fatal to `labels-reconcile`, matching
`issueflow-reconcile`. One cause had two contradictory policies (#192).
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192) @codex-reviewer-andresmgsl's four gaps, all real, all taken. 1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name and then re-resolved every preserved label through the repository-wide list — so preservation depended on a paginated read with nothing to do with this issue, and an incomplete one would drop a bystander. It now keeps name<TAB>id from the issue payload, subtracts removals by name, and resolves ONLY added names. Fixture: a bystander on the issue with id 14 that is absent from the repo-list fixture entirely must still survive the PUT. 2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing the write proved the sweep reached the forge. The GET already proves that, and replacing a set with itself opens ceremony#128's window for no state change — most calls here are exactly this case, since the reconcilers call --remove-label unconditionally. Short-circuits when the wanted set equals the current one. This was the policy-shaped choice flagged for @andres; the reviewer's reasoning is better than mine was. 3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary state edit, so clearing `merge-next` and both `stale` edits could fail into the generic per-PR branch and still finish `reconciled.` and exit 0. All four sites go through one `label_write` helper, so a future call site cannot reopen it by forgetting to mark itself. Probe: a failed NON-primary write (unstale on a blocked PR) must fail the sweep. 4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains "reconciled.", which a log-tail consumer greps for. The line is now "sweep incomplete", and the test asserts the whole output is free of the token rather than only of the success prefix. Also added the two fault boundaries the acceptance plan named and the fixtures never proved: a failed current-label GET and a failed replacement PUT, each non-zero with the backend's verb/path/status diagnostic. Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving preserved ids reds 7, writing the unchanged set reds 1. forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172), test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint clean. Refs #192
2026-08-05 13:03:25 +00:00
READS, but a sweep that could not write exits non-zero and its output carries
no `reconciled.` token at all (#192).
- Every label mutation goes through one checked helper, so clearing
`merge-next` or either `stale` edit fails the sweep too — not only the
primary state edit (#192).
- A preserved label keeps the id the issue payload already carried, so
preservation does not depend on a repository-wide list that has nothing to do
with the issue (#192).
- A removal that changes nothing writes nothing, rather than replacing the set
with itself and opening a race for no state change (#192).
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192) Two defects, one cause, and the second is why the first survived a week. THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500 for every removal under the token the sweep actually holds — measured inside Actions, probe run 701, where the same `PUT .../labels` with the desired full set returns 200 including the empty set for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it went unseen: it fails only for `${{ github.token }}`. Net effect before this: on Forgejo the state machine could only ever ADD labels. Every `state:*` transition needing the previous state cleared and every `blocker:*` that should lift was inert. Both PRs open right now carry stale `blocker:*` labels that are false and that nothing can remove. So the removal path is read-current, compute-wanted, one PUT — the same shape the assignee branch beside it already used. An ADD-ONLY call keeps its additive POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered a concurrent set, and forge_labels_add stays pinned against ever doing that. The window is accepted here and only here, where the caller asked to REMOVE and no additive verb can say that. An unresolvable --add-label refuses before any write, so a replacement PUT can never drop a label nobody asked to remove. THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell through, and `main` printed `reconciled.` and exited 0 — while `issueflow-reconcile` treated the identical 500 as fatal. One cause, two contradictory policies, and the wrong one hid the write defect. A failed write is fatal now, and the tally reaches main's exit code. That second half is load-bearing: making reconcile_pr fatal alone is not enough, because the loop swallows a per-PR non-zero into a log line and finishes. The per-PR tolerance is right and stays — one bad PR must not blind the board — but it now applies to READS. A sweep that could not write exits non-zero and never prints `reconciled.` The diagnostic says what was attempted and that it did not happen. The old text blamed a missing label and told the operator to bootstrap, when the label was present and the call returned 500 — #101's rule is report, do not diagnose. Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases, removing the tally reds 2, restoring the DELETE loop reds 7. test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and actionlint clean. Refs #192
2026-08-05 12:48:09 +00:00
- Every failure diagnostic on the forgejo backend names the verb as well as the
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
cannot be told from a failed write of the same path (#192).
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192) Two defects, one cause, and the second is why the first survived a week. THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500 for every removal under the token the sweep actually holds — measured inside Actions, probe run 701, where the same `PUT .../labels` with the desired full set returns 200 including the empty set for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it went unseen: it fails only for `${{ github.token }}`. Net effect before this: on Forgejo the state machine could only ever ADD labels. Every `state:*` transition needing the previous state cleared and every `blocker:*` that should lift was inert. Both PRs open right now carry stale `blocker:*` labels that are false and that nothing can remove. So the removal path is read-current, compute-wanted, one PUT — the same shape the assignee branch beside it already used. An ADD-ONLY call keeps its additive POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered a concurrent set, and forge_labels_add stays pinned against ever doing that. The window is accepted here and only here, where the caller asked to REMOVE and no additive verb can say that. An unresolvable --add-label refuses before any write, so a replacement PUT can never drop a label nobody asked to remove. THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell through, and `main` printed `reconciled.` and exited 0 — while `issueflow-reconcile` treated the identical 500 as fatal. One cause, two contradictory policies, and the wrong one hid the write defect. A failed write is fatal now, and the tally reaches main's exit code. That second half is load-bearing: making reconcile_pr fatal alone is not enough, because the loop swallows a per-PR non-zero into a log line and finishes. The per-PR tolerance is right and stays — one bad PR must not blind the board — but it now applies to READS. A sweep that could not write exits non-zero and never prints `reconciled.` The diagnostic says what was attempted and that it did not happen. The old text blamed a missing label and told the operator to bootstrap, when the label was present and the call returned 500 — #101's rule is report, do not diagnose. Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases, removing the tally reds 2, restoring the DELETE loop reds 7. test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and actionlint clean. Refs #192
2026-08-05 12:48:09 +00:00
- The diagnostic names what was attempted and that it did not happen, instead
of blaming a missing label and telling the operator to bootstrap — a cause it
had not established (#192, #101).
- An add-label the repo does not carry refuses before any write, so a
replacement `PUT` can never drop a label nobody asked to remove (#192).
### Added
- `test/forge-backends.test.sh` pins the replacement contract: preserve
unrelated labels across a combined add+remove, an absent removal as a
successful no-op, the empty set as a full clear, and `forge_labels_add`
still `POST`-only, per ceremony#128 (#192).
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192) Two defects, one cause, and the second is why the first survived a week. THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500 for every removal under the token the sweep actually holds — measured inside Actions, probe run 701, where the same `PUT .../labels` with the desired full set returns 200 including the empty set for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it went unseen: it fails only for `${{ github.token }}`. Net effect before this: on Forgejo the state machine could only ever ADD labels. Every `state:*` transition needing the previous state cleared and every `blocker:*` that should lift was inert. Both PRs open right now carry stale `blocker:*` labels that are false and that nothing can remove. So the removal path is read-current, compute-wanted, one PUT — the same shape the assignee branch beside it already used. An ADD-ONLY call keeps its additive POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered a concurrent set, and forge_labels_add stays pinned against ever doing that. The window is accepted here and only here, where the caller asked to REMOVE and no additive verb can say that. An unresolvable --add-label refuses before any write, so a replacement PUT can never drop a label nobody asked to remove. THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell through, and `main` printed `reconciled.` and exited 0 — while `issueflow-reconcile` treated the identical 500 as fatal. One cause, two contradictory policies, and the wrong one hid the write defect. A failed write is fatal now, and the tally reaches main's exit code. That second half is load-bearing: making reconcile_pr fatal alone is not enough, because the loop swallows a per-PR non-zero into a log line and finishes. The per-PR tolerance is right and stays — one bad PR must not blind the board — but it now applies to READS. A sweep that could not write exits non-zero and never prints `reconciled.` The diagnostic says what was attempted and that it did not happen. The old text blamed a missing label and told the operator to bootstrap, when the label was present and the call returned 500 — #101's rule is report, do not diagnose. Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases, removing the tally reds 2, restoring the DELETE loop reds 7. test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and actionlint clean. Refs #192
2026-08-05 12:48:09 +00:00
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
swallow was in the loop, where a fixture-level probe cannot reach (#192).