Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2196c77
SPAR-348: Feat: add unit-tested check-run evaluators for CI gating
sskirby Jul 2, 2026
8422e52
SPAR-348: Feat: wait for GitHub Actions check-runs before merging
sskirby Jul 2, 2026
e7edc6d
SPAR-348: Ci: run check-run evaluator tests on push and PR
sskirby Jul 2, 2026
71fae7f
SPAR-348: Docs: document check-run gating and versioning
sskirby Jul 2, 2026
d5300a8
SPAR-348: Fix: drop the pre-loop settle, restore the 10s poll cadence
sskirby Jul 2, 2026
e7f5246
SPAR-348: Feat: evaluate latest run per name; add required-checks + p…
sskirby Jul 2, 2026
2a5a655
SPAR-348: Fix: timeout, error-payload retries, required checks, sha-g…
sskirby Jul 2, 2026
7e7feb8
SPAR-348: Docs: document CI_WAIT_TIMEOUT_SECONDS, REQUIRED_CHECK_RUNS…
sskirby Jul 2, 2026
03a0a15
SPAR-348: Fix: harden CI-wait against transient API failures (review …
sskirby Jul 2, 2026
b0affcc
SPAR-348: Feat: anchor model — require named checks present AND gate …
sskirby Jul 3, 2026
f5102e1
SPAR-348: Feat: normalize GraphQL statusCheckRollup into the check-ru…
sskirby Jul 3, 2026
27f31cb
SPAR-348: Feat: fetch checks via GraphQL statusCheckRollup; REQUIRED_…
sskirby Jul 3, 2026
2930474
SPAR-348: Fix: floor empty-config mode; guard malformed REQUIRED_CHEC…
sskirby Jul 3, 2026
ba24dce
SPAR-348: Feat: rebase onto latest base + re-check before merge (narr…
sskirby Jul 3, 2026
07887fa
SPAR-348: Fix: cap total CI-wait across retries; shrink pre-merge rac…
sskirby Jul 3, 2026
5bc53bb
SPAR-348: Fix: evaluate checks per (name, source); drop dead payload …
sskirby Jul 3, 2026
a452a5f
SPAR-348: Fix: fail-closed config/scope errors; per-attempt CI budget…
sskirby Jul 3, 2026
57c1e1e
SPAR-348: Test: cover multi-source required-check edge cases
sskirby Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Test
on:
push:
branches: [ master ]
pull_request:
jobs:
ci_checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ci_checks unit tests
run: bash test/ci_checks_test.sh
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ LABEL "com.github.actions.color"="purple"

RUN apk --no-cache add jq bash curl git git-lfs

ADD ci_checks.sh /ci_checks.sh
ADD entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ Fork of the `cirrus-actions/rebase` repo for integrating a PR.

Supports two commands:

- `/integrate` -- Rebases, waits for CI, and merges the PR.
- `/integrate` -- Rebases, waits for CI, and merges the PR. "CI" spans a commit's
GitHub Actions check-runs **and** legacy status contexts (e.g. Buildkite),
read together via GitHub's GraphQL `statusCheckRollup`. The merge proceeds only
when every check present on the commit has completed with a `success`,
`neutral`, or `skipped` conclusion. See [Configuration](#configuration) for
`REQUIRED_CHECKS`, which additionally waits for named checks to *appear* so a PR
can't merge in the window before its checks have registered.
- `/hotfix` -- Same as integrate, but appends `[skip tests]` to the merge commit message.

# Example Usage
Expand All @@ -30,6 +36,10 @@ Supports two commands:
- uses: nulogy/integrate-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MERGING_TOKEN }}
# Strongly recommended (see Configuration): require your CI checks to be
# present and pass. Without it, the action gates only on whatever checks
# happen to exist when it polls.
REQUIRED_CHECKS: '[{"checks":["your-ci-check"]}]'
always_job:
name: Aways run job
runs-on: ubuntu-latest
Expand All @@ -45,3 +55,43 @@ Then on a PR, type `/integrate` or `/hotfix` into the comments section. Using `/

This will fail if the HEAD branch is not rebaseable on top of the BASE branch of the PR and the HEAD branch needs to be rebased.

# Configuration

All optional, passed via `env:` on the action step:

| Variable | Default | Purpose |
|---|---|---|
| `GITHUB_TOKEN` | — | **Required.** Token allowed to merge into the PR's base branch. |
| `ADD_CHANGE_LOGS` | `false` | Collect `Change log:` PR comments into the merge commit message. |
| `CI_WAIT_TIMEOUT_SECONDS` | `7200` (2h) | Give up waiting for CI after this many seconds **per rebase attempt** (fail, don't merge). Must be a positive integer (no leading zeros / units) and above your slowest required check, or the action cancels a healthy PR. |
| `MAX_REBASE_ATTEMPTS` | `100` | How many times to rebase onto the latest base and re-run CI when the base advances during CI. High by default (set-and-forget); the enclosing job's own `timeout-minutes` is the real backstop for total runtime. Positive integer. |
| `REQUIRED_CHECKS` | _(empty)_ | JSON array of rules pairing path prefixes with check names that must be **present** (and pass) before merging — matching GitHub Actions check-runs *and* legacy status contexts (e.g. `buildkite/packmanager`). A rule with no `paths` always applies; with `paths` it applies only when the PR changes a file under one of those prefixes. The action *always* requires every check present on the commit to pass; these rules additionally require the named checks to have appeared, closing the window where a check hasn't registered yet and an empty/partial set looks "green". You do **not** list every check — a new check is caught by the always-on "all present must pass" rule — but name at least one reliably-running check per product as an anchor. Example: `[{"checks":["buildkite/packmanager"]},{"paths":["some/dir/"],"checks":["test","e2e"]}]` |

Example (a monorepo: Buildkite gates one product, GitHub Actions gates another):

```yml
- uses: nulogy/integrate-action@v2.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MERGING_TOKEN }}
REQUIRED_CHECKS: '[{"checks":["buildkite/packmanager"]},{"paths":["SensrTrxMES/"],"checks":["test","client_test","e2e"]}]'
```

Notes:

- Check state is read from GitHub's GraphQL `statusCheckRollup`, so Actions check-runs and legacy status contexts are gated the same way — the action works for Actions-only, status-only, or mixed repos, and needs no branch-protection required checks.
- Without `REQUIRED_CHECKS` the action still won't merge on an *empty* check set (it waits for at least one check to appear), but it can only gate on whatever has appeared by then; set `REQUIRED_CHECKS` to guarantee specific checks ran.
- The action waits `CI_WAIT_TIMEOUT_SECONDS` **per rebase attempt** and retries up to `MAX_REBASE_ATTEMPTS` times, so worst-case runtime is roughly `CI_WAIT_TIMEOUT_SECONDS × MAX_REBASE_ATTEMPTS`. For long "set and forget" runs on a busy base, raise the workflow job's `timeout-minutes` (GitHub's default job timeout is 6h) — it, not this action, is the ultimate cap.
- A `skipped` (or `neutral`) check passes the gate — a skipped check never blocks a merge.
- Each anchor should be a check that registers no later than the checks it stands in for (prefer a fast-registering check-run over a slow external status), so the "all present" rule can't finish before a sibling has appeared.
- Check names and path prefixes must not contain commas.
- If a commit has more than 100 checks, the action refuses to merge (it cannot see them all) rather than merging on a partial view.

# Versioning

This action is released as git tags. Reference a tag for stable behavior, e.g. `nulogy/integrate-action@v2.0.0`.

- `v2.0.0` -- waits for GitHub Actions check-runs in addition to legacy commit statuses before merging. Use this in repos whose CI runs (partly) on GitHub Actions, e.g. monorepos.
- `v1.1.1` -- legacy behavior: waits only on the combined commit-status API (check-runs are ignored). Pin this if you rely on the old behavior.

`@master` tracks the latest release.

123 changes: 123 additions & 0 deletions ci_checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Pure helpers for evaluating CI state on a commit. No network and no globals:
# every input is an argument, so these are unit-testable with fixtures.
#
# "$1" is a check-runs-shaped payload as produced by normalize_rollup:
# {"check_runs":[{name,status,conclusion,started_at,id,source},...]}
# "source" distinguishes a check's origin (a check-run's app, or "status" for a
# legacy commit-status context). GitHub can list several runs for the same name
# (reruns), and two different sources can share a name; helpers evaluate the
# LATEST run per (name, source) -- newest id, then started_at -- so a rerun never
# masks the current run and two distinct same-named checks are never collapsed.

# Check-run conclusions we treat as passing. Anything else on a completed run
# (failure, timed_out, cancelled, action_required, stale, or null/unknown)
# blocks the merge. (skipped passes: a skipped check must not block a merge.)
CI_OK_CONCLUSIONS='["success","neutral","skipped"]'

# jq prelude defining `latest_per_name`: reduce .check_runs to the newest run per
# (name, source). Unnamed runs are dropped (a null name cannot be an object key).
_ci_jq_latest='def latest_per_name:
[ (.check_runs // []) | map(select(.name != null)) | group_by([.name, .source])[]
| max_by([(.id // 0), (.started_at // "")]) ];'

# True if $1 is a usable GraphQL statusCheckRollup response: no top-level errors
# and the commit object resolved. (A resolved commit with no checks yet has a
# null rollup, which is still usable -> normalizes to an empty set.)
rollup_payload_valid() {
jq -e '(.errors | not) and (.data.repository.object != null)' <<<"$1" >/dev/null 2>&1
}

# Normalize a GraphQL statusCheckRollup response into the {check_runs:[...]} shape
# the helpers below consume, so legacy StatusContexts (e.g. Buildkite) and Actions
# CheckRuns are evaluated uniformly. StatusContext.state maps onto (status,
# conclusion): SUCCESS -> completed/success; FAILURE|ERROR -> completed/failure;
# PENDING|EXPECTED -> in_progress/none (i.e. not yet completed). CheckRun enums
# are lowercased. "source" keeps a status and a same-named check-run (or two
# same-named check-runs from different apps) distinct so neither is dropped.
normalize_rollup() {
jq '{
check_runs: [
(.data.repository.object.statusCheckRollup.contexts.nodes // [])[]
| if .__typename == "CheckRun" then
{ name: .name,
status: ((.status // "") | ascii_downcase),
conclusion: (if .conclusion == null then null else (.conclusion | ascii_downcase) end),
started_at: .startedAt,
id: (.databaseId // 0),
source: ("check:" + ((.checkSuite.app.databaseId // 0) | tostring)) }
else
{ name: .context,
status: (if (.state == "SUCCESS" or .state == "FAILURE" or .state == "ERROR") then "completed" else "in_progress" end),
conclusion: (if .state == "SUCCESS" then "success" elif (.state == "FAILURE" or .state == "ERROR") then "failure" else null end),
started_at: .createdAt,
id: 0,
source: "status" }
end
]
}' <<<"$1"
}

# Count latest-per-(name,source) check-runs that have not finished yet.
check_runs_incomplete_count() {
jq -r "$_ci_jq_latest"'
latest_per_name | map(select(.status != "completed")) | length
' <<<"$1"
}

# "name: conclusion" for each latest-per-(name,source) completed check-run whose
# conclusion is not acceptable. Empty output = all completed runs passed.
check_runs_failures() {
jq -r --argjson ok "$CI_OK_CONCLUSIONS" "$_ci_jq_latest"'
latest_per_name[]
| select(.status == "completed")
| select(.conclusion as $c | ($ok | index($c)) | not)
| "\(.name): \(.conclusion // "none")"
' <<<"$1"
}

# Of the comma-separated required names in $2 (surrounding whitespace trimmed),
# print those NOT yet present-and-completed: absent from the commit, or ANY of
# their runs (across sources) not yet completed. Empty output = every required
# name has at least one run and all its runs have completed.
required_checks_pending() {
jq -r --arg req "$2" "$_ci_jq_latest"'
(latest_per_name) as $runs
| ($req | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)))[]
| . as $name
| ([ $runs[] | select(.name == $name) ]) as $entries
| select( ($entries | length) == 0 or ($entries | any(.status != "completed")) )
' <<<"$1"
}

# Of the comma-separated required names in $2 (surrounding whitespace trimmed),
# print "name: reason" for each that is absent ("missing") or has ANY completed
# run (across sources) whose conclusion is not acceptable. Empty output = every
# required name is present and all its runs passed.
required_checks_failures() {
jq -r --arg req "$2" --argjson ok "$CI_OK_CONCLUSIONS" "$_ci_jq_latest"'
(latest_per_name) as $runs
| ($req | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)))[]
| . as $name
| ([ $runs[] | select(.name == $name) ]) as $entries
| if ($entries | length) == 0 then "\($name): missing"
else
([ $entries[] | select(.status == "completed") | select(.conclusion as $c | ($ok | index($c)) | not) ]) as $bad
| if ($bad | length) > 0 then "\($name): \($bad[0].conclusion // "incomplete")" else empty end
end
' <<<"$1"
}

# True if any newline-separated path in $1 starts with any comma-separated
# prefix in $2. Matching is literal prefix (not glob), so "SensrTrxMES/" matches
# "SensrTrxMES/x" but not "SensrTrxMESX/x".
any_path_has_prefix() {
local paths="$1" prefixes_csv="$2" prefix p
while IFS= read -r prefix; do
[[ -z "$prefix" ]] && continue
while IFS= read -r p; do
[[ -n "$p" && "$p" == "$prefix"* ]] && return 0
done <<<"$paths"
done < <(echo "$prefixes_csv" | tr ',' '\n')
return 1
}
Loading
Loading