|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Merge every open PR that is ready and fully green, then re-arm CI/CD. |
| 4 | +# |
| 5 | +# WHY THIS EXISTS |
| 6 | +# --------------- |
| 7 | +# Nobody reviews PRs on this fleet — the owner explicitly does not want to be in |
| 8 | +# the merge loop, and background-job agent sessions are barred from merging by |
| 9 | +# hand. So the policy lives here, in the repo, where it is visible, revocable, |
| 10 | +# and applies uniformly to every PR instead of depending on who opened it. |
| 11 | +# |
| 12 | +# THE POLICY |
| 13 | +# merge a PR <=> it is not a draft |
| 14 | +# AND carries no hold label |
| 15 | +# AND has at least one check |
| 16 | +# AND every check has finished green |
| 17 | +# AND GitHub reports it cleanly mergeable |
| 18 | +# |
| 19 | +# Anything else is left alone for the next sweep. Nothing here forces a merge: |
| 20 | +# a red or pending PR simply waits, and a draft waits forever. To hold a ready |
| 21 | +# PR back, mark it a draft or add one of the hold labels below. |
| 22 | +# |
| 23 | +# ONE PR PER SWEEP, AND ONLY ONTO A GREEN BASE |
| 24 | +# -------------------------------------------- |
| 25 | +# A PR's checks prove *that PR against the base it branched from* — not against |
| 26 | +# the other PRs sitting next to it. Merging a batch in one pass would put a |
| 27 | +# combination onto the base that nothing ever built. So this script merges at |
| 28 | +# most one PR, then hands control back to CI: the merge train advances one car |
| 29 | +# per sweep, and every car is verified on the base before the next one couples. |
| 30 | +# |
| 31 | +# For the same reason it refuses to merge while the base's CI is red or still |
| 32 | +# running. Red base => stop adding changes until it is fixed; running CI => the |
| 33 | +# answer is not in yet. Both simply defer to the next sweep. |
| 34 | +# |
| 35 | +# THE RE-ARM (do not remove) |
| 36 | +# A push made with the default GITHUB_TOKEN does NOT trigger workflows. Both |
| 37 | +# CI and the deploy workflow here run on push, so a merge from this script |
| 38 | +# would otherwise land on the base branch and never build or ship. Worse, the |
| 39 | +# green-base guard above keys on "a CI run exists for the current tip" — with |
| 40 | +# no CI run ever produced by an automated merge, the very next sweep would |
| 41 | +# block forever. The explicit workflow_dispatch calls at the end restore both. |
| 42 | +# |
| 43 | +# REARM_WORKFLOWS is set by .github/workflows/auto-merge.yml and lists exactly |
| 44 | +# the workflows that would otherwise have fired on push. |
| 45 | + |
| 46 | +set -euo pipefail |
| 47 | + |
| 48 | +REPO="${GH_REPO:?GH_REPO must be set}" |
| 49 | +BASE_BRANCH="${BASE_BRANCH:-main}" |
| 50 | +CI_WORKFLOW="${CI_WORKFLOW:-ci.yml}" |
| 51 | +REARM_WORKFLOWS="${REARM_WORKFLOWS:-$CI_WORKFLOW}" |
| 52 | + |
| 53 | +# A PR wearing any of these is never merged automatically. |
| 54 | +HOLD_LABELS='["hold","no-automerge","do-not-merge","wip"]' |
| 55 | + |
| 56 | +echo "[auto-merge] sweeping open PRs against ${BASE_BRANCH} in ${REPO}" |
| 57 | + |
| 58 | +# Never add changes to a base that is red or mid-verification. |
| 59 | +# |
| 60 | +# The run has to belong to the CURRENT tip of the base branch. Checking only |
| 61 | +# "the latest CI run" is a trap: right after a merge, the newest run is still |
| 62 | +# the *previous* commit's — and it is green — so the guard would wave through a |
| 63 | +# second merge onto a commit nothing has verified yet. That is exactly the |
| 64 | +# batching this script exists to prevent. |
| 65 | +base_sha=$(gh api "repos/${REPO}/commits/${BASE_BRANCH}" --jq '.sha') |
| 66 | +base_ci=$(gh run list --repo "$REPO" --workflow "$CI_WORKFLOW" --branch "$BASE_BRANCH" --limit 1 \ |
| 67 | + --json status,conclusion,headSha --jq '.[0] // empty') |
| 68 | + |
| 69 | +if [ -z "$base_ci" ]; then |
| 70 | + echo "[auto-merge] no CI history for ${BASE_BRANCH} — proceeding" |
| 71 | +else |
| 72 | + base_status=$(printf '%s' "$base_ci" | jq -r '.status') |
| 73 | + base_conclusion=$(printf '%s' "$base_ci" | jq -r '.conclusion // ""') |
| 74 | + base_ci_sha=$(printf '%s' "$base_ci" | jq -r '.headSha') |
| 75 | + |
| 76 | + if [ "$base_ci_sha" != "$base_sha" ]; then |
| 77 | + echo "[auto-merge] ${BASE_BRANCH} is at ${base_sha:0:8} but the newest CI run is for ${base_ci_sha:0:8} — waiting for CI to catch up" |
| 78 | + exit 0 |
| 79 | + fi |
| 80 | + if [ "$base_status" != "completed" ]; then |
| 81 | + echo "[auto-merge] ${BASE_BRANCH} CI is still running — deferring to the next sweep" |
| 82 | + exit 0 |
| 83 | + fi |
| 84 | + if [ "$base_conclusion" != "success" ]; then |
| 85 | + echo "[auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} — refusing to merge onto a broken base" >&2 |
| 86 | + exit 0 |
| 87 | + fi |
| 88 | +fi |
| 89 | + |
| 90 | +prs_json=$(gh pr list --repo "$REPO" --state open --base "$BASE_BRANCH" --limit 50 \ |
| 91 | + --json number,title,isDraft,mergeable,mergeStateStatus,labels,statusCheckRollup) |
| 92 | + |
| 93 | +count=$(printf '%s' "$prs_json" | jq 'length') |
| 94 | +if [ "$count" -eq 0 ]; then |
| 95 | + echo "[auto-merge] no open PRs" |
| 96 | + exit 0 |
| 97 | +fi |
| 98 | + |
| 99 | +merged_any=0 |
| 100 | + |
| 101 | +for number in $(printf '%s' "$prs_json" | jq -r '.[].number'); do |
| 102 | + pr=$(printf '%s' "$prs_json" | jq -c --argjson n "$number" '.[] | select(.number == $n)') |
| 103 | + title=$(printf '%s' "$pr" | jq -r '.title') |
| 104 | + |
| 105 | + # A rollup entry is either a CheckRun (status + conclusion) or a commit |
| 106 | + # StatusContext (state) — external services report as the latter. |
| 107 | + verdict=$(printf '%s' "$pr" | jq -r --argjson hold "$HOLD_LABELS" ' |
| 108 | + def ok: |
| 109 | + if has("state") then (.state == "SUCCESS") |
| 110 | + else ((.status == "COMPLETED") |
| 111 | + and ((.conclusion // "") | test("^(SUCCESS|NEUTRAL|SKIPPED)$"))) end; |
| 112 | + def pending: |
| 113 | + if has("state") then (.state == "PENDING") |
| 114 | + else (.status != "COMPLETED") end; |
| 115 | +
|
| 116 | + . as $pr |
| 117 | + | (($pr.statusCheckRollup) // []) as $checks |
| 118 | + | if $pr.isDraft then "skip: draft" |
| 119 | + elif ([$pr.labels[]?.name] | any(. as $l | $hold | index($l) != null)) |
| 120 | + then "skip: hold label" |
| 121 | + elif ($checks | length) == 0 then "skip: no checks reported yet" |
| 122 | + elif ($checks | map(pending) | any) then "skip: checks still running" |
| 123 | + elif (($checks | map(ok) | all) | not) then "skip: checks not green" |
| 124 | + else "merge" end |
| 125 | + ') |
| 126 | + |
| 127 | + if [ "$verdict" != "merge" ]; then |
| 128 | + echo "[auto-merge] #${number} ${verdict} — ${title}" |
| 129 | + |
| 130 | + # A CANCELLED check is not a verdict, it is noise: CI workflows in this |
| 131 | + # fleet use `concurrency: cancel-in-progress`, so an unrelated newer run on |
| 132 | + # the same ref can kill a PR's build. Nothing ever re-runs it, the PR is |
| 133 | + # never green, and it would sit in this queue forever. Re-run it and let a |
| 134 | + # later sweep judge the real result. Genuine failures are left alone; only a |
| 135 | + # run with no real failure is retried. |
| 136 | + if [ "$verdict" = "skip: checks not green" ]; then |
| 137 | + retry_urls=$(printf '%s' "$pr" | jq -r ' |
| 138 | + [ .statusCheckRollup[]? |
| 139 | + | select(has("state") | not) |
| 140 | + | select((.conclusion // "") == "CANCELLED") |
| 141 | + | .detailsUrl ] as $cancelled |
| 142 | + | [ .statusCheckRollup[]? |
| 143 | + | select(((.conclusion // .state // "") |
| 144 | + | test("^(FAILURE|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE|ERROR)$"))) ] as $failed |
| 145 | + | if ($failed | length) == 0 then $cancelled[] else empty end |
| 146 | + ') |
| 147 | + for url in $retry_urls; do |
| 148 | + run_id=$(printf '%s' "$url" | grep -oE '/runs/[0-9]+' | grep -oE '[0-9]+' || true) |
| 149 | + [ -z "$run_id" ] && continue |
| 150 | + echo "[auto-merge] #${number} re-running cancelled run ${run_id}" |
| 151 | + gh run rerun "$run_id" --repo "$REPO" || echo "[auto-merge] #${number} could not re-run ${run_id}" >&2 |
| 152 | + done |
| 153 | + fi |
| 154 | + continue |
| 155 | + fi |
| 156 | + |
| 157 | + # Mergeability is computed lazily by GitHub and is invalidated every time the |
| 158 | + # base branch moves — so right after a merge (exactly when this workflow runs) |
| 159 | + # every PR reports UNKNOWN. Poll until GitHub has an answer instead of |
| 160 | + # treating "not computed yet" as "not mergeable"; otherwise the fast path can |
| 161 | + # never merge anything and the whole train falls back to the cron. |
| 162 | + mergeable="" |
| 163 | + state="" |
| 164 | + for attempt in 1 2 3 4 5 6; do |
| 165 | + fresh=$(gh pr view "$number" --repo "$REPO" --json mergeable,mergeStateStatus) |
| 166 | + mergeable=$(printf '%s' "$fresh" | jq -r '.mergeable') |
| 167 | + state=$(printf '%s' "$fresh" | jq -r '.mergeStateStatus') |
| 168 | + [ "$mergeable" != "UNKNOWN" ] && break |
| 169 | + echo "[auto-merge] #${number} mergeability not computed yet (attempt ${attempt}) — waiting" |
| 170 | + sleep 5 |
| 171 | + done |
| 172 | + |
| 173 | + if [ "$mergeable" != "MERGEABLE" ]; then |
| 174 | + echo "[auto-merge] #${number} skip: not mergeable (${mergeable}/${state}) — ${title}" |
| 175 | + continue |
| 176 | + fi |
| 177 | + |
| 178 | + echo "[auto-merge] #${number} green and ready — merging: ${title}" |
| 179 | + if gh pr merge "$number" --repo "$REPO" --squash --delete-branch; then |
| 180 | + merged_any=1 |
| 181 | + echo "[auto-merge] #${number} merged" |
| 182 | + # One car per sweep: let CI verify this on the base before the next couples. |
| 183 | + break |
| 184 | + else |
| 185 | + # Losing a race (someone merged first, or the base moved underneath) is |
| 186 | + # normal; the next sweep re-evaluates from fresh state. |
| 187 | + echo "[auto-merge] #${number} merge failed — leaving for the next sweep" >&2 |
| 188 | + fi |
| 189 | +done |
| 190 | + |
| 191 | +if [ "$merged_any" -eq 1 ]; then |
| 192 | + for wf in $REARM_WORKFLOWS; do |
| 193 | + echo "[auto-merge] re-arming ${wf} on ${BASE_BRANCH}" |
| 194 | + gh workflow run "$wf" --repo "$REPO" --ref "$BASE_BRANCH" \ |
| 195 | + || echo "[auto-merge] could not dispatch ${wf} — is workflow_dispatch declared?" >&2 |
| 196 | + done |
| 197 | +else |
| 198 | + echo "[auto-merge] nothing merged; no re-arm needed" |
| 199 | +fi |
0 commit comments