Skip to content

auto-update-pr

auto-update-pr #2677

# Flywheel: after a merge to the base branch, find one open PR that is
# out-of-date (behind base) but otherwise passing + conflict-free, and press
# its "Update branch" button. Updating retriggers the PR's CI; if it goes green
# and the PR has auto-merge enabled, it merges -> another push to base -> this
# workflow runs again -> the next stale PR gets updated, and so on.
#
# Only ONE PR is updated per run, on purpose: updating every stale PR at once
# makes them all up-to-date simultaneously, the first merges, the rest are
# instantly stale again, and a full CI run is burned on each for nothing.
#
# Uses PROSOPONATOR_PAT (not the default GITHUB_TOKEN) so the branch update is
# attributed to a real user and therefore retriggers the PR's CI workflows.
#
# It also stands down entirely whenever an auto-merge PR already has CI in
# flight -- see the gate step for why, and for the two ways that gate is stopped
# from wedging.
#
# INTERIM. This is a hand-rolled merge queue. GitHub's own merge queue provides
# the same benefit as "require branches to be up to date" without making anyone
# press "Update branch", so once the queue is switched on for a repo this
# workflow should be deleted there rather than left running alongside it.
name: auto-update-pr
on:
push:
branches: [main]
# backstop: catches the case where a stale PR's CI failed and stalled the
# flywheel, then the base later moved for some other reason.
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
pull-requests: read
# Single-flight: never let two runs race to update PRs and double-spend CI.
concurrency:
group: auto-update-pr
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
update-stale-pr:
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
# own, where it can also persist and poison later trusted jobs. Nothing is
# given up by staying hosted: standard GitHub-hosted runners are free and
# unlimited on public repos, macOS and Windows included. Only *larger*
# runners are billed, so do not "upgrade" a job to one.
# https://docs.github.com/en/billing/reference/actions-runner-pricing
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.PROSOPONATOR_PAT }}
REPO: ${{ github.repository }}
BASE: main
# how long to wait for GitHub to finish computing mergeability per PR
POLL_ATTEMPTS: "6"
POLL_SLEEP_SECONDS: "10"
# A check QUEUED or IN_PROGRESS for longer than this is treated as hung
# rather than busy, so it cannot gate the flywheel forever. Longer than the
# slowest browser-matrix job, shorter than a working day.
IN_FLIGHT_MAX_AGE_MINUTES: "90"
# After pressing "Update branch", wait for the new checks to show up before
# exiting, so the next run sees them and stands down instead of updating a
# second PR. Floor applies even if the rollup never reports.
SETTLE_ATTEMPTS: "9"
SETTLE_SLEEP_SECONDS: "10"
steps:
# Stand down whenever an auto-merge PR already has CI in flight: that PR is
# effectively next in the merge queue, so spending fleet capacity updating a
# different branch only creates contention with it.
#
# The two things this gate must never do (and the reason it is written the
# long way):
#
# (a) It must not let one bad PR wedge the flywheel. A PR whose workflows
# never triggered has ZERO rollup entries -- that is stalled, not busy,
# and must not gate. A check stuck QUEUED/IN_PROGRESS past
# IN_FLIGHT_MAX_AGE_MINUTES is hung, likewise not busy; it is logged
# loudly so a genuinely hung job is visible rather than silently
# ignored. Between them, the gate can only hold while real work is
# really running.
#
# (b) It must not let several PRs churn at once. Once this run updates a
# PR, that PR has checks running and the next run sees them and stands
# down -- provided the checks have actually been reported, which is
# what the settle loop at the end of the next step waits for.
#
# Age is computed from startedAt (check runs) / createdAt (status contexts),
# both of which GitHub returns for pending work.
- name: Stand down if an auto-merge PR already has CI in flight
id: gate
run: |
set -euo pipefail
in_flight=$(
gh pr list --repo "$REPO" --state open --base "$BASE" --limit 100 \
--json number,isDraft,autoMergeRequest,statusCheckRollup \
| jq -r --argjson maxage "$IN_FLIGHT_MAX_AGE_MINUTES" '
[ .[]
| select(.isDraft == false)
| select(.autoMergeRequest != null)
# zero rollup entries => nothing is running => not in flight
| select((.statusCheckRollup // []) | length > 0)
| { number,
running: [ .statusCheckRollup[]
| select(.status == "QUEUED" or .status == "IN_PROGRESS"
or .state == "PENDING")
| (.startedAt // .createdAt // empty)
| (now - fromdateiso8601) / 60 ] }
| select(.running | length > 0)
# youngest pending check decides: if anything started recently,
# this PR is genuinely busy
| { number, age: (.running | min) }
| select(.age <= $maxage)
| "\(.number) \(.age | floor)m" ]
| .[]'
)
if [ -n "$in_flight" ]; then
echo "Auto-merge PRs with CI in flight:"
echo "$in_flight"
echo "Standing down. The */30 cron retries once this drains."
echo "gated=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Anything pending but older than the cap is hung, not busy. Surface it
# -- proceeding past a hung check is deliberate, not an oversight.
gh pr list --repo "$REPO" --state open --base "$BASE" --limit 100 \
--json number,isDraft,autoMergeRequest,statusCheckRollup \
| jq -r --argjson maxage "$IN_FLIGHT_MAX_AGE_MINUTES" '
[ .[]
| select(.isDraft == false)
| select(.autoMergeRequest != null)
| { number,
running: [ .statusCheckRollup[]?
| select(.status == "QUEUED" or .status == "IN_PROGRESS"
or .state == "PENDING")
| (.startedAt // .createdAt // empty)
| (now - fromdateiso8601) / 60 ] }
| select(.running | length > 0)
| select((.running | min) > $maxage)
| "::warning::PR #\(.number) has a check pending for \((.running | min) | floor) minutes; treating it as hung, not in flight." ]
| .[]' || true
echo "No auto-merge PR has CI in flight."
echo "gated=false" >> "$GITHUB_OUTPUT"
- name: Update one stale-but-passing PR
if: steps.gate.outputs.gated == 'false'
run: |
set -euo pipefail
# Open, non-draft PRs targeting BASE that have auto-merge enabled,
# oldest first (lowest number) for FIFO fairness.
# Drop the `.autoMergeRequest != null` filter to also flywheel PRs
# that don't yet have auto-merge enabled.
mapfile -t prs < <(
gh pr list --repo "$REPO" --state open --base "$BASE" --limit 100 \
--json number,isDraft,autoMergeRequest \
--jq '[ .[]
| select(.isDraft == false)
| select(.autoMergeRequest != null)
| .number ]
| sort
| .[]'
)
if [ "${#prs[@]}" -eq 0 ]; then
echo "No open auto-merge PRs targeting $BASE. Nothing to do."
exit 0
fi
echo "Candidate PRs (auto-merge, targeting $BASE): ${prs[*]}"
for n in "${prs[@]}"; do
echo "::group::PR #$n"
echo "Considering https://github.com/$REPO/pull/$n"
mergeable=""
state=""
# Poll until GitHub finishes computing the merge state (it is lazy
# and returns UNKNOWN right after a base change).
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
if ! out=$(
gh pr view "$n" --repo "$REPO" \
--json mergeable,mergeStateStatus \
--jq '"\(.mergeable) \(.mergeStateStatus)"'
); then
echo "Failed to fetch merge state for PR #$n; skipping."
mergeable="UNKNOWN"; state="UNKNOWN"
break
fi
read -r mergeable state <<<"$out"
echo "attempt $attempt: mergeable=$mergeable mergeStateStatus=$state"
if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then
break
fi
sleep "$POLL_SLEEP_SECONDS"
done
if [ "$mergeable" = "UNKNOWN" ] || [ "$state" = "UNKNOWN" ]; then
echo "Merge state still computing after polling; skipping PR #$n."
echo "::endgroup::"
continue
fi
# CONFLICTING => real conflicts, author must resolve. Skip.
if [ "$mergeable" != "MERGEABLE" ]; then
echo "PR #$n is not mergeable ($mergeable); skipping."
echo "::endgroup::"
continue
fi
# mergeStateStatus meanings we care about:
# BEHIND -> conflict-free, checks/reviews otherwise satisfied,
# only blocker is being out of date. THIS is the target.
# CLEAN -> already up to date and ready (auto-merge handles it).
# BLOCKED/UNSTABLE/DIRTY -> failing/pending checks, missing review,
# or conflicts: not something a branch update fixes.
if [ "$state" != "BEHIND" ]; then
echo "PR #$n mergeStateStatus=$state (not BEHIND); skipping."
echo "::endgroup::"
continue
fi
# Skip PRs with unresolved review conversations. A branch update
# won't unblock them, and we should not flywheel a PR that still has
# open feedback toward an auto-merge.
owner="${REPO%/*}"; name="${REPO#*/}"
if ! unresolved=$(
gh api graphql \
-f owner="$owner" -f name="$name" -F number="$n" \
-f query='
query($owner:String!, $name:String!, $number:Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
nodes { isResolved }
pageInfo { hasNextPage }
}
}
}
}' \
--jq '[ .data.repository.pullRequest.reviewThreads.nodes[]
| select(.isResolved == false) ] | length'
); then
echo "Failed to fetch review threads for PR #$n; skipping."
echo "::endgroup::"
continue
fi
if [ "$unresolved" -gt 0 ]; then
echo "PR #$n has $unresolved unresolved conversation(s); skipping."
echo "::endgroup::"
continue
fi
echo "PR #$n is behind, passing, and has no open conversations. Updating its branch..."
if ! gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"/repos/$REPO/pulls/$n/update-branch"; then
echo "Failed to update PR #$n; skipping."
echo "::endgroup::"
continue
fi
# Do not exit until the update has actually produced queued/running
# checks. Between pressing "Update branch" and GitHub reporting the
# new run there is a window in which the next invocation would see an
# idle repo and update a SECOND PR -- which is exactly the concurrent
# churn the gate exists to prevent. Waiting here closes it.
settled=false
for attempt in $(seq 1 "$SETTLE_ATTEMPTS"); do
sleep "$SETTLE_SLEEP_SECONDS"
running=$(
gh pr view "$n" --repo "$REPO" --json statusCheckRollup \
--jq '[ .statusCheckRollup[]?
| select(.status == "QUEUED" or .status == "IN_PROGRESS"
or .state == "PENDING") ] | length'
) || running=0
echo "settle attempt $attempt: $running check(s) queued/running on PR #$n"
if [ "$running" -gt 0 ]; then
settled=true
break
fi
done
if [ "$settled" != true ]; then
echo "::warning::PR #$n reported no queued checks after the settle window; the next run may pick up another PR."
fi
echo "Updated PR #$n. Done (one per run)."
echo "::endgroup::"
exit 0
done
echo "No behind-but-passing PR found this run."