diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..337574f --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 639f355..e6ab333 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 9907a70..62c21dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. + diff --git a/ci_checks.sh b/ci_checks.sh new file mode 100644 index 0000000..981c3c7 --- /dev/null +++ b/ci_checks.sh @@ -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 +} diff --git a/entrypoint.sh b/entrypoint.sh index 3de88e5..f1f84db 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,7 +2,7 @@ set -e -echo "Integrate Action v1.1.1" +echo "Integrate Action v2.0.0" # Workaround until new Actions support neutral strategy # See how it was before: https://developer.github.com/actions/creating-github-actions/accessing-the-runtime-environment/#exit-codes-and-statuses @@ -11,6 +11,9 @@ NEUTRAL_EXIT_CODE=0 # since https://github.blog/2022-04-12-git-security-vulnerability-announced/ git config --global --add safe.directory /github/workspace +# shellcheck source=ci_checks.sh +source /ci_checks.sh + # Skip if not a PR echo "Checking if issue is a pull request..." (jq -r ".issue.pull_request.url" "$GITHUB_EVENT_PATH") || exit $NEUTRAL_EXIT_CODE @@ -43,6 +46,37 @@ URI=https://api.github.com API_HEADER="Accept: application/vnd.github.v3+json" AUTH_HEADER="Authorization: token $GITHUB_TOKEN" +# CI-wait configuration (all optional). NOTE: v2 gates differently from v1 (see +# the README); leaving these unset does NOT reproduce v1's legacy-status-only gate. + +# Seconds to wait for CI PER rebase attempt before giving up (fail-closed). The +# action retries across rebases (see MAX_REBASE_ATTEMPTS), so total runtime is +# bounded by the enclosing job's own timeout -- raise the job's timeout-minutes +# for long "set and forget" runs. +CI_WAIT_TIMEOUT_SECONDS="${CI_WAIT_TIMEOUT_SECONDS:-7200}" +# How many times to rebase onto the latest base and re-run CI when the base keeps +# advancing during CI. High by default so /integrate is "set and forget"; the +# enclosing job's own timeout is the ultimate backstop. +MAX_REBASE_ATTEMPTS="${MAX_REBASE_ATTEMPTS:-100}" +# JSON array of rules pairing path prefixes with required check names, e.g. +# [ {"checks":["buildkite/packmanager"]}, +# {"paths":["some/dir/"],"checks":["test","e2e"]} ] +# A rule with no "paths" is always required; with "paths" it applies only when the +# PR changes a file under one of those prefixes. A required check must be PRESENT +# (and pass) before merging. Names match both GitHub Actions check-runs and legacy +# commit-status contexts (e.g. "buildkite/packmanager"). When empty, the action +# gates only on whatever checks are present on the commit. +REQUIRED_CHECKS="${REQUIRED_CHECKS:-}" + +# Reject non-positive-integer numeric config up front (e.g. "4h", "0600", "") so +# it fails clearly instead of yielding a misleading timeout or a wrong bound. +for _var in CI_WAIT_TIMEOUT_SECONDS MAX_REBASE_ATTEMPTS; do + if ! [[ "${!_var}" =~ ^[1-9][0-9]*$ ]]; then + echo "$_var must be a positive integer, got '${!_var}'. Cancelling integration." + exit 1 + fi +done + USER_URL=$(jq -r ".comment.user.url" "$GITHUB_EVENT_PATH") user_resp=$(curl -X GET -s -H "${API_HEADER}" -H "${AUTH_HEADER}" "${USER_URL}") @@ -87,40 +121,204 @@ git remote set-url origin https://x-access-token:$GITHUB_TOKEN@github.com/$GITHU git config --global user.email "action@github.com" git config --global user.name "GitHub Action" -# Make sure branches are up-to-date -git fetch origin $BASE_BRANCH +# Check out the PR branch. The base is (re)fetched and the branch (re)rebased +# per attempt inside the merge loop below. git fetch origin $HEAD_BRANCH - -# Rebase git checkout -b $HEAD_BRANCH origin/$HEAD_BRANCH -git rebase origin/$BASE_BRANCH -git push --force-with-lease -HEAD_BRANCH_HEAD=$(git rev-parse HEAD) -echo "(Potentially) Rebased commit hash of HEAD is: $HEAD_BRANCH_HEAD" -# Poll for CI status -while true; do - sleep 10 +# Does the PR touch any of the given comma-separated path prefixes? Paginates the +# PR file list. Returns 0 on a match, 1 when the full file list is known and +# matches nothing. If the file list can't be fetched after retries it exits the +# whole action (fail-closed: an undeterminable scope must not silently weaken the +# gate, and failing fast beats blocking on a check that will never run). +pr_touches_paths() { + local prefixes_csv="$1" page=1 resp count files + while : ; do + resp="" + for _ in 1 2 3; do + resp=$(curl -s --max-time 30 -H "${AUTH_HEADER}" -H "${API_HEADER}" "${PR_URL}/files?per_page=100&page=$page") || resp="" + if [[ -n "$resp" ]] && jq -e 'type == "array"' <<<"$resp" >/dev/null 2>&1; then + break + fi + resp="" + sleep 3 + done + if [[ -z "$resp" ]]; then + echo "Could not fetch the PR's changed files after retries; cannot determine required-check scope. Cancelling integration (re-run /integrate)." + exit 1 + fi + count=$(jq 'length' <<<"$resp") + if [[ "$count" -eq 0 ]]; then + return 1 + fi + files=$(jq -r '.[].filename' <<<"$resp") + if any_path_has_prefix "$files" "$prefixes_csv"; then + return 0 + fi + if [[ "$count" -lt 100 ]]; then + return 1 + fi + page=$((page + 1)) + done +} + +# Build the set of required check names for this PR from REQUIRED_CHECKS (see +# config above). A rule with no "paths" always applies; otherwise it applies when +# the PR changes a file under one of its prefixes. Names may refer to check-runs +# or legacy status contexts. +required_names="" +if [[ -n "$REQUIRED_CHECKS" ]]; then + if jq -e 'type == "array"' <<<"$REQUIRED_CHECKS" >/dev/null 2>&1; then + rule_count=$(jq 'length' <<<"$REQUIRED_CHECKS") + i=0 + # Extract each rule's fields by index (not via read+IFS: a tab delimiter is + # IFS-whitespace, which would drop an empty "paths" field and misread the row). + while [[ "$i" -lt "$rule_count" ]]; do + # Type-guard so a malformed rule (e.g. checks as a string, or a non-object) + # yields "" and is skipped, rather than erroring out and aborting under set -e. + rule_paths=$(jq -r --argjson i "$i" '(.[$i]) as $r | if ($r | type) == "object" and (($r.paths | type) == "array") then ($r.paths | map(select(type == "string")) | join(",")) else "" end' <<<"$REQUIRED_CHECKS") + rule_checks=$(jq -r --argjson i "$i" '(.[$i]) as $r | if ($r | type) == "object" and (($r.checks | type) == "array") then ($r.checks | map(select(type == "string")) | join(",")) else "" end' <<<"$REQUIRED_CHECKS") + i=$((i + 1)) + if [[ -z "$rule_checks" ]]; then + continue + fi + if [[ -z "$rule_paths" ]] || pr_touches_paths "$rule_paths"; then + required_names="${required_names:+$required_names,}$rule_checks" + fi + done + else + echo "REQUIRED_CHECKS is set but is not a JSON array. Cancelling integration (fix the config)." + exit 1 + fi +fi +if [[ -n "$required_names" ]]; then + echo "Required checks for this PR: $required_names" +else + echo "No required checks in scope; gating on every check present on the commit." +fi + +# Poll the commit's checks via one GraphQL statusCheckRollup query, which returns +# legacy status contexts (e.g. Buildkite) AND GitHub Actions check-runs together, +# so both are gated uniformly. +OWNER="${GITHUB_REPOSITORY%%/*}" +REPO="${GITHUB_REPOSITORY##*/}" +# $owner/$name/$oid are GraphQL variables, not shell -- single quotes are correct. +# shellcheck disable=SC2016 +GQL_QUERY='query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{statusCheckRollup{contexts(first:100){totalCount nodes{__typename ... on CheckRun{name status conclusion startedAt databaseId checkSuite{app{databaseId}}} ... on StatusContext{context state createdAt}}}}}}}}' + +# Outer loop: rebase onto the LATEST base, wait for CI, then re-check the base +# right before merging. If another PR merged during CI (base advanced), re-rebase +# and re-verify so we never merge a commit that was only CI'd against a stale +# base. The pre-merge re-check narrows -- but cannot fully close -- this window; +# the gap between that check and the merge call itself needs a merge queue. +rebase_attempt=0 +while true; do # OUTER: rebase / CI / re-check base / merge + rebase_attempt=$((rebase_attempt + 1)) + if [[ "$rebase_attempt" -gt "$MAX_REBASE_ATTEMPTS" ]]; then + echo "Base advanced through $MAX_REBASE_ATTEMPTS CI cycles; giving up. Re-run /integrate." + exit 1 + fi - LAST_STATUS=$(curl -s -H "${AUTH_HEADER}" -H "${API_HEADER}" "${URI}/repos/$GITHUB_REPOSITORY/commits/$HEAD_BRANCH_HEAD/status" | jq -r ".state") + git fetch origin $BASE_BRANCH + base_sha=$(git rev-parse "origin/$BASE_BRANCH") + if ! git rebase "origin/$BASE_BRANCH"; then + git rebase --abort || true + echo "Cannot rebase $HEAD_BRANCH onto updated $BASE_BRANCH (conflict). Cancelling integration." + exit 1 + fi + if ! git push --force-with-lease; then + echo "Could not force-push rebased $HEAD_BRANCH (was it pushed to during CI?). Cancelling integration." + exit 1 + fi + HEAD_BRANCH_HEAD=$(git rev-parse HEAD) + echo "Rebased HEAD is $HEAD_BRANCH_HEAD (attempt $rebase_attempt/$MAX_REBASE_ATTEMPTS onto base $base_sha)" + + # Fresh CI-wait budget for THIS rebase attempt, so a base advance late in one + # attempt doesn't starve the next attempt's CI. + deadline=$(( $(date +%s) + CI_WAIT_TIMEOUT_SECONDS )) + while true; do + sleep 10 + + if (( $(date +%s) > deadline )); then + echo "Timed out after ${CI_WAIT_TIMEOUT_SECONDS}s waiting for CI on $HEAD_BRANCH @ $HEAD_BRANCH_HEAD. Cancelling integration." + exit 1 + fi - if [[ $LAST_STATUS != "pending" ]]; then - break + gql_payload=$(jq -n --arg q "$GQL_QUERY" --arg owner "$OWNER" --arg name "$REPO" --arg oid "$HEAD_BRANCH_HEAD" \ + '{query: $q, variables: {owner: $owner, name: $name, oid: $oid}}') + rollup_resp=$(curl -s --max-time 30 -H "${AUTH_HEADER}" -H "Content-Type: application/json" -d "$gql_payload" "${URI}/graphql") || { + echo "Polling for CI: rollup fetch failed (transient), retrying..." + continue + } + if ! rollup_payload_valid "$rollup_resp"; then + echo "Polling for CI: rollup response not usable yet, retrying..." + continue + fi + # Refuse to merge on a partial view: the rollup returns at most 100 contexts, + # so if the commit has more, some checks are invisible to us. + context_total=$(jq '.data.repository.object.statusCheckRollup.contexts.totalCount // 0' <<<"$rollup_resp") + if [[ "$context_total" -gt 100 ]]; then + echo "Commit $HEAD_BRANCH_HEAD has $context_total checks; only 100 are fetched. Refusing to merge on a partial view. Cancelling integration." + exit 1 + fi + check_runs_json=$(normalize_rollup "$rollup_resp") + + # Wait for the required checks to APPEAR and finish. This anchors the wait: + # because sibling checks register together, a newly-added check will have + # registered by the time an anchor has, so the "all present" wait below then + # covers it without it being listed in REQUIRED_CHECKS. + if [[ -n "$required_names" ]]; then + pending=$(required_checks_pending "$check_runs_json" "$required_names") + if [[ -n "$pending" ]]; then + echo "Polling for CI: waiting on required check(s): $(echo "$pending" | tr '\n' ' ')" + continue + fi fi - echo "Polling for CI build completion..." + + # Floor for the no-required-checks mode: never merge on an empty check set -- + # wait until at least one check has appeared so an unregistered CI run can't + # look "green". (With REQUIRED_CHECKS set, the anchor above already does this.) + if [[ -z "$required_names" ]]; then + present_count=$(jq '.check_runs | length' <<<"$check_runs_json") + if [[ "$present_count" -eq 0 ]]; then + echo "Polling for CI: no checks have appeared on the commit yet, retrying..." + continue + fi + fi + + # Wait for every check present on the commit to finish. + incomplete=$(check_runs_incomplete_count "$check_runs_json") + if [[ "$incomplete" -gt 0 ]]; then + echo "Polling for CI: $incomplete check(s) still running..." + continue + fi + + break done -if [[ $LAST_STATUS != "success" ]]; then - echo "CI did not pass for branch $HEAD_BRANCH and HEAD commit $HEAD_BRANCH_HEAD. Cancelling integration." +# Every check present on the commit must have concluded acceptably... +failed_runs=$(check_runs_failures "$check_runs_json") +if [[ -n "$failed_runs" ]]; then + echo "CI did not pass. Failing checks for $HEAD_BRANCH @ $HEAD_BRANCH_HEAD:" + echo "$failed_runs" exit 1 fi -# Rebase -git checkout $HEAD_BRANCH -git rebase origin/$BASE_BRANCH -git push --force-with-lease +# ...and the required checks must additionally be PRESENT (not merely "not +# failing") -- this is what guarantees we didn't merge before they ran. +if [[ -n "$required_names" ]]; then + required_failures=$(required_checks_failures "$check_runs_json" "$required_names") + if [[ -n "$required_failures" ]]; then + echo "CI did not pass. Required check problem(s) for $HEAD_BRANCH @ $HEAD_BRANCH_HEAD:" + echo "$required_failures" + exit 1 + fi +fi -# Hit the merge button +# Assemble the merge payload (including fetching change-log comments) BEFORE the +# base re-check, so the only work left between the re-check and the merge call is +# the merge itself -- keeping the residual race window as small as possible. +# sha=$HEAD_BRANCH_HEAD makes GitHub merge only the exact commit CI validated. MERGE_COMMIT_TITLE="Merge branch '$HEAD_BRANCH' on behalf of $USER_FULL_NAME" if [[ "$ACTION_MODE" == "hotfix" ]]; then MERGE_COMMIT_TITLE="$MERGE_COMMIT_TITLE [skip tests]" @@ -146,14 +344,31 @@ if [[ $ADD_CHANGE_LOGS = "true" ]]; then JSON_STRING=$( jq -n \ --arg title "$MERGE_COMMIT_TITLE" \ --arg message "$MERGE_COMMIT_MESSAGE" \ - '{commit_title: $title, commit_message: $message}' ) - - merge_resp=$(curl -X PUT -s -H "${AUTH_HEADER}" -H "${API_HEADER}" -d "$JSON_STRING" "${PR_URL}/merge") + --arg sha "$HEAD_BRANCH_HEAD" \ + '{commit_title: $title, commit_message: $message, sha: $sha}' ) else - merge_resp=$(curl -X PUT -s -H "${AUTH_HEADER}" -H "${API_HEADER}" -d "{\"commit_title\":\"$MERGE_COMMIT_TITLE\"}" "${PR_URL}/merge") + JSON_STRING=$( jq -n \ + --arg title "$MERGE_COMMIT_TITLE" \ + --arg sha "$HEAD_BRANCH_HEAD" \ + '{commit_title: $title, sha: $sha}' ) +fi + +# Re-check the base as late as possible: if it advanced during CI, another PR +# merged, so re-rebase and re-verify rather than merge a stale-CI'd commit. The +# only work after this is the merge PUT itself; the residual gap between here and +# that call needs a merge queue to fully close. +git fetch origin $BASE_BRANCH +if [[ "$(git rev-parse "origin/$BASE_BRANCH")" != "$base_sha" ]]; then + echo "Base advanced during CI; re-rebasing and re-verifying before merge." + continue fi +merge_resp=$(curl -X PUT -s -H "${AUTH_HEADER}" -H "${API_HEADER}" -d "$JSON_STRING" "${PR_URL}/merge") + if [[ $merge_resp != *"Pull Request successfully merged"* ]]; then echo "Could not merge PR. Error from GitHub: '$merge_resp'" exit 1 fi + +break # OUTER: merged successfully +done diff --git a/test/ci_checks_test.sh b/test/ci_checks_test.sh new file mode 100644 index 0000000..8732b7a --- /dev/null +++ b/test/ci_checks_test.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Zero-dependency tests for ci_checks.sh (needs bash + jq). Run: bash test/ci_checks_test.sh +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +source ./ci_checks.sh + +fail=0 +assert_eq() { # $1=desc $2=expected $3=actual + if [[ "$2" == "$3" ]]; then echo "ok - $1" + else echo "FAIL - $1"; echo " expected: [$2]"; echo " actual: [$3]"; fail=1; fi +} +assert_ok() { # $1=desc ; runs $2.. as a command, expects exit 0 + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then echo "ok - $desc"; else echo "FAIL - $desc (expected exit 0)"; fail=1; fi +} +assert_notok() { # $1=desc ; runs $2.. expects non-zero exit + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then echo "FAIL - $desc (expected non-zero exit)"; fail=1; else echo "ok - $desc"; fi +} + +ALL_GREEN='{"total_count":4,"check_runs":[ + {"name":"test","status":"completed","conclusion":"success","started_at":"2026-07-02T17:07:15Z"}, + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T17:07:17Z"}, + {"name":"client_test","status":"completed","conclusion":"success","started_at":"2026-07-02T17:07:15Z"}, + {"name":"auto-merge","status":"completed","conclusion":"skipped","started_at":"2026-07-02T17:07:10Z"}]}' +RUNNING='{"total_count":2,"check_runs":[ + {"name":"test","status":"completed","conclusion":"success","started_at":"2026-07-02T17:07:15Z"}, + {"name":"e2e","status":"in_progress","conclusion":null,"started_at":"2026-07-02T17:07:17Z"}]}' +FAILED='{"total_count":2,"check_runs":[ + {"name":"test","status":"completed","conclusion":"success","started_at":"2026-07-02T17:07:15Z"}, + {"name":"e2e","status":"completed","conclusion":"failure","started_at":"2026-07-02T17:07:17Z"}]}' +CANCELLED='{"total_count":1,"check_runs":[ + {"name":"e2e","status":"completed","conclusion":"cancelled","started_at":"2026-07-02T17:07:17Z"}]}' +NULL_CONCL='{"total_count":1,"check_runs":[ + {"name":"weird","status":"completed","conclusion":null,"started_at":"2026-07-02T17:07:17Z"}]}' +NEUTRAL='{"total_count":1,"check_runs":[ + {"name":"advisory","status":"completed","conclusion":"neutral","started_at":"2026-07-02T17:07:17Z"}]}' +EMPTY='{"total_count":0,"check_runs":[]}' +# A superseded run (older, cancelled) plus its rerun (newer, success) for the same name. +RERUN='{"total_count":2,"check_runs":[ + {"name":"e2e","status":"completed","conclusion":"cancelled","started_at":"2026-07-02T10:00:00Z"}, + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T10:30:00Z"}]}' +# Same name, older success but newer run still running -> latest is incomplete. +RERUN_RUNNING='{"total_count":2,"check_runs":[ + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T10:00:00Z"}, + {"name":"e2e","status":"in_progress","conclusion":null,"started_at":"2026-07-02T10:30:00Z"}]}' + +echo "# incomplete-count / failures (latest run per name)" +assert_eq "all green: 0 incomplete" "0" "$(check_runs_incomplete_count "$ALL_GREEN")" +assert_eq "all green: no failures" "" "$(check_runs_failures "$ALL_GREEN")" +assert_eq "running: 1 incomplete" "1" "$(check_runs_incomplete_count "$RUNNING")" +assert_eq "failed: e2e reported" "e2e: failure" "$(check_runs_failures "$FAILED")" +assert_eq "cancelled: e2e reported" "e2e: cancelled" "$(check_runs_failures "$CANCELLED")" +assert_eq "null conclusion is a failure" "weird: none" "$(check_runs_failures "$NULL_CONCL")" +assert_eq "neutral passes" "" "$(check_runs_failures "$NEUTRAL")" +assert_eq "empty: 0 incomplete" "0" "$(check_runs_incomplete_count "$EMPTY")" +assert_eq "empty: no failures" "" "$(check_runs_failures "$EMPTY")" + +echo "# reruns: only the latest run per name counts" +assert_eq "rerun success supersedes cancelled: no failures" "" "$(check_runs_failures "$RERUN")" +assert_eq "rerun success supersedes cancelled: 0 incomplete" "0" "$(check_runs_incomplete_count "$RERUN")" +assert_eq "rerun still running: 1 incomplete" "1" "$(check_runs_incomplete_count "$RERUN_RUNNING")" + +echo "# required_checks_pending (names absent or latest run not completed)" +assert_eq "required all present+completed: none pending" "" \ + "$(required_checks_pending "$ALL_GREEN" "test,client_test,e2e")" +assert_eq "required e2e still running: e2e pending" "e2e" \ + "$(required_checks_pending "$RUNNING" "test,e2e")" +assert_eq "required missing name reported pending" "missingcheck" \ + "$(required_checks_pending "$ALL_GREEN" "missingcheck")" +assert_eq "required rerun still running: e2e pending" "e2e" \ + "$(required_checks_pending "$RERUN_RUNNING" "e2e")" +assert_eq "empty required list: nothing pending" "" \ + "$(required_checks_pending "$ALL_GREEN" "")" + +echo "# required_checks_failures (absent or bad-conclusion required names)" +assert_eq "required all green: no failures" "" \ + "$(required_checks_failures "$ALL_GREEN" "test,client_test,e2e")" +assert_eq "required missing: reported missing" "gone: missing" \ + "$(required_checks_failures "$ALL_GREEN" "gone")" +assert_eq "required failed: reported" "e2e: failure" \ + "$(required_checks_failures "$FAILED" "e2e")" +assert_eq "required cancelled (no rerun): reported" "e2e: cancelled" \ + "$(required_checks_failures "$CANCELLED" "e2e")" +assert_eq "required cancelled superseded by rerun success: no failure" "" \ + "$(required_checks_failures "$RERUN" "e2e")" +assert_eq "empty required list: no failures" "" \ + "$(required_checks_failures "$ALL_GREEN" "")" + +echo "# required names tolerate surrounding whitespace" +assert_eq "spaces after commas: none pending" "" \ + "$(required_checks_pending "$ALL_GREEN" "test, client_test, e2e")" +assert_eq "spaces around names: no failures" "" \ + "$(required_checks_failures "$ALL_GREEN" " test ,client_test, e2e ")" + +echo "# latest run per name is by id, so a newer run is never masked by an older one" +# Newer run (higher id) is still queued but has a null started_at. +RERUN_ID_QUEUED='{"total_count":2,"check_runs":[ + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T10:00:00Z","id":100}, + {"name":"e2e","status":"queued","conclusion":null,"started_at":null,"id":200}]}' +# Newer run (higher id) completed as a failure, older as success. +RERUN_ID_FAILED='{"total_count":2,"check_runs":[ + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T10:00:00Z","id":100}, + {"name":"e2e","status":"completed","conclusion":"failure","started_at":null,"id":200}]}' +assert_eq "newer queued run counts as incomplete" "1" "$(check_runs_incomplete_count "$RERUN_ID_QUEUED")" +assert_eq "newer queued run keeps required pending" "e2e" "$(required_checks_pending "$RERUN_ID_QUEUED" "e2e")" +assert_eq "newer failing run is not masked by older success" "e2e: failure" "$(check_runs_failures "$RERUN_ID_FAILED")" +assert_eq "newer failing required run reported" "e2e: failure" "$(required_checks_failures "$RERUN_ID_FAILED" "e2e")" + +echo "# a null check-run name must not crash the required-checks gate" +NULL_NAME='{"total_count":2,"check_runs":[ + {"name":null,"status":"completed","conclusion":"failure","started_at":"2026-07-02T10:00:00Z","id":1}, + {"name":"e2e","status":"completed","conclusion":"success","started_at":"2026-07-02T10:00:00Z","id":2}]}' +assert_eq "null name ignored: none pending" "" "$(required_checks_pending "$NULL_NAME" "e2e")" +assert_eq "null name ignored: no failures" "" "$(required_checks_failures "$NULL_NAME" "e2e")" +assert_eq "null name ignored: 0 incomplete" "0" "$(check_runs_incomplete_count "$NULL_NAME")" + +echo "# GraphQL statusCheckRollup: validity + normalization into the check-run model" +ROLLUP_MIXED='{"data":{"repository":{"object":{"statusCheckRollup":{"state":"PENDING","contexts":{"nodes":[ + {"__typename":"CheckRun","name":"e2e","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T17:07:17Z","databaseId":3}, + {"__typename":"CheckRun","name":"test","status":"IN_PROGRESS","conclusion":null,"startedAt":"2026-07-02T17:07:15Z","databaseId":4}, + {"__typename":"StatusContext","context":"buildkite/packmanager","state":"SUCCESS","createdAt":"2026-07-02T17:07:07Z"}]}}}}}}' +ROLLUP_STATUS_FAIL='{"data":{"repository":{"object":{"statusCheckRollup":{"contexts":{"nodes":[ + {"__typename":"StatusContext","context":"buildkite/packmanager","state":"FAILURE","createdAt":"2026-07-02T17:07:07Z"}]}}}}}}' +ROLLUP_NULL='{"data":{"repository":{"object":{"statusCheckRollup":null}}}}' +ROLLUP_ERROR='{"errors":[{"message":"Something went wrong"}]}' +ROLLUP_NOOBJECT='{"data":{"repository":{"object":null}}}' + +assert_ok "mixed rollup is valid" rollup_payload_valid "$ROLLUP_MIXED" +assert_ok "null rollup (commit, no checks) is valid" rollup_payload_valid "$ROLLUP_NULL" +assert_notok "errors payload invalid" rollup_payload_valid "$ROLLUP_ERROR" +assert_notok "unresolved commit invalid" rollup_payload_valid "$ROLLUP_NOOBJECT" + +# Legacy status + check-runs evaluated uniformly after normalization. +assert_eq "normalized: 1 incomplete (test in_progress)" "1" \ + "$(check_runs_incomplete_count "$(normalize_rollup "$ROLLUP_MIXED")")" +assert_eq "normalized: no failures" "" \ + "$(check_runs_failures "$(normalize_rollup "$ROLLUP_MIXED")")" +assert_eq "normalized: buildkite (a StatusContext) satisfies a required name" "" \ + "$(required_checks_pending "$(normalize_rollup "$ROLLUP_MIXED")" "buildkite/packmanager,e2e")" +assert_eq "normalized: in-progress required still pending" "test" \ + "$(required_checks_pending "$(normalize_rollup "$ROLLUP_MIXED")" "test")" +assert_eq "normalized: absent required still pending" "client_test" \ + "$(required_checks_pending "$(normalize_rollup "$ROLLUP_MIXED")" "client_test")" +assert_eq "normalized: a FAILURE legacy status is a failure" "buildkite/packmanager: failure" \ + "$(check_runs_failures "$(normalize_rollup "$ROLLUP_STATUS_FAIL")")" +assert_eq "normalized: required legacy status failure reported" "buildkite/packmanager: failure" \ + "$(required_checks_failures "$(normalize_rollup "$ROLLUP_STATUS_FAIL")" "buildkite/packmanager")" +assert_eq "normalized null rollup: 0 incomplete" "0" \ + "$(check_runs_incomplete_count "$(normalize_rollup "$ROLLUP_NULL")")" + +echo "# same name from two sources is not collapsed (bugs 1 & 2)" +# Two check-runs named "build" from different apps: one fails, one passes. +ROLLUP_TWO_APPS='{"data":{"repository":{"object":{"statusCheckRollup":{"contexts":{"nodes":[ + {"__typename":"CheckRun","name":"build","status":"COMPLETED","conclusion":"FAILURE","startedAt":"2026-07-02T10:00:00Z","databaseId":50,"checkSuite":{"app":{"databaseId":111}}}, + {"__typename":"CheckRun","name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T10:01:00Z","databaseId":100,"checkSuite":{"app":{"databaseId":222}}}]}}}}}}' +# A legacy status "test" (FAILURE) alongside an Actions check-run "test" (SUCCESS). +ROLLUP_STATUS_VS_CHECK='{"data":{"repository":{"object":{"statusCheckRollup":{"contexts":{"nodes":[ + {"__typename":"CheckRun","name":"test","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T10:00:00Z","databaseId":5,"checkSuite":{"app":{"databaseId":15368}}}, + {"__typename":"StatusContext","context":"test","state":"FAILURE","createdAt":"2026-07-02T11:00:00Z"}]}}}}}}' +assert_eq "two apps same name: failing app not masked" "build: failure" \ + "$(check_runs_failures "$(normalize_rollup "$ROLLUP_TWO_APPS")")" +assert_eq "two apps same name: required build fails" "build: failure" \ + "$(required_checks_failures "$(normalize_rollup "$ROLLUP_TWO_APPS")" "build")" +assert_eq "status vs check same name: failing status not masked" "test: failure" \ + "$(check_runs_failures "$(normalize_rollup "$ROLLUP_STATUS_VS_CHECK")")" +assert_eq "status vs check same name: required test fails" "test: failure" \ + "$(required_checks_failures "$(normalize_rollup "$ROLLUP_STATUS_VS_CHECK")" "test")" +# One source of a required name still running -> the name stays pending. +ROLLUP_ONE_SOURCE_RUNNING='{"data":{"repository":{"object":{"statusCheckRollup":{"contexts":{"nodes":[ + {"__typename":"CheckRun","name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T10:00:00Z","databaseId":50,"checkSuite":{"app":{"databaseId":111}}}, + {"__typename":"CheckRun","name":"build","status":"IN_PROGRESS","conclusion":null,"startedAt":"2026-07-02T10:01:00Z","databaseId":100,"checkSuite":{"app":{"databaseId":222}}}]}}}}}}' +assert_eq "one source running: required build still pending" "build" \ + "$(required_checks_pending "$(normalize_rollup "$ROLLUP_ONE_SOURCE_RUNNING")" "build")" +# Both sources of a required name pass -> no failure, nothing pending. +ROLLUP_TWO_APPS_PASS='{"data":{"repository":{"object":{"statusCheckRollup":{"contexts":{"nodes":[ + {"__typename":"CheckRun","name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T10:00:00Z","databaseId":50,"checkSuite":{"app":{"databaseId":111}}}, + {"__typename":"CheckRun","name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-07-02T10:01:00Z","databaseId":100,"checkSuite":{"app":{"databaseId":222}}}]}}}}}}' +assert_eq "two apps both pass: required build not pending" "" \ + "$(required_checks_pending "$(normalize_rollup "$ROLLUP_TWO_APPS_PASS")" "build")" +assert_eq "two apps both pass: required build no failure" "" \ + "$(required_checks_failures "$(normalize_rollup "$ROLLUP_TWO_APPS_PASS")" "build")" + +echo "# any_path_has_prefix" +FILES_MIXED=$'SensrTrxMES/app/x.js\nPackManager/db/schema.rb' +FILES_PM_ONLY=$'PackManager/db/schema.rb\n.github/workflows/integrate.yml' +assert_ok "matches SensrTrxMES/ prefix" any_path_has_prefix "$FILES_MIXED" "SensrTrxMES/" +assert_notok "PM-only does not match SensrTrxMES/" any_path_has_prefix "$FILES_PM_ONLY" "SensrTrxMES/" +assert_ok "matches one of several prefixes" any_path_has_prefix "$FILES_PM_ONLY" "SensrTrxMES/,PackManager/" +assert_notok "empty prefix list never matches" any_path_has_prefix "$FILES_MIXED" "" +assert_notok "prefix respects the trailing slash" any_path_has_prefix $'SensrTrxMESX/x.js' "SensrTrxMES/" + +exit $fail