Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Auto-merge — nobody is in the merge loop.
#
# Green, ready PRs merge themselves and deploy themselves. The owner does not
# review PRs, and background-job agent sessions are barred from merging by hand,
# so the policy lives in scripts/ci/auto-merge-sweep.sh (read it — it defines
# exactly what "ready" means and how a PR is held back).
#
# Two triggers, deliberately:
# workflow_run — merges within seconds of CI going green (the common path).
# schedule — a safety net. Catches PRs whose checks finished while this
# workflow was failing/disabled, and PRs whose last check was
# an external status that reported after CI. Without it, a PR
# that went green "off-cycle" waits forever.
#
# To stop all of this: delete this file, or add a `hold` label to a PR.

name: Auto-merge

on:
workflow_run:
workflows: ['CI']
types: [completed]
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}

permissions:
contents: write # merge the PR
pull-requests: write # read PR state, delete the branch
actions: write # dispatch the re-arm workflows

# Never let two sweeps merge concurrently — they would race on the same PRs.
concurrency:
group: auto-merge
cancel-in-progress: false

jobs:
sweep:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- name: Merge every green, ready PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
BASE_BRANCH: ${{ github.event.repository.default_branch }}
CI_WORKFLOW: ci.yml
# Everything that runs on push and therefore would NOT fire after a
# merge made with the default GITHUB_TOKEN. Keep this in sync when a
# push-triggered workflow is added.
REARM_WORKFLOWS: ci.yml deploy.yml
run: bash scripts/ci/auto-merge-sweep.sh
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ name: CI
# CI to be green, and every deploy would sail through unverified.

on:
workflow_dispatch: {}
push:
branches: [main]
pull_request:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ name: Deploy
# looks while the real production build only ever changed by hand.

on:
workflow_dispatch: {}
push:
branches: [main]

Expand Down
199 changes: 199 additions & 0 deletions scripts/ci/auto-merge-sweep.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
#
# Merge every open PR that is ready and fully green, then re-arm CI/CD.
#
# WHY THIS EXISTS
# ---------------
# Nobody reviews PRs on this fleet — the owner explicitly does not want to be in
# the merge loop, and background-job agent sessions are barred from merging by
# hand. So the policy lives here, in the repo, where it is visible, revocable,
# and applies uniformly to every PR instead of depending on who opened it.
#
# THE POLICY
# merge a PR <=> it is not a draft
# AND carries no hold label
# AND has at least one check
# AND every check has finished green
# AND GitHub reports it cleanly mergeable
#
# Anything else is left alone for the next sweep. Nothing here forces a merge:
# a red or pending PR simply waits, and a draft waits forever. To hold a ready
# PR back, mark it a draft or add one of the hold labels below.
#
# ONE PR PER SWEEP, AND ONLY ONTO A GREEN BASE
# --------------------------------------------
# A PR's checks prove *that PR against the base it branched from* — not against
# the other PRs sitting next to it. Merging a batch in one pass would put a
# combination onto the base that nothing ever built. So this script merges at
# most one PR, then hands control back to CI: the merge train advances one car
# per sweep, and every car is verified on the base before the next one couples.
#
# For the same reason it refuses to merge while the base's CI is red or still
# running. Red base => stop adding changes until it is fixed; running CI => the
# answer is not in yet. Both simply defer to the next sweep.
#
# THE RE-ARM (do not remove)
# A push made with the default GITHUB_TOKEN does NOT trigger workflows. Both
# CI and the deploy workflow here run on push, so a merge from this script
# would otherwise land on the base branch and never build or ship. Worse, the
# green-base guard above keys on "a CI run exists for the current tip" — with
# no CI run ever produced by an automated merge, the very next sweep would
# block forever. The explicit workflow_dispatch calls at the end restore both.
#
# REARM_WORKFLOWS is set by .github/workflows/auto-merge.yml and lists exactly
# the workflows that would otherwise have fired on push.

set -euo pipefail

REPO="${GH_REPO:?GH_REPO must be set}"
BASE_BRANCH="${BASE_BRANCH:-main}"
CI_WORKFLOW="${CI_WORKFLOW:-ci.yml}"
REARM_WORKFLOWS="${REARM_WORKFLOWS:-$CI_WORKFLOW}"

# A PR wearing any of these is never merged automatically.
HOLD_LABELS='["hold","no-automerge","do-not-merge","wip"]'

echo "[auto-merge] sweeping open PRs against ${BASE_BRANCH} in ${REPO}"

# Never add changes to a base that is red or mid-verification.
#
# The run has to belong to the CURRENT tip of the base branch. Checking only
# "the latest CI run" is a trap: right after a merge, the newest run is still
# the *previous* commit's — and it is green — so the guard would wave through a
# second merge onto a commit nothing has verified yet. That is exactly the
# batching this script exists to prevent.
base_sha=$(gh api "repos/${REPO}/commits/${BASE_BRANCH}" --jq '.sha')
base_ci=$(gh run list --repo "$REPO" --workflow "$CI_WORKFLOW" --branch "$BASE_BRANCH" --limit 1 \
--json status,conclusion,headSha --jq '.[0] // empty')

if [ -z "$base_ci" ]; then
echo "[auto-merge] no CI history for ${BASE_BRANCH} — proceeding"
else
base_status=$(printf '%s' "$base_ci" | jq -r '.status')
base_conclusion=$(printf '%s' "$base_ci" | jq -r '.conclusion // ""')
base_ci_sha=$(printf '%s' "$base_ci" | jq -r '.headSha')

