Skip to content

fix(kanban): fail closed in the three mutators that exit 0 on failure (backend#1412) - #122

Merged
LukasWodka merged 1 commit into
developfrom
fix/1412-fail-closed-mutators
Aug 1, 2026
Merged

fix(kanban): fail closed in the three mutators that exit 0 on failure (backend#1412)#122
LukasWodka merged 1 commit into
developfrom
fix/1412-fail-closed-mutators

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Implements tracebloc/backend#1412. Ports the fail-closed pattern from advance-deploy-env.yml (RUN_FAILED set at the failure sites, asserted after the loop, stderr captured rather than discarded) into the four call sites that counted failures, printed them, and exited 0.

The shared failure mode: a green run meant "the workflow finished", not "the board is correct". All of these are scheduled daily passes, so a persistent cause — revoked project write, a deleted Status option, a token that lost a scope — failed identically every day behind an unbroken green history. Nothing here is a new capability; in three of the four cases the counters already existed and only the assertion was missing.

What changed, per file

.github/workflows/kanban-archive.yml — "Archive them" (:103-136)

A failed archiveProjectV2Item left terminal work on the board, which is the single thing this workflow exists to prevent, and reported success. At ~800 items/month reaching Prod the board would degrade slowly enough to look like normal growth.

  • Asserts fail -eq 0 after the loop.
  • > /dev/null 2>&1 destroyed the API error entirely. Now 2>&1 >/dev/null, so stderr is kept and the payload (never read) is dropped. The warning carries the actual error.
  • The step summary is still written before the exit, so it survives the failure.

.github/workflows/kanban-reconcile.yml — "Apply moves" (:346-400)

Same shape, in both the ARCHIVE and the Status-update branch. Every [FAIL] was a card left in the column this pass had just decided was wrong — i.e. the drift the run existed to remove survived it, and tomorrow's pass re-planned the identical move.

  • Asserts fail -eq 0; stderr captured into the [FAIL] line.

.github/workflows/kanban-reconcile.yml — membership add loop (:592-651)

[FAIL] ("could not add") and [WARN] ("Status set failed") were printed and never counted — no counter existed at all, so this step could fail on every single item and still go green.

  • Both branches now increment add_fail; the step exits non-zero.
  • [WARN] is counted as a failure, not tolerated, and is the worse of the two: a Status-less card renders in no column and is invisible in every column view. Confirmed nothing repairs it — the classify pass reads a null Status as "No status" and routes that to no-action for both PRs (:262) and issues (:307), and this sweep skips the card because it now is on the board. A [FAIL] card at least stays missing and gets rediscovered tomorrow.

.github/workflows/kanban-reconcile.yml — scope discovery (:426-487)

The add-to-kanban.yml probe used gh's exit code only, so 404 ("this repo does not opt into the board" — the answer we want) was indistinguishable from 403 / 429 / 5xx ("we could not find out"). Both silently dropped the repo from SCOPE, and a repo out of scope is never swept, so its drift became invisible while the run reported success.

  • Reads the HTTP status via gh api -i --silent (headers only, no base64 body) and fails on anything that is neither 2xx nor 404. A transport failure yields no status line and lands in the fatal branch — correctly, since that is also "we could not find out".
  • Adds a MIN_SCOPE: 1 floor assertion. The per-repo sweep emits "Missing from board: 0" identically whether nothing is missing or nothing was examined; the existing MAX_ADDS ceiling only catches the opposite failure (everything looks missing). The floor is deliberately loose — a "did we look at anything at all" tripwire, not a coverage target, so it never fires when a repo legitimately opts out.

How to verify

Lintactionlint 1.7.12 + shellcheck 0.11.0, the exact versions pinned in actionlint.yml, run with the same invocation as CI: 0 findings, exit 0 over the whole tree.

actionlint -no-color -oneline -shellcheck shellcheck

Behaviour — each failure mode was simulated against stubbed gh calls on bash 5 (matching the ubuntu runner; macOS bash 3.2 lacks mapfile). Reviewers can reproduce by stubbing a gh() shell function:

  • Scope probe: 200 + 404 mixed → happy path unchanged, rc=0, 404 skipped silently. 403 / 429 / no-status-line → rc=1 with the offending repo named. Empty repo listing and all-repos-untracked → caught by the floor, rc=1. Confirmed mapfile on empty input declares an empty array, so the floor is what fires rather than an unbound variable error under set -u.
  • Archive / apply loops: all-success → rc=0 and identical output to before; any failure → rc=1 with the captured stderr in the warning, and the payload ({"data":null}) correctly not in the message.
  • Add loop: both succeed → rc=0, added.tsv unchanged; Status-set failure → rc=1; add failure → rc=1.

Downstream steps still render. Checked that the new exits do not suppress reporting: "Per-label summary" is always(); "Discover + add items missing from the board" is always() && steps.ids.outcome == 'success', so the new Apply moves exit does not stop membership reconcile (preserving that step's documented intent); "Membership summary" reads steps.membership.outputs.count, written at :550, well before the new assertion at :645.

Success paths are unchanged — this is a failure-handling change only. No refactors, no reformatting.

Notes for the reviewer

  • The ticket's premise held for all four items; none was already fixed. Line numbers had drifted by ~10-60 lines, so the actual ranges are listed above.
  • Scoped to these two files only, per the ticket. fr-pass-comment.yml and advance-deploy-env.yml / fr-gate.yml are deliberately untouched.
  • advance-deploy-env.yml was read first and its pattern ported, not reinvented; the new assertions reference it by name in their comments.

Refs tracebloc/backend#1412

🤖 Generated with Claude Code


Note

Low Risk
CI-only workflow shell changes; success paths unchanged and failures become visible rather than altering happy-path behavior.

Overview
Scheduled kanban workflows no longer report success when GraphQL mutations or scope discovery fail silently. The change ports the fail-closed pattern from advance-deploy-env.yml: capture API stderr with 2>&1 >/dev/null, surface it in warnings/[FAIL] lines, and exit 1 when any counted failure remains.

In kanban-archive.yml, failed archiveProjectV2Item calls now fail the step after the step summary is written, so terminal cards cannot pile up behind a green daily run.

In kanban-reconcile.yml, Apply moves exits non-zero if any archive or Status update fails. Membership scope discovery treats only HTTP 404 as “not on board”; other statuses abort instead of dropping repos from the sweep, and MIN_SCOPE: 1 blocks a “clean” run over an empty repo list. The add loop tracks add_fail for both [FAIL] adds and [WARN] Status-set failures (Status-less cards are treated as hard failures).

Reviewed by Cursor Bugbot for commit d1d4439. Bugbot is set up for automated code reviews on this repo. Configure here.

Four call sites counted their failures, printed them, and exited 0. A
green run therefore meant "the workflow finished", not "the board is
correct" -- and because every one of these is a scheduled daily pass,
a persistent cause (revoked project write, deleted Status option,
token scope loss) failed identically every day behind an unbroken
green history. Ports the RUN_FAILED pattern already used in
advance-deploy-env.yml rather than inventing a second one.
What fails closed now that did not before:
- kanban-archive.yml "Archive them": a failed archiveProjectV2Item
left terminal work on the board -- the one thing the workflow
exists to prevent -- and reported success. Now asserts fail -eq 0
after the loop. The error text was also destroyed by
`> /dev/null 2>&1`; stderr is now captured and printed with the
warning. Step summary is still written before the exit.
- kanban-reconcile.yml "Apply moves": same shape, both the ARCHIVE
and the Status-update branch. Every [FAIL] was a card left in the
column the pass had just decided was wrong. Now asserts fail -eq 0
and captures stderr into the [FAIL] line.
- kanban-reconcile.yml membership add loop: [FAIL] ("could not add")
and [WARN] ("Status set failed") were printed and never counted, so
the step could fail on every item and still go green. Both now
increment add_fail and the step exits non-zero. [WARN] counts as a
failure because a Status-less card renders in no column and no pass
repairs it: classify reads null Status as "No status" -> no-action,
and the sweep skips it because it is now on the board.
- kanban-reconcile.yml scope discovery: the add-to-kanban.yml probe
used gh's exit code only, making 404 ("not tracked") identical to
403/429/5xx ("could not determine"), so a repo silently dropped out
of scope and its drift became invisible. Now reads the HTTP status
and fails on anything that is neither 2xx nor 404. Adds a MIN_SCOPE
floor because the per-repo sweep emits "Missing from board: 0"
identically whether nothing is missing or nothing was examined --
MAX_ADDS only caught the opposite failure.
Success paths are unchanged; this is failure handling only. Verified
with actionlint 1.7.12 + shellcheck 0.11.0 (0 findings) and by
simulating each failure mode against stubbed gh calls on bash 5.
Refs tracebloc/backend#1412
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@LukasWodka