diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 00000000..bcdd2289 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ae9c2a..98eb4eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c66e428..1fdf29fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,6 +10,7 @@ name: Deploy # looks while the real production build only ever changed by hand. on: + workflow_dispatch: {} push: branches: [main] diff --git a/scripts/ci/auto-merge-sweep.sh b/scripts/ci/auto-merge-sweep.sh new file mode 100644 index 00000000..a4398148 --- /dev/null +++ b/scripts/ci/auto-merge-sweep.sh @@ -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