Skip to content

ci(set-status): add the missing card instead of losing the race (backend#2731) - #370

Merged
LukasWodka merged 2 commits into
developfrom
fix/2731-set-status-adds-the-card
Aug 28, 2026
Merged

ci(set-status): add the missing card instead of losing the race (backend#2731)#370
LukasWodka merged 2 commits into
developfrom
fix/2731-set-status-adds-the-card

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What

set-status and add-to-kanban fire on the same pull_request event and start together. set-status polled 5×5s for the PR's card and failed when it was not there; add-to-kanban frequently won seconds later. So the card ended correct and the check ended red.

On .github#362 the board was already in the exact state set-status would have written — the job's purpose was achieved and only its verdict was wrong. That is the worst shape for a check to fail in: nothing is broken, so the habit it teaches is to merge past red.

Why end the race rather than widen the window

The retry budget's own comment held it at 5×5s deliberately, to find out how often the race is actually lost:

"Widening it in the same change would hide how often the race is actually lost… If this turns out to be noisy, the measurement tells us what the window should be — guessing does not."

That measurement arrived (backend#2731: #361 and #362, both within seconds). A bigger number would still be a guess, and a longer wait is still a wait on a sibling workflow that may simply never have run.

So set-status adds the card itself when it is absent. Three things make that safe, and I checked each rather than assuming:

  • It already holds a token with permission-organization-projects: write and permission-pull-requests: read — no permission change, and mint-scope still reports 0 unscoped, 0 findings.
  • addProjectV2ItemById is idempotent — adding an item already on the board returns the existing item id.
  • add-to-kanban does exactly one thing (an actions/add-to-project step and nothing else), so there is nothing to duplicate. This was the ticket's own "check first" caveat.

Still fail-closed. backend#2037's guarantee is unchanged: an unresolvable PR node, or an add that returns no item id, goes red exactly as before. Only "not yet" stops reporting as "missing". Per the ticket, the failure was not downgraded to a warning.

Tests

Nothing covered this file's status-write block. The closing-ref suite already owns assertions about set-pr-status.yml, so four went there (156 → 160 assertions), plus two WORKFLOW_MUTATIONS — per that list's own note that rule 5 does not exempt a guard for living in YAML.

Both mutations are caught, each by its own named assertion (55 mutation(s): 0 stale, 0 uncaught):

mutationcaught by
set-status stops adding the missing card"set-status adds the card itself when it is absent"
a card that could not be added reports success"a failed add still FAILS CLOSED"

They were UNCAUGHT on the first attempt, and that is the part worth reading. Two separate causes, and I had asserted the fix was covered when it was not:

  1. The patch that was supposed to add the assertions died on a parse error before running, so the suite's assertion count never moved — I read the unchanged 156 as "152 + my 4" and believed the tests existed.
  2. The assertions as first drafted were vacuous anyway. "addProjectV2ItemById" in HOST passes under the mutation that deletes the call, because the identifier also appears in the comment above it explaining that the add is idempotent — documenting the fix keeping the assertion green, the mirror image of the backend#2632 trap. And a bare "exit 1" in HOST is satisfied by any other refusal in the file.

Both now pin the call, not a token: "addProjectV2ItemById(input: {projectId:", and a regex tying the refusal message to its exit 1 as one sequence. The harness reporting UNCAUGHT is the only reason either was found.

Verification

  • make selftestsrc=0
  • make mint-scope0 unscoped, 0 exempted, 0 findings
  • python3 scripts/tests/closing-ref-gate-mutations.py55 mutations, 0 stale, 0 uncaught
  • actionlint + yaml.safe_load on the changed workflow → clean
  • worktree restored clean after every mutation run (one killed run left standards-sync.py mutated — the backend#2441 corruption shape; restored and re-run from a verified-clean baseline before the result above was taken)

What I could NOT verify, and it matters

This PR does not exercise the new path..github's own set-pr-status-caller.yml pins set-pr-status.yml@main, so the running job on this PR is main's copy. The fallback only takes effect after promotion to main, and its first real exercise is the first PR that loses the race afterwards. The four assertions read the workflow source; they cannot prove the API call succeeds.

Per the ticket's Done when: the first half ("two consecutive PRs land with set-status green on the first attempt") is only observable post-promotion. The second half ("a PR whose card genuinely never appears still fails") is what the fail-closed mutation pins.

Part of tracebloc/backend#2731


Note

Medium Risk
Touches CI board writes (GraphQL add + status update) on every PR event; mistakes could duplicate cards or mask genuine board failures, though idempotent add and unchanged fail-closed paths limit blast radius.

Overview
Fixes false-red set-status checks when add-to-kanban adds the card a few seconds later: after the existing 5×5s poll, the job now adds the PR to the org project via GraphQL (pullRequest node id → addProjectV2ItemById) instead of treating “not yet” as “missing.” The 5×5s window is unchanged; the race is ended by doing the add locally with the token that already has organization-projects: write, relying on idempotent project adds.

Fail-closed behavior is preserved for real failures (unresolvable PR node, add returns no item id); only the error copy distinguishes “could not be added” from the old race wording.

Tests:closing-ref-gate-selftest.py gains four workflow-source pins on the mutation call, PR node lookup, and the refusal/exit 1 pair; closing-ref-gate-mutations.py adds two YAML mutations so removing the fallback or softening the final refusal reddens the suite.

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

LukasWodkaand others added 2 commits August 28, 2026 07:53
…end#2731)
set-status and add-to-kanban fire on the same pull_request event and start
together. set-status polled 5 x 5s for the card and failed when it was not
there; add-to-kanban frequently won seconds later, so the card ended CORRECT
and the check ended RED. On .github#362 the board was already in the exact
state set-status would have written -- the job did its work and reported
failure anyway, which is the shape that teaches people to merge past red.
The old comment held the retry budget at 5 x 5s deliberately, to measure how
often the race is lost rather than guess a window. That measurement arrived
(#361 and #362, both within seconds), so this ends the race instead of
widening the window: the job already holds a token with
organization-projects: write, and addProjectV2ItemById is idempotent, so it
adds the card itself when it is absent. add-to-kanban does exactly one thing
-- an actions/add-to-project step -- so there is nothing to duplicate.
Still fail-closed: an unresolvable PR node, or an add that returns no item
id, goes red exactly as before. Only "not yet" stops reporting as "missing".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ckend#2731)
Nothing covered set-pr-status.yml_s status-write block. The closing-ref suite
already owns assertions about that file, so the four new checks live there.
Scope stated in the file rather than implied: these read the workflow SOURCE,
so they cannot prove the fallback works against the real API -- and this
repo_s own caller pins @main, so the PR that lands the fix does not exercise
it either. What they catch is the regression that matters: deleting the
fallback, or softening the refusal back to a pass.
Both are registered as WORKFLOW_MUTATIONS, per that list_s own note that rule
5 does not exempt a guard for living in YAML.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 28, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The card ended correct and the check ended red… nothing is broken, so the habit it teaches is to merge past red." That is the right reason to treat this as urgent rather than cosmetic. I saw the red on #361 two days ago and read it as a real failure; a check that fails while its purpose is achieved costs more than one that never ran.

The part I most want to credit is that this honours the prior decision's terms instead of overriding them. The 5×5s budget was held deliberately, with its own comment saying "widening it in the same change would hide how often the race is actually lost… the measurement tells us what the window should be — guessing does not." The measurement then arrived — #361 and #362, both lost within seconds — and the conclusion drawn from it is that a bigger number is still a guess and a longer wait still waits on a sibling workflow that may never have run. A deferred decision that actually got measured and then resolved on the evidence is rarer than it should be.

The three safety claims are each stated as checked rather than assumed, which is the right form, and I verified the one that carries the weight:

Fail-closed holds on both paths. An unresolvable PR node exits 1; an add that returns no item id exits 1. And the structure is better than the description promised — the re-check is a separate block outside the add path, so one guard covers "no add was attempted and the card is still absent" and "the add ran and produced nothing" rather than trusting the mutation's return inline. Both spellings of absence are tested (-z and = "null"), which matters because jq -r prints the literal string null and // empty does not catch that.

"The board write cannot proceed; this is not a race" in the error text is a small thing worth keeping: the log distinguishes itself from the case this PR fixes, so the next person reading a red set-status knows immediately which of the two they have.

And the scope claim is the right one to have made — an existing organization-projects: write grant, no widening, mint-scope still 0 unscoped, 0 findings. Taking over a sibling's work is only safe when it needs no new permission to do it, and checking that add-to-kanban does exactly one thing closes the duplication question the ticket itself raised.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 0bde277 into developAug 28, 2026
21 checks passed
@LukasWodka
LukasWodka deleted the fix/2731-set-status-adds-the-card branch August 28, 2026 06:42
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.

2 participants

@LukasWodka@saadqbal