Skip to content

feat(fr-gate): make the FR gate self-clearing and self-explanatory#68

Closed
LukasWodka wants to merge 4 commits into
developfrom
feat/fr-gate-self-clearing
Closed

feat(fr-gate): make the FR gate self-clearing and self-explanatory#68
LukasWodka wants to merge 4 commits into
developfrom
feat/fr-gate-self-clearing

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The problem

The FR gate is right about what it checks and wrong about when. It fires the instant a promotion PR opens — before anyone has moved the kanban cards — so the first run is structurally doomed. Clearing that red then requires a human to notice it, open the workflow log to find out which item is at fault, and press "Re-run jobs".

Measured across 60 days:

Gate runs 448, 58% failed
Runs retried by hand 30%
Retries that then passed 96%
Share of the fleet's 151 same-commit fail→pass events 128 of them
Org-wide CI flake excluding this gate 1.26% → 0.20%

A check that is red 58% of the time, and that a human fixes by pressing a button that works 19 times out of 20, has stopped being a signal. Nothing here changes what the gate checks. It makes the gate say what it wants and then clear itself.

Live right now, for illustration: cli#397 and client#383 are both on attempt 4, data-ingestors#396 on attempt 2 — all still red, all waiting on a human.

What ships

1. fr-gate.yml — one sticky comment that says what blocks it

On a blocked promotion the gate posts a single comment on the PR:

⛔ FR gate: 2 item(s) not ready to promote to main

Item Status now Needs to be
#1234 FR on staging Ready for prod (or later)
#1235 Code review Ready for prod (or later)

To clear this, for each item above either comment /fr-pass on it, or drag its card to Ready for prod on the engineer kanban. The gate then re-runs itself. You should not need to press "Re-run jobs".

Found and updated in place via a hidden <!-- fr-gate-sticky …> marker, so N runs leave one comment rather than N. When the gate goes green the comment is closed out; if the gate never failed, nothing is posted at all — the happy path stays silent.

2. fr-pass-comment.yml/fr-pass clears the red immediately

Advancing a card is exactly the input the gate reads, so after a successful advance this now re-runs the FR gate on the repo's open promotion PRs. This is the highest-value half: it turns the most common unblock action into a self-clearing one, in about a minute.

3. fr-gate-recheck.yml (new) — a sweep for the drag-on-the-board path

Dragging a card on the board fires no event anywhere: GitHub has no trigger for a Projects v2 field change, and a promotion PR has no event of its own. A cron is the only mechanism available. It runs centrally in this repo (same shape as auto-classify.yml and kanban-reconcile.yml), four times an hour at off-peak minutes.

It is not wasteful, because it is not blind: it reads the blocking item numbers out of the sticky marker, re-checks only those, and presses re-run only once they have actually reached the required column. A genuinely-blocked PR costs one read per sweep and zero re-runs. There are ~9 open promotion PRs org-wide at any time, and this repo is public, so the Actions minutes are free.

Design decisions

