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
|
|
|
|
fix(forgejo): a read failure names its verb too, and the tests assert the whole diagnostic (#192)
@codex-reviewer-andresmgsl caught a test that describes evidence it does not
collect — mine, and it is the class this PR is about.
Two cases were titled "naming the verb, path and status" and asserted only the
substring "500". The PUT boundary happened to satisfy the contract because
forgejo_write already passes "PUT $endpoint" to forgejo_http_ok. The GET
boundary did not: the diagnostic was `HTTP 500 from 'repos/o/r/issues/5'`, with
no verb at all — so a caller could not tell a failed READ from a failed WRITE
of the same path, and #192's acceptance criterion asks for exactly that
distinction.
Reads now pass "GET $endpoint" on both non-paginated and paginated paths, and
the two tests assert the complete expected diagnostic as one substring rather
than a status code that any failure would contain. Reverting the verb reds the
GET case.
Also, per the same review: the failed GET is asserted to write nothing, and the
failed PUT to have attempted exactly one write.
forge-backends 117/117 (was 115), 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:11:33 +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`
|
2026-08-05 12:54:29 +00:00
|
|
|
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).
|