ci: add backend-ci-gate, a Backend CI check that can actually be required - #2267
Conversation
|
Reviewed the workflow change and the gate script's decision table against the actual Process nit (per this repo's
Both should be stripped before merge per the project's own stated convention. Logic check (no bugs found, one latent operational caveat)
One thing worth being aware of operationally (not a bug in this PR, and not something it needs to fix): Self-test / script
Test coverage
Changelog fragment ( Nice catch overall — the two-sided failure mode (skip-reports-success vs. path-filter-never-reports) is a real and easy-to-miss GitHub Actions gotcha, and the historical replay + self-test give good confidence this won't regress silently. |
a39113b to
8d66253
Compare
…ired `main`'s branch protection has no `required_status_checks` object at all, so nothing gates a merge on CI having run, let alone passed. PR #2262 merged with Backend CI never having run on its head commit at all -- and because nothing was required, "no check reported" was not a blocker. The push that merged it then failed at the linter, which skipped `pytest` (0s); `main` sat that way for ~30 hours, repaired only by accident when an unrelated PR's `pre-commit run --all-files` happened to reformat the same file. Requiring *something* is therefore the fix, but requiring the `pytest` job is not, because it leaves a second hole open and opens a third: * GitHub reports a job skipped by its own `if:` as SUCCESS to branch protection. `pytest` is gated on `needs.linter.result == 'success'`, so a red linter skips it and a required `pytest` still reads green. This is not hypothetical: PRs #2260, #2264 and #2265 are all sitting at `linter=failure / pytest=skipped` right now, and would be mergeable under that policy with a red linter. * A workflow skipped by path filtering never reports its checks at all, so the required check hangs Pending forever. With `paths-ignore: docs/**` on the `pull_request` trigger, requiring any job here would make docs-only PRs permanently unmergeable. So the requirable check has to always run and inspect the other jobs itself, telling "skipped because this PR touches no backend code" apart from "skipped because something upstream broke". That is the new `gate` job; its decision table is `.github/scripts/backend_ci_gate.sh`, which carries a `--self-test` that the job runs on every invocation -- a gate whose own logic has silently inverted is worse than no gate. `paths-ignore` is dropped from the `pull_request` trigger for the reason above; the `changes` path filter still keeps the expensive jobs from running, so a docs-only PR now costs two ubuntu-latest jobs of a few seconds. `require_backend_ci_gate.sh` applies the protection change itself, because the obvious `gh api` call is a footgun: `PUT .../branches/main/protection` replaces the ENTIRE object (dropping review rules and the force-push/deletion bans unless they are re-sent), and the narrower `PATCH .../protection/required_status_checks` sub-resource 404s when no such object exists yet. It refuses to require a context name that has never been reported on the branch, since that would block every PR with no error anywhere. Verified by replaying the gate over the last 60 Backend CI runs: it blocks all 8 PR runs with a red linter and both of the merge-commit runs from #2262's window, and allows all 25 genuinely green runs and the 3 with no backend changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8d66253 to
905c727
Compare
ReviewThis is unusually well-engineered CI infra — the decision table in Minor, forward-looking robustness notes on
|
ReviewThis is a well-reasoned fix for a real gap (PR #2262 merging with Backend CI never having run). The decision-table approach in A few things worth addressing before (or shortly after) merge, mostly in 1. The rebuilt object doesn't actually merge
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Why
mainhas norequired_status_checksat all:Nothing gates a merge on CI having run, let alone passed. PR #2262 merged with Backend CI having never run on its head commit:
With nothing required, "no check reported" is not a blocker. The push that then merged it (
03e37b659) failed at the linter, which skippedpytest— 0 seconds.mainsat that way for ~30 hours, repaired only by accident when PR #2266'spre-commit run --all-fileshappened to reformat the same file.Why "just require the
pytestjob" isn't the fixRequiring something would have blocked #2262 (a check that never reports stays Pending). But requiring
pytestspecifically leaves a second hole open and opens a third:A job skipped by its own
if:reports SUCCESS to branch protection. (GitHub docs: "If a job within a workflow is skipped due to a conditional, it will report its status as Success.")pytestis gatedif: needs.linter.result == 'success', so a red linter skips it and the required check still reads green. Not hypothetical — three open PRs are in that state right now:linterpytest0469df80dd27a4d6325bd1cda74Under a required-
pytestpolicy all three are mergeable today with a red linter.A workflow skipped by path filtering never reports at all, so the required check hangs Pending and blocks forever. With
paths-ignore: [docs/**]on thepull_requesttrigger, requiring any job in this workflow would make docs-only PRs permanently unmergeable.What this adds
A
gatejob (check namebackend-ci-gate) that always runs and inspects the other jobs' results itself, distinguishing "skipped because this PR touches no backend code" from "skipped because something upstream broke"..github/scripts/backend_ci_gate.sh, run with--self-test(13 cases, ~0.1s) before each evaluation — a gate whose own logic has silently inverted is worse than no gate.paths-ignoredropped from thepull_requesttrigger only. Thechangesfilter still keeps the expensive jobs off, so a docs-only PR now costs twoubuntu-latestjobs of a few seconds.pushkeeps its filter..github/scripts/require_backend_ci_gate.shapplies the protection change. It exists because the obviousgh apicall is a footgun:PUT .../branches/main/protectionreplaces the entire object (silently dropping the review rules and the force-push/deletion bans unless re-sent), and the narrowerPATCH .../protection/required_status_checks404s with "Required status checks not enabled" when none exists yet. It also refuses to require a context name that has never been reported on the branch — requiring a typo'd name blocks every PR with no error anywhere.Verification
Replayed the gate over the last 60 real Backend CI runs:
No genuinely green run is blocked. Mutation-checked too: neutering the two result comparisons makes the self-test fail 7/13 rather than pass silently.
require_backend_ci_gate.shdry-run verified to reproduce the current protection object byte-for-byte plus the new key, and to refusebackend-ci-gatetoday (not yet onmain).Follow-up — merging this changes nothing on its own
Three things, in this order:
backend.yml, so it only exercisesbackend=true. Open a one-line docs-only PR and confirmbackend-ci-gatereports success withlinter/pytestskipped, and read the check name offgh api .../check-runsrather than trusting this description.bash .github/scripts/require_backend_ci_gate.sh --apply. The 8 currently-open PRs will each need a merge frommainbefore the check appears on their heads.Two governance decisions this PR deliberately does not make:
enforce_adminsisfalse. Admins keep "merge without waiting for requirements to be met", so the gate is advisory for exactly the merge path feat(agents): system_instructions_mode — let an agent config EXTEND the corpus persona instead of replacing it #2262 took. Requiring the gate without flipping this is still a real improvement, but it is not airtight.paths:filters, so their checks are not safely requirable as-is — each would hang Pending forever on any PR its filter excludes. Swept rather than spot-checked:frontend.yml,frontend-e2e.yml,frontend-e2e-extract.yml,frontend-e2e-websocket.yml,production-stack.ymlandredis-integration.yml. Each needs the same gate treatment before it can join the required list. (backend.ymlkeepspaths-ignoreonpushonly, which is safe — branch protection does not gate pushes.)🤖 Generated with Claude Code