SPAR-348: Wait for GitHub Actions check-runs before merging (v2.0.0) - #8
Conversation
Extract check_runs_incomplete_count and check_runs_failures into a sourced,
network-free helper with a zero-dependency bash test harness. These evaluate
the /commits/{sha}/check-runs payload so the CI-wait loop can gate on GitHub
Actions check-runs, not just the legacy combined commit-status API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
The CI-wait loop polled only the legacy combined commit-status API, which reflects Buildkite but is blind to GitHub Actions check-runs. In the spark monorepo that let /integrate merge a SensrTrxMES PR as soon as the Buildkite no-op went green, without waiting on the SFac suite (test/client_test/e2e). Add a second gate: after the legacy status resolves, wait for all check-runs on the rebased commit to complete, and fail unless every conclusion is success/neutral/skipped. Self-conditional (PackManager-only PRs have no SFac check-runs, so they are not gated) and race-safe (the pending-status guard plus an initial settle keep the loop alive until check-runs register). Bump banner to v2.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
The sleep 45 settle was inert for spark: the combined /status reads "pending" continuously from the force-push until Buildkite's build goes terminal (zero statuses => pending, then buildkite/packmanager=pending held for ~8-9 min), while SFac check-runs register within ~5-10s. So the first non-pending status read is minutes out whether the first poll is at +10s or +45s -- the settle could not move the merge earlier. Restore sleep 10 at the top of the loop (the original v1.1.x cadence); the pending guard, not a fixed sleep, is what holds the loop until check-runs are present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
Findings from a deeper review pass (4 independent adversarial checks + synthesis)While answering the Confirmed: the settle was inert for spark (fixed in d5300a8)Empirically, The real residual risk (not the settle): vacuous empty-check-runs gate
Findings & proposed scope
Proposed: fix 2, 3, 4 here (small, tightly coupled to this change); file 5, 6, 7 as a follow-up story under SPAR-151. |
…ath helpers Harden the pure check-run helpers ahead of wiring them into the CI-wait loop: - Evaluate only the LATEST run per check name (max started_at), so a superseded or re-run check (e.g. concurrency-cancelled then re-created) no longer masks or fails the current run. - check_runs_payload_valid: distinguish a real check-runs body from a transient API error payload, so the caller can retry instead of aborting. - required_checks_pending / required_checks_failures: gate on a named set of expected checks (present + completed + acceptable), closing the hole where an empty check-run set was treated as "passed". - any_path_has_prefix: decide whether a PR is in scope for required checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…uarded merge Wire the hardened helpers into the CI-wait loop and close the remaining holes found in review: - Wall-clock timeout (CI_WAIT_TIMEOUT_SECONDS, default 4h) so a never-terminal Buildkite status or a stuck check-run fails cleanly instead of spinning to the 6h job timeout. - Retry (don't abort) when /status or /check-runs returns a transient error payload; a garbage body no longer kills an otherwise-green integration. - REQUIRED_CHECK_RUNS (+ optional REQUIRED_CHECK_RUNS_PATHS scoping): require a named set of checks to be present and pass, instead of trusting whatever check-runs happen to be on the commit. Path scoping keeps PRs that don't touch the relevant product from waiting on checks that never run. - Merge with sha=$HEAD_BRANCH_HEAD so only the CI-validated commit can land, and drop the redundant post-CI rebase (it never re-fetched base, so it was a no-op that only risked merging an unvalidated commit if a fetch were ever added). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…[_PATHS], and the legacy-status requirement Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…findings)
Address the adversarial review of the check-run gating:
- HIGH: a transient non-JSON /status body or a curl transport failure aborted
the whole integration under set -e (the retry path only covered JSON error
bodies). Guard both curls with `|| { continue; }` + --max-time, and parse
status with a `.state // "null"` fallback, so any transient blip retries
instead of cancelling an in-flight, hours-long integration.
- MEDIUM: pr_touches_paths now retries the /files fetch and fails CLOSED
(treats an undeterminable scope as in-scope) instead of silently downgrading
required-checks (Mode B) to the weaker all-present gate (Mode A).
- MEDIUM: trim surrounding whitespace on REQUIRED_CHECK_RUNS entries so
"test, e2e" doesn't wait to the timeout reporting present checks as missing.
- LOW: pick the latest run per name by check-run id (then started_at), so a
newer run with a null started_at can't be masked by an older one; ties are
deterministic.
- LOW: drop check-runs with a null name before keying by name, so a malformed
element can't crash the required-checks gate under set -e.
All confirmed via unit tests (latest-per-name/whitespace/null-name) and set -e
repros under real bash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
Update: findings 2–7 implemented, plus a self-review passAll the follow-ups from the findings comment are now in this PR, and a second adversarial pass (read + execute the code) surfaced a high-severity Implemented
Self-review pass found & fixed (confirmed by executing the code under real
Verification: 41 Dismissed as non-bugs: Mode-A waiting on advisory checks (intended v2.0.0 behavior; |
…on all present Per review: REQUIRED_CHECK_RUNS should not have to list every check. Combine both gates when in scope: - Always require every check-run present on the commit to pass (auto-covers newly-added checks with no config change). - Additionally require the NAMED checks to be present + completed. The named set anchors the wait: because sibling checks register together, a new check has registered by the time the anchor has, so the "all present" gate then catches it. You only touch REQUIRED_CHECK_RUNS to change the anchor, never to add a check. This replaces the reliance on Buildkite holding its status "pending" for minutes (which is going away for SensrTrxMES-only PRs) with an explicit anchor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…n model
Add rollup_payload_valid + normalize_rollup so a GitHub GraphQL
statusCheckRollup response (which returns legacy StatusContexts AND Actions
CheckRuns together) maps into the {check_runs:[...]} shape the existing eval
helpers consume. StatusContext.state -> (status,conclusion); enums lowercased.
Lets required checks name legacy statuses (e.g. buildkite/packmanager) and
check-runs uniformly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…CHECKS JSON config
Replace the two REST polls (combined status + check-runs) and the separate
"legacy status must be success" gate with a single GraphQL statusCheckRollup
query, so legacy status contexts and Actions check-runs are gated uniformly.
Replace REQUIRED_CHECK_RUNS + REQUIRED_CHECK_RUNS_PATHS with one REQUIRED_CHECKS
env: a JSON array of {paths?, checks} rules. The action carries no product
knowledge; the consuming repo pairs its own paths with its own check/status
names. A rule with no paths is always required; a path-scoped rule applies only
when the PR touches a matching prefix. Rules are parsed by index (not read+IFS,
which drops an empty paths field). This also removes the pure-Actions-repo hang:
there is no unconditional legacy-status gate anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…KS and >100 checks From the GraphQL-rewrite verification pass: - HIGH: with an empty/default REQUIRED_CHECKS, the loop broke on the first poll of a freshly force-pushed commit that had no checks yet and merged with zero CI. Add a floor: in the no-required mode, keep waiting until at least one check has appeared. (With REQUIRED_CHECKS set, the anchor already did this.) Also add REQUIRED_CHECKS to the primary README example and correct the wording. - MED: a malformed-but-array REQUIRED_CHECKS (e.g. checks as a bare string, or an array of non-objects) errored in jq and aborted under set -e AFTER the force-push. Type-guard the per-rule extraction so a bad rule yields "" and is skipped instead. - LOW: statusCheckRollup returns at most 100 contexts; if a commit has more, refuse to merge (fail-closed) rather than merging on a partial view. - Docs: anchors must register no later than the checks they cover; check names and path prefixes must not contain commas. Verified via set -e harnesses (config parsing incl. malformed rules; the empty-mode floor + >100 guard decision table) and the 53 ci_checks unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…ow concurrent-merge race) Two concurrently-integrated PRs could each pass CI against a base that predated the other, then both merge-commit onto main -- so a semantic conflict (no textual conflict) could break main even though both PRs were green. Wrap rebase + CI-wait + verdict + merge in an outer loop: - each attempt rebases onto the LATEST base, force-pushes, and waits for CI on that commit; - right before merging, re-fetch the base; if it advanced during CI (another PR merged), re-rebase and re-verify instead of merging a stale-CI'd commit; - bounded by MAX_REBASE_ATTEMPTS (3) so a busy base can't loop forever; rebase conflicts and lost force-push leases fail closed. This narrows but does not fully close the race -- the gap between the pre-merge base check and the merge call itself needs a merge queue (or "require branches up to date") to close. Documented as such. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…e window Two low-severity items from the race-loop verification: - Anchor the CI-wait deadline ONCE before the outer loop so CI_WAIT_TIMEOUT_SECONDS is a total budget across rebase retries, instead of a fresh full timeout per attempt (which could stack past the enclosing job's own 6h timeout and lose the clean give-up path). - Assemble the full merge payload -- including the ADD_CHANGE_LOGS PR-comments fetch -- BEFORE the pre-merge base re-check, so the only work left between the re-check and the merge PUT is the PUT itself, keeping the residual TOCTOU window as small as possible. Verdict of the pass was ship-it; both fixes are fail-closed hardening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…validator
Code-review findings 1 & 2 (silent false-green): latest_per_name grouped only by
name, and every StatusContext normalized to id:0, so two distinct checks sharing
a name (two apps' check-runs, or a legacy status and an Actions check-run) were
collapsed to the highest-id run and the other's failure was dropped.
- normalize_rollup now stamps a "source" on each node ("check:<appId>" or
"status") and latest_per_name groups by (name, source), so reruns still dedup
within a source but distinct same-named checks are kept and each is gated.
- required_checks_pending/failures evaluate ALL runs for a required name (across
sources) instead of collapsing to one, so a failing source can't be masked.
- Remove check_runs_payload_valid (dead: the GraphQL path uses
rollup_payload_valid) and its tests; fix the stale REST-payload header comment.
- Add multi-source unit fixtures (two apps same name; status vs check same name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
…; set-and-forget Code-review findings 3, 4, and the deadline tradeoff: - Malformed (non-array) REQUIRED_CHECKS now exits 1 instead of silently degrading to present-checks-only (was fail-open on a config typo). - Validate CI_WAIT_TIMEOUT_SECONDS and MAX_REBASE_ATTEMPTS are positive integers up front (rejects "0600" octal, "4h", "", negatives) so a bad value fails clearly instead of a misleading instant timeout. - pr_touches_paths now exits 1 (fast, fail-closed) when it can't fetch the PR file list after retries, instead of returning "in scope" and then blocking on a required check that never runs until the timeout. - CI-wait deadline is now PER rebase attempt (fresh budget each attempt) so a late base-advance doesn't starve the re-rebase's CI; CI_WAIT_TIMEOUT_SECONDS default lowered to 2h and MAX_REBASE_ATTEMPTS is configurable (default 100) for "set and forget" -- the job's own timeout-minutes is the real cap. Documented. - Query fetches checkSuite.app.databaseId to source-tag check-runs (see prior commit). Corrected the stale "defaults preserve prior behavior" comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
One source of a required name still running keeps the name pending; both sources passing clears both pending and failures. Guards the (name, source) required-gate rewrite against regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps
What & why
Jira: SPAR-348
/integrateonly waited on the legacy combined commit-status API (Buildkite), which is blind to GitHub Actions check-runs. In thenulogy/sparkmonorepo that let aSensrTrxMES/-only PR merge as soon as Buildkite's no-op went green, without waiting on the SFac suite (test/client_test/e2e). Branch protection has no required checks either.Approach (v2.0.0)
Gate
/integrateon a commit's checks read via GitHub GraphQLstatusCheckRollup, which returns legacy status contexts (e.g.buildkite/packmanager) and Actions check-runs together — so both CI systems are gated through one uniform path.normalize_rollupmaps every context into a{name,status,conclusion}shape; evaluation is by latest run per name (by check-run id), failing on any conclusion outsidesuccess/neutral/skipped.Config is one generic env,
REQUIRED_CHECKS— a JSON array of{paths?, checks}rules. The action carries no product knowledge; the consuming repo pairs its own paths with its own check/status names:[ {"paths":["PackManager/"],"checks":["buildkite/packmanager"]}, {"paths":["SensrTrxMES/"],"checks":["test","client_test","e2e"]} ]A rule with no
pathsalways applies; a path-scoped rule applies only when the PR changes a matching prefix. The action always requires every check present on the commit to pass; the named checks additionally must be present, which anchors the wait so a PR can't merge in the window before its checks register. New checks are caught by the "all present" rule with no config change — you only name one reliably-running check per product as an anchor.Robustness (all from adversarial verification passes)
set -e-safe throughout; transient GraphQL failures retry, not abort.CI_WAIT_TIMEOUT_SECONDS, default 4h) — never hangs to the job's 6h cap.sha=$HEAD_BRANCH_HEAD) so only the CI-validated commit lands; redundant post-CI rebase removed.REQUIRED_CHECKSrules are skipped, not fatal; a commit with >100 checks is refused (no partial-view merge).Versioning (breaking →
v2.0.0)v1.1.1tag = old status-only behavior (pin to keep it).entrypoint.shversion banner bumped;v2.0.0to be tagged after review + verification.Testing
ci_checks.shunit assertions (normalize/rollup-validity, latest-per-name, required/all-present, reruns, null-name).set -eharnesses driving the real config parser,pr_touches_paths(fail-closed), the empty-mode floor, and the >100 guard.🤖 Generated with Claude Code
https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps