Skip to content

Commit 19cc803

Browse files
authored
ci: green PRs merge and deploy themselves (#127)
* ci: add the auto-merge sweep policy * ci: green PRs merge and deploy themselves * ci: allow CI to be dispatched so auto-merge can re-arm it * ci: allow deploy to be dispatched so an automated merge still ships
1 parent 7362057 commit 19cc803

4 files changed

Lines changed: 255 additions & 0 deletions

File tree

.github/workflows/auto-merge.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Auto-merge — nobody is in the merge loop.
2+
#
3+
# Green, ready PRs merge themselves and deploy themselves. The owner does not
4+
# review PRs, and background-job agent sessions are barred from merging by hand,
5+
# so the policy lives in scripts/ci/auto-merge-sweep.sh (read it — it defines
6+
# exactly what "ready" means and how a PR is held back).
7+
#
8+
# Two triggers, deliberately:
9+
# workflow_run — merges within seconds of CI going green (the common path).
10+
# schedule — a safety net. Catches PRs whose checks finished while this
11+
# workflow was failing/disabled, and PRs whose last check was
12+
# an external status that reported after CI. Without it, a PR
13+
# that went green "off-cycle" waits forever.
14+
#
15+
# To stop all of this: delete this file, or add a `hold` label to a PR.
16+
17+
name: Auto-merge
18+
19+
on:
20+
workflow_run:
21+
workflows: ['CI']
22+
types: [completed]
23+
schedule:
24+
- cron: '*/10 * * * *'
25+
workflow_dispatch: {}
26+
27+
permissions:
28+
contents: write # merge the PR
29+
pull-requests: write # read PR state, delete the branch
30+
actions: write # dispatch the re-arm workflows
31+
32+
# Never let two sweeps merge concurrently — they would race on the same PRs.
33+
concurrency:
34+
group: auto-merge
35+
cancel-in-progress: false
36+
37+
jobs:
38+
sweep:
39+
runs-on: ubuntu-latest
40+
timeout-minutes: 10
41+
steps:
42+
- uses: actions/checkout@v4
43+
44+
- name: Merge every green, ready PR
45+
env:
46+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+
GH_REPO: ${{ github.repository }}
48+
BASE_BRANCH: ${{ github.event.repository.default_branch }}
49+
CI_WORKFLOW: ci.yml
50+
# Everything that runs on push and therefore would NOT fire after a
51+
# merge made with the default GITHUB_TOKEN. Keep this in sync when a
52+
# push-triggered workflow is added.
53+
REARM_WORKFLOWS: ci.yml deploy.yml
54+
run: bash scripts/ci/auto-merge-sweep.sh

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ name: CI
77
# CI to be green, and every deploy would sail through unverified.
88

99
on:
10+
workflow_dispatch: {}
1011
push:
1112
branches: [main]
1213
pull_request:

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ name: Deploy
1010
# looks while the real production build only ever changed by hand.
1111

1212
on:
13+
workflow_dispatch: {}
1314
push:
1415
branches: [main]
1516

scripts/ci/auto-merge-sweep.sh

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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

Comments
 (0)