fix: require every named check to pass, not a matching count - #37
Open
wroszkowski wants to merge 2 commits into
Open
fix: require every named check to pass, not a matching count#37wroszkowski wants to merge 2 commits into
wroszkowski wants to merge 2 commits into
Conversation
github-flow-manager authorised a promotion by counting successful check runs across all required names and comparing that total to the number of names. The counter was flat, so a surplus on one name silently covered a deficit on another, and the exact-equality test also rejected commits where every name passed but one of them produced two successful runs. Duplicate results per name are routine, which is what made both directions reachable: a merge queue builds a commit twice, and a reusable workflow called by several callers publishes its check once per caller. Both failure modes were observed on DocPlanner/dbt-app, whose required checks were then run_app_tests, run_dbt_tests and build_push / build_push-dbt. While a merge queue was active, commit 7d7d1c8b reported run_app_tests twice (queue run plus push run) and run_dbt_tests as skipped, so 1 + 2 + 0 = 3 == 3 names and the commit was promoted to master while run_dbt_tests had never gone green. Once the merge queue stopped being used on 2026-08-03 the duplicate disappeared, the best any commit could score became 2 of 3, and promotion became structurally impossible: master did not move for four days while the job kept reporting success and "THERE IS NO COMMITS PASSING EVALUATION". Each required name is now evaluated on its own. It must have at least one result, none of them still queued or in progress, and every concluded result must have concluded SUCCESS. Several results for one name are fine as long as they are all green. The acceptance rule is deliberately unchanged: SUCCESS only, exactly what the old counter accepted. This is a counting fix, not a widening of what counts as green, so no repository starts promoting on a verdict it did not already accept. NEUTRAL in particular must keep failing: the devops-pipelines hotfix_aware_skip_check workflow publishes hotfix-skip-tests as SUCCESS to mean "authorised to promote without tests" and NEUTRAL to mean "not a hotfix", and monolith-app gates its hotfix promote on that single name, so accepting NEUTRAL would turn a fail-closed gate into a fail-open one. --accept-skipped-checks opts into treating SKIPPED as satisfied, for repositories that skip a required job on purpose. It defaults to off. The fallback path is untouched: without SPECIFIC_COMMIT_CHECK_NAME, StatusSuccess still comes from GitHub's own status check rollup. Names are now trimmed and empty entries dropped, so a stray separator no longer contributes a name that can never be satisfied, and an entirely unusable name list fails closed instead of promoting vacuously. Also adds a go build/vet/test job, since the repository had no test step in CI at all, and prints under --verbose why each commit was held back, the absence of which is what made the four-day freeze invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
carlos-lopez-vecino
approved these changes
Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Promotion was authorised by counting successful check results across all
required names and comparing that total to the number of names
(
github/manager.go,hydrateCommits+checkRunSet):checksPassedis one flat counter. It never verified that each required namepassed, so:
name produced two successful runs.
Duplicate results per name are not exotic here — they are routine. A merge queue
builds a commit twice (queue run + push run), and a reusable workflow called by
several callers publishes its check once per caller: on
monolith-app's currentdevelopHEAD,load_context / Load contextappears 4×.Evidence from DocPlanner/dbt-app
Required checks at the time:
run_app_tests,run_dbt_tests,build_push / build_push-dbt.While a merge queue was active, a
developcommit producedrun_app_teststwice:
build_push / build_push-dbtrun_app_testsrun_dbt_testsTotal 3,
numChecks3 → promoted tomasterwhilerun_dbt_testswas nevergreen. Verified on commit
7d7d1c8b.The merge queue then stopped being used on 2026-08-03.
run_app_testsdropped toone run, so the best any commit could score became 1+1+0 = 2 ≠ 3, and
promotion became structurally impossible:
mastersat unchanged from 2026-08-03to 2026-08-07 while the job kept exiting 0 and reporting
THERE IS NO COMMITS PASSING EVALUATION. Verified counts on0e6dae35,268fa5a3,11f61a6a,bd917063,b28faf42.One defect, both directions, silent in both.
The fix
Per-name coverage instead of a sum. For each required name:
SUCCESS.Several results for one name are fine as long as they are all green.
The acceptance predicate is deliberately unchanged —
SUCCESSonly, exactlywhat the old counter accepted. This is a counting fix, not a widening of what
counts as green, so no repository starts promoting on a verdict it did not
already accept.
Old vs. new on the same fixtures (generated from the committed test fixtures)
Note the second-to-last row: with a single required name, one success plus one
failure counted 1 == 1 and promoted. That is the masking bug sitting on
the most dangerous gate we have — see the next section.
❓ Decision for maintainers: what should
SKIPPEDandNEUTRALmean?This is a policy choice, not a technical inevitability, and I have deliberately
not encoded my own repo's preference as the default. Please rule on it.
I set out to make
SKIPPEDandNEUTRALsatisfy a required name — that isGitHub's own required-status-check semantics, and
dbt-app'srun_dbt_testsislegitimately skipped on
developpushes (change detection finds no dbt files,because the merge-base diff of
developagainst itself is empty). Whileenumerating the blast radius I found that default would introduce a worse bug
than the one it fixes, so I inverted it.
devops-pipelines/.github/workflows/hotfix_aware_skip_check.ymlpublishes itshotfix-skip-testscheck assuccess= "this commit is authorised to promotewithout tests" and
neutral= "not a hotfix":monolith-app/.github/workflows/hotfix.yamlthen gates the irreversible promoteon exactly that one name:
Accepting
NEUTRALwould flip that gate from fail-closed to fail-open: everyordinary commit would satisfy
StatusSuccess == trueon the hotfix path andpromote untested. So, as shipped here:
NEUTRALnever satisfies, and no flag can make it — a flag would justrelocate the footgun. Same as today's behaviour.
SKIPPEDdoes not satisfy by default, also same as today, with--accept-skipped-checksas an opt-in for repos that skip a required job onpurpose.
dbt-appdoes not need that flag, because it already solved the skipupstream: as of
7431e918itsrequired_checksaredbt-app CI merge readiness,build_push / build_push-dbt, wheredbt-app CI merge readinessis anif: always()aggregator that acceptssuccess|skippedper tracked job and emits a single literal green/red check.noa-whisper-appdoes the same withall_builds_passed. That is the patternworth recommending to other teams, and it keeps working under per-name coverage.
Open questions for you:
--accept-skipped-checksworth keeping at all, or should teams always bepushed to the aggregator pattern? I kept it because it is nearly free, but I
have no consumer for it — including my own repo.
NEUTRALstay permanently non-satisfying, or become configurable oncethe
hotfix-skip-testscontract lives somewhere more durable than a workflowcomment?
Blast radius
One invocation point, ~143 consumers, no staged rollout by default.
DocPlanner/devops-pipelines/.github/workflows/promote_commit_to_master.yml(job
promote,docker run ghcr.io/docplanner/github-flow-manager:${{ inputs.ghfm_version }}).Nothing in the org invokes the binary directly.
workflow files.
software-templatesskeletons carry the same pattern, sofuture repos inherit it.
status_check_nameson at least one path → affected bythis change. Most resolve the names from their own
.github/cicd.yml(
context.release.required_checks/hotfix_required_checks);noa-notes-app/noa-notes-front-appusecontext.promote.required_checks,and
noa-second-opinion-appuses a 4-entry matrix.specificChecksNames == ""→ the
StatusCheckRollupfallback → completely unaffected. That path isuntouched and now has a regression test (
TestHydrateCommitsFallsBackToRollup).They are
.github-private,ai-plugin-manager,backuper-app,crm-app,deployments-app,deployments-front-app,gmb-app,integrations-app,invoicing-app,legal-operations-front-app,manually-generated-sitemaps,marketplace-account-app,messenger-front-app,noa-notes-integration-docs,package-app-php-client,patient-request-app,payments-app,pdf-viewer-front-app,repman,restorer-app,watson-mobile.ghfm_versiondefaults tolatest, and.goreleaser.ymlmovesghcr.io/docplanner/github-flow-manager:lateston everyv*tag. Mergingthis PR ships nothing; tagging ships it to all 122 affected repos at once.
Who changes behaviour, and how
Newly blocked — failures that were previously masked. Any repo where the
successes happened to total N while some name was red, missing, or still pending.
Worst exposure is the 47 single-name repos, where one success plus one
failure counted 1 == 1 and promoted:
monolith-app's hotfix path,authorization-app,one-app,one-front-app,booking-front-app,platform-app,geocoder-app,crm-front-app,kraken-mcp-app,package-gateway,rabbit-sidecar-app,permissions-portal-front-app,revenue-features-front-app,widgets-front-app,noa-notes-hub-front-app,noa-whisper-app,docker-jupyter-hub,terapia-app,websockets-app, andevery other
build_push / build_push-*-only repo. This is the point of thePR, but it will surface previously invisible red checks as new promotion
blocks. Expect support questions.
Newly promoting — commits previously blocked by an inflated count. Same
single-name set: the moment a name resolved twice (routine for
reusable-workflow checks),
2 != 1froze promotion silently. Those repos startpromoting again.
Named, deliberate side effects:
noa-second-opinion-appdeclaresrun_tests (app), build_push (app) / build_push-noa-second-opinion— note thespace after the comma. Today
strings.Splityields a name with a leadingspace that can never match, so
numChecks=2can only ever reach 1 and thatrepo's automated promotion is currently dead. This PR adds
TrimSpace, soit will start promoting for the first time (correctly, on its real checks).
Worth telling that team. Happy to drop the trimming if you would rather fix it
in their config instead.
vendor-bills-es-apphas the literalrequired_checks: '[]';chat-appandopibot-appsethotfix_required_checks: 'build_push'where the real check runis
build_push / build_push-<app>. These are blocked today and stay blocked —but the
--verboseoutput now saysNEVER RAN - no commit status, workflow run or check run carries this nameinstead of a bare "no commits passing evaluation", so they become fixable
config rather than a mystery.
phone-front-appruns the invertedexpression: 'StatusSuccess == false', soper-name coverage changes when it promotes rather than whether. Flagged
because the inversion makes the blast direction non-obvious.
Known limitation, deliberately not fixed here: GraphQL page caps
github/types.gostill hardcodescheckSuites(first: 20)andcheckRuns(first: 25).monolith-app'sdevelopHEAD already carries 23check suites (atlantis, sentry, sonar, semgrep, claude, wip, dp-testings…), so
a required check can fall outside the window depending on ordering.
Truncation already blocks promotion today — a missing run also reduced the count —
so this PR does not make it worse. It does remove the accidental compensation
where a duplicate elsewhere covered a truncated one, and it makes truncation
diagnosable through the per-name summary. I did not bundle a fix on purpose:
#33 raised both caps to 100 and was fully reverted in #35 because it raised
GraphQL query cost ~20× (worst case ~2,526 points/call against the 5,000/hour
bucket). That needs real pagination, in its own PR.
post-office-app(10 names)and
secretary-ai-app(7 names) are the other exposed consumers.Also in this PR
CI ran no tests at all —
pull_requests_tests.yamlonly ran pre-commit.Added a
Unit Testsjob (go build/go vet/go test). It is not inbranch protection; someone with admin should add it as required.
--verbosenow explains why a commit was held back, one line per requiredname. A commit blocked by its checks previously looked identical to one blocked
by the expression — which is exactly why a four-day release freeze went
unnoticed while the job reported success:
Names are trimmed and empty entries dropped, so a stray separator (
a,b,) nolonger contributes a name that can never be satisfied. A list with nothing
usable (e.g.
",") now fails closed rather than promoting vacuously on anempty requirement set.
Evaluation logic moved to
github/checks.go;checkRunSetis gone.API surface
The CLI is unchanged apart from the new opt-in
--accept-skipped-checks; the,separator default in
cmd/root.gois untouched. Two exported Go signatures gainone
boolparameter (github.GetCommits,manager.Manage) andgithub.Commitgains a
ChecksSummary []stringfield. All in-repo callers are updated; nothingin the org imports the module — it is consumed as a container image.
Verification
40 test cases covering: the historical false-pass (duplicate success masking a
skip, a failure, and a missing name); the historical false-block (extra
successful run, and a single name reported twice); genuine failures across every
blocking conclusion; pending runs signalled both via
statusand via a nullconclusion; a missing name; all-green through check runs, commit statuses andworkflow-run names; the
monolith-appNEUTRALhotfix gate in both directions;name splitting; and the untouched
StatusCheckRollupfallback.golangci-lintlocally was v2.12.2 (0 issues). The CI-pinned v1.44.2 will notrun under Go 1.26 (
panic: load embedded ruleguard rules, unrelated to thischange), so that version is verified only by this PR's own CI run.
I also dry-ran the rebuilt binary against live
DocPlanner/dbt-appdevelop → masterwith--dry-run; both branches are currently at97cee590,so it correctly short-circuited with
IS ALREADY IN master branch. EXITING THE PROCESS WITHOUT ANY ACTION.Suggested rollout
Merging is inert; tagging is the deploy. Because
ghfm_versiondefaults tolatest, I would not let a tag movelatestimmediately:monolith-appanddbt-apppinghfm_versionto itexplicitly for a few days —
monolith-appbecause it owns thehotfix-skip-testsgate,dbt-appbecause it has the reproducer.latestmove, and expect a short tail of "my repo stopped promoting"reports that are genuinely red checks the old counter was hiding.
🤖 Generated with Claude Code