if [ "$base_ci_sha" != "$base_sha" ]; then
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"
exit 0
fi
if [ "$base_status" != "completed" ]; then
echo "[auto-merge] ${BASE_BRANCH} CI is still running — deferring to the next sweep"
exit 0
fi
if [ "$base_conclusion" != "success" ]; then
echo "[auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} — refusing to merge onto a broken base" >&2
exit 0
fi
fi

prs_json=$(gh pr list --repo "$REPO" --state open --base "$BASE_BRANCH" --limit 50 \
--json number,title,isDraft,mergeable,mergeStateStatus,labels,statusCheckRollup)

count=$(printf '%s' "$prs_json" | jq 'length')
if [ "$count" -eq 0 ]; then
echo "[auto-merge] no open PRs"
exit 0
fi

merged_any=0

for number in $(printf '%s' "$prs_json" | jq -r '.[].number'); do
pr=$(printf '%s' "$prs_json" | jq -c --argjson n "$number" '.[] | select(.number == $n)')
title=$(printf '%s' "$pr" | jq -r '.title')

# A rollup entry is either a CheckRun (status + conclusion) or a commit
# StatusContext (state) — external services report as the latter.
verdict=$(printf '%s' "$pr" | jq -r --argjson hold "$HOLD_LABELS" '
def ok:
if has("state") then (.state == "SUCCESS")
else ((.status == "COMPLETED")
and ((.conclusion // "") | test("^(SUCCESS|NEUTRAL|SKIPPED)$"))) end;
def pending:
if has("state") then (.state == "PENDING")
else (.status != "COMPLETED") end;

. as $pr
| (($pr.statusCheckRollup) // []) as $checks
| if $pr.isDraft then "skip: draft"
elif ([$pr.labels[]?.name] | any(. as $l | $hold | index($l) != null))
then "skip: hold label"
elif ($checks | length) == 0 then "skip: no checks reported yet"
elif ($checks | map(pending) | any) then "skip: checks still running"
elif (($checks | map(ok) | all) | not) then "skip: checks not green"
else "merge" end
')

if [ "$verdict" != "merge" ]; then
echo "[auto-merge] #${number} ${verdict} — ${title}"

# A CANCELLED check is not a verdict, it is noise: CI workflows in this
# fleet use `concurrency: cancel-in-progress`, so an unrelated newer run on
# the same ref can kill a PR's build. Nothing ever re-runs it, the PR is
# never green, and it would sit in this queue forever. Re-run it and let a
# later sweep judge the real result. Genuine failures are left alone; only a
# run with no real failure is retried.
if [ "$verdict" = "skip: checks not green" ]; then
retry_urls=$(printf '%s' "$pr" | jq -r '
[ .statusCheckRollup[]?
| select(has("state") | not)
| select((.conclusion // "") == "CANCELLED")
| .detailsUrl ] as $cancelled
| [ .statusCheckRollup[]?
| select(((.conclusion // .state // "")
| test("^(FAILURE|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE|ERROR)$"))) ] as $failed
| if ($failed | length) == 0 then $cancelled[] else empty end
')
for url in $retry_urls; do
run_id=$(printf '%s' "$url" | grep -oE '/runs/[0-9]+' | grep -oE '[0-9]+' || true)
[ -z "$run_id" ] && continue
echo "[auto-merge] #${number} re-running cancelled run ${run_id}"
gh run rerun "$run_id" --repo "$REPO" || echo "[auto-merge] #${number} could not re-run ${run_id}" >&2
done
fi
continue
fi

# Mergeability is computed lazily by GitHub and is invalidated every time the
# base branch moves — so right after a merge (exactly when this workflow runs)
# every PR reports UNKNOWN. Poll until GitHub has an answer instead of
# treating "not computed yet" as "not mergeable"; otherwise the fast path can
# never merge anything and the whole train falls back to the cron.
mergeable=""
state=""
for attempt in 1 2 3 4 5 6; do
fresh=$(gh pr view "$number" --repo "$REPO" --json mergeable,mergeStateStatus)
mergeable=$(printf '%s' "$fresh" | jq -r '.mergeable')
state=$(printf '%s' "$fresh" | jq -r '.mergeStateStatus')
[ "$mergeable" != "UNKNOWN" ] && break
echo "[auto-merge] #${number} mergeability not computed yet (attempt ${attempt}) — waiting"
sleep 5
done

if [ "$mergeable" != "MERGEABLE" ]; then
echo "[auto-merge] #${number} skip: not mergeable (${mergeable}/${state}) — ${title}"
continue
fi

echo "[auto-merge] #${number} green and ready — merging: ${title}"
if gh pr merge "$number" --repo "$REPO" --squash --delete-branch; then
merged_any=1
echo "[auto-merge] #${number} merged"
# One car per sweep: let CI verify this on the base before the next couples.
break
else
# Losing a race (someone merged first, or the base moved underneath) is
# normal; the next sweep re-evaluates from fresh state.
echo "[auto-merge] #${number} merge failed — leaving for the next sweep" >&2
fi
done

if [ "$merged_any" -eq 1 ]; then
for wf in $REARM_WORKFLOWS; do
echo "[auto-merge] re-arming ${wf} on ${BASE_BRANCH}"
gh workflow run "$wf" --repo "$REPO" --ref "$BASE_BRANCH" \
|| echo "[auto-merge] could not dispatch ${wf} — is workflow_dispatch declared?" >&2
done
else
echo "[auto-merge] nothing merged; no re-arm needed"
fi
Loading