Re-run the existing run; never publish a status. Branch protection requires the check by name (gate / gate — verified on cli's main). Only a real run of that job satisfies it; a synthetic commit status of the same name would sit alongside the failed check run, not replace it. Re-running is also precisely what humans do today — the automated behaviour is already understood and already known to work 96% of the time.

The sweep is a trigger, not a second gate. Nothing in fr-gate-recheck.yml decides whether a promotion may merge; it only decides whether pressing re-run is worth a minute. The re-run it presses is a full, normal, authoritative gate run that can still fail. So the worst a bug in that file can do is re-run too often (noise) or too rarely (today's status quo). It cannot let an unready item through. That invariant is written at the top of the file in block capitals for whoever edits it next.

Read the marker instead of re-deriving the item list. Duplicating the commit-subject discovery logic in a second workflow is the one change here that could actually diverge from the gate. So the gate publishes what it blocked on, and the sweep consumes it. The rank table is duplicated — but only as a "is it worth re-running yet" heuristic, annotated as such.

Predict the gate, don't re-implement it. The sweep's item check is the gate's own predicate: at-or-beyond by rank, with the same exact-match clause, and — importantly — the same treatment of items that are not on the kanban at all. The gate skips those (⚪ not on kanban, skipping) and can pass with them present, so the sweep must not count them as blockers or it would strand a PR the gate would happily let through. item_status() therefore distinguishes "the query succeeded and this item is genuinely off the board" from "the lookup failed", which are not the same fact and must not produce the same decision.

Never press the button when a verdict is already coming. Both re-run paths skip when any fr-gate run for that head is queued or in progress. A label change, a push, or a second /fr-pass seconds after the first all start a fresh run, and re-running the older completed one alongside it would just produce two runs answering the same question. Scoped by workflow path, so an unrelated workflow on the same commit does not suppress a legitimate re-run.

Backstop. Whenever the heuristic above still says "blocked" — including when there is no marker to read at all — and the red run is ≥4h old, the sweep stops guessing and re-runs the gate to let it answer for itself. That covers a stale marker, a repeatedly-failing lookup, and any future drift in the duplicated rank table, without this file needing to know every rule the gate has. Self-throttling: a re-run resets the run's updated_at, so it fires at most once per 4h per PR. This is also what makes the activation gap below harmless.

The PAT, not GITHUB_TOKEN, posts the comment. A reusable workflow can only narrow the caller's permissions, never widen them. Relying on pull-requests: write would mean editing the caller in all ~17 repos and would silently no-op wherever the org default is read-only. PROJECTS_KANBAN_TOKEN is already inherited everywhere this gate runs. No caller repo needs any change.

Loop guard. The sticky comment spells out /fr-pass as the remedy and is posted by an account whose author_association passes the collaborator check — so without a guard, the gate would advance the promotion PR's own card the moment it explained why the promotion was blocked. fr-pass-comment.yml now skips comments starting with the sticky marker. Only the initial POST could ever reach it; later runs edit in place, and that workflow subscribes to created only.

Considered and rejected

  • workflow_dispatch on the caller — the callers are pull_request-triggered; there is nothing to dispatch.
  • Making the gate non-blocking / advisory — the gate is load-bearing; the ask was to keep it blocking.
  • Re-running on a pull_request event — the promotion PR does not change when a card moves. No event exists.
  • A per-repo scheduled caller — 17 files to roll out and 17× the schedule cost, for something one central cron does better.

Blast radius — deliberately untouched

This is consumed by ~17 repos, so the diff is purely additive: 557 insertions, 0 deletions.

  • Item discovery (the git log --format='%s' extraction and its fallback) — not a character.
  • The rank() table and the at-or-beyond comparison — not a character.
  • Pass/fail semantics: the "Verify each item" step is still the only thing that sets the exit code. The comment step is continue-on-error and cannot turn a blocked promotion green, or a clean one red.
  • The skip-fr-gate override — byte-for-byte as it was; the sweep explicitly leaves labelled PRs alone.
  • The job id gate, i.e. the required check name gate / gate. No new job was added to the reusable workflow, so no new check appears anywhere.
  • permissions: on fr-gate.yml — hardening it would change inherited permissions in 17 repos for no benefit here.

Activation order (worth knowing)

The callers pin tracebloc/.github/.github/workflows/…@main, but this repo's default branch is develop, and scheduled workflows only run from the default branch. So on merge to develop:

  • Part 3 (the cron) goes live immediately — and, finding no markers yet, uses the 4h stale fallback. Which is already better than today.
  • Parts 1 and 2 go live on the normal develop → main promotion, at which point markers appear and the sweep switches to its fast, targeted path.

No action needed, just don't be surprised by the staggering.

actionlint

Run locally with actionlint 1.7.12 -no-color -oneline -shellcheck shellcheck (shellcheck 0.11.0), matching #66's CI invocation.

Tree Findings
develop (baseline) 29
this branch 29 — same finding set, only line numbers shifted
fr-gate-recheck.yml (new file, 305 lines) 0
this branch + #66 merged 0

Zero findings introduced. The two # shellcheck disable=SC2016 directives in the new file are per-line with reasons, matching #66's stated convention — both are GraphQL variables that must not expand in shell.

Overlap with #66 / #67

Testing

The shell was extracted from each run: block and executed locally against a mocked gh, since this ships to ~17 repos. 12 sweeper scenarios plus the /fr-pass path, all green:

  • Sticky comment — blocked/no existing comment → POST; blocked/existing → PATCH the same id (no duplicate); pass/existing → PATCH to resolved; pass/none → no API write at all.
  • $GITHUB_OUTPUT — confirmed the multi-line heredoc round-trips and is still written when the step exits 1.
  • Sweep, 7 scenarios — cards moved → re-run; still blocked → no re-run; skip-fr-gate → skipped; gate already green → skipped; no marker + 1h → wait; no marker + 9h → re-run; forged marker containing shell metacharacters → refused, no re-run, no injection (only digits are ever passed onward; anyone can comment on a public repo).
  • CapsMAX_RERUNS and DRY_RUN both verified.
  • Marker position (added after Bugbot's third finding, fixed in d7a08ca) — sticky in the head of the thread, in the tail, in both, and in neither.
  • In-flight suppression (added after Bugbot's second finding, fixed in 1731754) — gate already running → skipped; unrelated workflow running → re-run still fires.
  • Gate-parity regressions (added after Bugbot's first finding, fixed in 5519d35) — item off the board → re-runs, because the gate would pass; lookup failed → no re-run and no guessing; heuristic says blocked but run is 10h old → backstop fires.
  • /fr-pass step — re-runs red gates, ignores green ones, ignores develop-targeting PRs, falls back to the second credential when the first lacks actions:write.

Also dry-run against the live org (read-only): it enumerated all 9 open promotion PRs, correctly identified which carry a red gate run, and applied the age rule per PR.

One real bug was found by the harness itself: the original age calculation used GNU date -d, and a malformed value made the loop exit early while still reporting success. It now computes the age inside the same jq call that selects the run, so it is one integer out of one command.

Bugbot

Three findings, all genuine, all fixed and replied to in thread: gate-parity on not-on-kanban items (5519d35), duplicate re-runs while a verdict was already in flight (1731754), and the sticky marker's comment-window cliff (d7a08ca). Each fix came with regression fixtures.

Rollback

Delete fr-gate-recheck.yml and revert the two additive hunks. The gate returns to exactly its current behaviour.

Part of the CI/pipeline health work in tracebloc/backend#930.

🤖 Generated with Claude Code

The FR gate is correct about what it checks and awful about when it
checks it. It fires the instant a promotion PR opens, which is before
anyone has moved the kanban cards, so the first run is structurally
doomed — and clearing that red then requires a human to notice it and
find the "Re-run jobs" button.

Over 60 days: 448 runs, 58% failed, 30% were retried by hand, and the
retry passed 96% of the time. The gate alone accounts for 128 of the
fleet's 151 same-commit fail→pass events; excluding it, org-wide CI
flake drops from 1.26% to 0.20%.

Three additions, no change to what counts as ready:

1. fr-gate.yml posts ONE sticky comment naming each blocking item, its
   Status now, and the Status it needs. Found and updated in place by a
   hidden marker, so N runs leave one comment, not N. Silent when the
   gate never failed. The marker also carries the blocking item numbers.

2. fr-pass-comment.yml re-runs the gate on the repo's open promotion PRs
   after it advances a card, so /fr-pass clears a stale red in a minute.

3. fr-gate-recheck.yml sweeps the org 4x/hour for the drag-on-the-board
   path, which fires no event at all. It reads the marker, re-checks only
   those items, and presses re-run only once they have actually moved.

Re-running the existing run — not publishing a status — is what keeps
the required check "gate / gate" satisfiable by name. The sweep is a
trigger, never an authority: the re-run it presses is a full, normal,
authoritative gate run that can still fail.

Untouched: item discovery, the rank table, pass/fail semantics, the
skip-fr-gate override, and the job name the branch protections require.

Also guards fr-pass-comment.yml against its own sticky comment, which
spells out "/fr-pass" as the remedy and is posted by an account whose
author_association passes the collaborator check.

Refs tracebloc/backend#930

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@LukasWodka

Copy link
Copy Markdown
Contributor Author

👋 Heads-up — Code review queue is at 40 / 30

Above the WIP limit. The team convention is to review existing PRs before opening new work.

Open PRs currently in Code review (oldest first):

Pull from review before opening new work. (This is a nudge from the kanban WIP check, not a block.)

Comment thread .github/workflows/fr-gate-recheck.yml Outdated
Bugbot caught a real divergence. fr-gate.yml skips items that are not on
the kanban ("⚪ not on kanban, skipping") and can pass with them present.
The sweep counted the same items as blocking, so a card leaving the board
made the targeted path refuse to re-run — and because the age fallback
only ran when there was no marker at all, that PR stayed red until a
human pressed the button. Exactly the failure this change exists to end.

item_status() now separates the two cases it was conflating: "" means
genuinely not on the board (skip it, as the gate does) and "?" means the
lookup failed (conclude nothing, retry next sweep). Collapsing them the
other way — treating every API hiccup as "off the board" — would have
turned each hiccup into a re-run.

The blocked predicate now also carries the gate's exact-match clause, so
the two are the same test rather than merely similar ones.

Also generalises the age fallback into a backstop that applies whether or
not a marker was read: if the heuristic still says "blocked" but the gate
run is older than STALE_HOURS, stop guessing and let the gate answer. That
covers this bug, a stale marker, and any future drift in the duplicated
rank table. Self-throttling — a re-run resets updated_at, so it can fire
at most once per STALE_HOURS per PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread .github/workflows/fr-gate-recheck.yml
Second Bugbot finding, also real. Both re-run paths selected the newest
*completed* gate run and ignored the possibility of another gate run for
the same head being queued or in progress. A label change, a push, or a
second /fr-pass seconds after the first all start a fresh run — and both
paths would then re-run the older completed one alongside it, producing
two runs that answer the same question.

Both selectors now report "INFLIGHT" when any fr-gate run for that head
is not yet completed, and both skip on it. A verdict already coming is
precisely the case where pressing the button buys nothing.

Scoped to fr-gate runs by workflow path, so an unrelated workflow running
on the same commit does not suppress a legitimate re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread .github/workflows/fr-gate-recheck.yml Outdated
Third Bugbot finding. The sweep pulled comments(first: 100) only. The
gate posts its sticky on the first failure and thereafter edits it in
place, so the marker sits wherever the thread was at that moment — near
the top on an ordinary promotion PR, near the bottom on one that was
already busy. A first-100 window misses the second case, and the sweep
then falls back to the 4h blind path instead of clearing in ≤15 min.

Now fetches head and tail as two aliased connections on the same query
and looks in both. No extra round-trip. A thread long enough to bury the
marker between the two windows still degrades to the age backstop, which
is the right floor for a case that cannot be read cheaply.

Theoretical today — the org's nine live promotion PRs carry 0-2 comments
each — but the marker is the mechanism the fast path depends on, so it
should not have a comment-count cliff in it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit d7a08ca. Configure here.

@LukasWodka

Copy link
Copy Markdown
Contributor Author

Superseded: #71 (fail-closed + 5× retry) and #75 (rebase-merge attribution) merged the correct retry/attribution logic. The self-clearing approach here now conflicts and would re-open the card-drag bypass. Closing (→ Cancelled); reopen only to salvage a specific piece.

@LukasWodka LukasWodka closed this Jul 26, 2026
Sign up for free to 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