Skip to content

SPAR-348: Wait for GitHub Actions check-runs before merging (v2.0.0) - #8

Merged
sskirby merged 18 commits into
masterfrom
SPAR-348-wait-for-check-runs
Jul 3, 2026
Merged

SPAR-348: Wait for GitHub Actions check-runs before merging (v2.0.0)#8
sskirby merged 18 commits into
masterfrom
SPAR-348-wait-for-check-runs

Conversation

@sskirby

@sskirby sskirby commented Jul 2, 2026

Copy link
Copy Markdown
Member

What & why

Jira: SPAR-348

/integrate only waited on the legacy combined commit-status API (Buildkite), which is blind to GitHub Actions check-runs. In the nulogy/spark monorepo that let a SensrTrxMES/-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 /integrate on a commit's checks read via GitHub GraphQL statusCheckRollup, 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_rollup maps every context into a {name,status,conclusion} shape; evaluation is by latest run per name (by check-run id), failing on any conclusion outside success/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 paths always 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.
  • Wall-clock timeout (CI_WAIT_TIMEOUT_SECONDS, default 4h) — never hangs to the job's 6h cap.
  • Merge is sha-guarded (sha=$HEAD_BRANCH_HEAD) so only the CI-validated commit lands; redundant post-CI rebase removed.
  • Empty-config mode won't merge on an empty check set (waits for ≥1 to appear); malformed REQUIRED_CHECKS rules are skipped, not fatal; a commit with >100 checks is refused (no partial-view merge).
  • No more special-cased "legacy status must be success" gate — so no pure-Actions-repo hang.

Versioning (breaking → v2.0.0)

  • v1.1.1 tag = old status-only behavior (pin to keep it).
  • entrypoint.sh version banner bumped; v2.0.0 to be tagged after review + verification.

Testing

  • 53 ci_checks.sh unit assertions (normalize/rollup-validity, latest-per-name, required/all-present, reruns, null-name).
  • Live GraphQL query against real SFac + PackManager commits (both normalize correctly).
  • set -e harnesses driving the real config parser, pr_touches_paths (fail-closed), the empty-mode floor, and the >100 guard.
  • Three adversarial verification passes; every confirmed finding fixed (the last round caught the empty-config early-merge + a malformed-config abort).
  • End-to-end on throwaway spark PRs pending (via the draft pin PR nulogy/spark#74).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CUQaePmRdFCZU5tNqBdTps

sskirby and others added 4 commits July 2, 2026 16:20
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
Comment thread entrypoint.sh Outdated
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
@sskirby

sskirby commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Findings from a deeper review pass (4 independent adversarial checks + synthesis)

While answering the sleep 45 question I ran a fuller review of the CI-wait loop against the live GitHub/Buildkite behavior on spark. Summary + proposed scope split below.

Confirmed: the settle was inert for spark (fixed in d5300a8)

Empirically, /status stays pending for 7.6–9.1 min (Buildkite holds its pending status until the build finishes) while SFac check-runs register in ~5–10s. The pending guard holds the loop until check-runs are long present; 10s vs 45s makes no difference. Reverted to the original 10s cadence.

The real residual risk (not the settle): vacuous empty-check-runs gate

check_runs_incomplete_count returns 0 for an empty set, so "SFac check-runs haven't registered yet" is indistinguishable from "SFac passed." If a GitHub Actions incident (or a dropped synchronize webhook) delayed check-run creation past Buildkite's terminal status, /integrate would merge with SFac never gated. The settle never covered this (it's minutes, not seconds). Under normal spark timing this never fires, but it's the design's true early-merge hole.

Findings & proposed scope

# Finding Severity Proposed
1 sleep 45 inert for spark ✅ fixed (d5300a8)
2 No wall-clock timeout → a stuck check-run (newly gated by this PR) or a Buildkite status that never goes terminal hangs the loop to the 6h job timeout medium this PR
3 New jq helpers abort the whole integration under set -e if /check-runs returns an error payload (rate-limit/502) — a regression vs v1.1.1, which only did jq -r .state. Fail-closed (no bad merge) but kills a good run medium this PR (treat non-array payload as "keep polling")
4 Pure-Actions repos (no legacy status) can never merge under v2.0.0 — /status stays pending/0 forever. Not a spark issue (always has buildkite/packmanager), but the v2.0.0 framing implies general Actions support high (doc) this PR (document the "must have a pending-holding legacy status" requirement)
5 Vacuous empty/partial gate (above): make a positive assertion that expected checks (test/client_test/e2e) are present before trusting a complete result; also paginate (>30 runs) medium follow-up
6 cancelled / action_required treated as failures → a concurrency-auto-cancelled run would abort a green integration. Not reachable on spark today (SFac workflows have no concurrency: block) medium follow-up
7 Post-CI second git rebase + merge with no sha guard. Currently a no-op (no second git fetch), so latent, and pre-existing (identical in v1.1.1) low follow-up

Proposed: fix 2, 3, 4 here (small, tightly coupled to this change); file 5, 6, 7 as a follow-up story under SPAR-151.

sskirby and others added 4 commits July 2, 2026 19:06
…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
@sskirby

sskirby commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Update: findings 2–7 implemented, plus a self-review pass

All 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 set -e bug that's also fixed. Pushed through 03a0a15.

Implemented

  • Wall-clock timeout CI_WAIT_TIMEOUT_SECONDS (default 4h — sized above the real ~2.6h SFac e2e, below the job's 6h cap).
  • REQUIRED_CHECK_RUNS (+ path-scoped REQUIRED_CHECK_RUNS_PATHS): require a named set of checks to be present and pass, closing the "empty check-run set = passed" hole. Path scoping keeps PRs that don't touch the product from waiting on checks that never run.
  • Latest run per name (by check-run id, then started_at): a rerun / concurrency re-create supersedes the earlier run — fixes both stale-cancelled masking and cancelled/action_required false-negatives on advisory runs.
  • Merge with sha=$HEAD_BRANCH_HEAD so only the CI-validated commit lands; dropped the redundant post-CI rebase.
  • README: full env docs + the legacy-status requirement (pure-Actions repos need at least one pending-holding status).

Self-review pass found & fixed (confirmed by executing the code under real bash)

  • 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). Both curls are now || { … continue; } + --max-time, and status parses with a .state // "null" fallback → transient blips retry instead of cancelling an hours-long integration.
  • MEDIUMpr_touches_paths now retries and fails closed (undeterminable scope → enforce required checks) instead of silently downgrading to the weaker all-present gate.
  • MEDIUMREQUIRED_CHECK_RUNS entries are whitespace-trimmed ("test, e2e" no longer hangs to the timeout reporting present checks as "missing").
  • LOW — null-started_at newer runs no longer masked (id-primary ordering); null check-run names filtered before keying (can't crash the gate).

Verification: 41 ci_checks.sh unit assertions (incl. latest-per-name, whitespace, null-name, reruns), shellcheck clean on changed code, bash -n clean, and set -e repros of the abort scenarios + a pr_touches_paths harness (stubbed curl) driven against the real functions.

Dismissed as non-bugs: Mode-A waiting on advisory checks (intended v2.0.0 behavior; REQUIRED_CHECK_RUNS is the escape hatch) and Mode-B waiting when a required check legitimately never runs (by-design fail-closed; keep REQUIRED_CHECK_RUNS_PATHS a subset of each check's trigger paths).

sskirby and others added 9 commits July 2, 2026 23:24
…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
@sskirby
sskirby merged commit 07e59b4 into master Jul 3, 2026
1 check passed
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.

1 participant