Skip to content

Auto-update PR branches after push to main #3863

Auto-update PR branches after push to main

Auto-update PR branches after push to main #3863

name: "Auto-update PR branches"
run-name: "Auto-update PR branches after push to ${{ github.ref_name }}"
# When `main` advances, update every open PR that has auto-merge enabled
# and is BEHIND, so polish / impl PRs squash-merge themselves end-to-end
# without manual "Update branch" clicks.
#
# NOTE (verified 2026-07-24): the `main` ruleset currently sets
# `strict_required_status_checks_policy: false`, so an up-to-date head is
# NOT actually required to merge. This file used to claim the opposite.
# That means every update-branch call is optional work which also
# invalidates the green checks on the previous head and forces a full CI
# re-run. Retiring this workflow is worth evaluating — see #9772; it is
# kept for now because the ruleset could be tightened again, and because
# behaviour changes here affect every auto-merge PR in the repo.
#
# Why we don't use GitHub's native merge queue: this repository is
# user-owned and merge_queue rules are not available on the account's
# plan — the rulesets API rejects the rule with an empty validation
# error. The auto-update workflow gives us the same end-user behavior
# (no manual "Update branch" clicks) without the plan dependency.
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write # update-branch creates a merge commit on the head ref
pull-requests: write # required to call PUT /pulls/:num/update-branch
concurrency:
group: auto-update-pr-branches
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Wait for PR mergeable state to settle
# GitHub recomputes `mergeStateStatus` asynchronously after a
# push to main. If we list PRs immediately the field can still
# be "UNKNOWN" — and the cached head SHA on the PR can lag
# behind the actual ref, so update-branch returns "expected
# head sha didn't match current head ref." Give GitHub a
# moment to settle.
run: sleep 30
- name: Update PRs with auto-merge enabled
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -eo pipefail
# After a push to main, every open PR with auto-merge is by
# definition behind. Don't filter on `mergeStateStatus`:
# right after the push it can still be UNKNOWN, and we'd
# skip valid candidates. Just iterate every open
# auto-merge PR — update-branch is a no-op when the head
# is already up-to-date.
PRS=$(gh pr list \
--repo "$GH_REPO" \
--state open \
--base main \
--limit 200 \
--json number,title,headRefName,autoMergeRequest)
# Skip Dependabot branches. Updating them here is actively
# harmful: the update-branch merge commit is authored by
# github-actions[bot], and GitHub gates every workflow run
# triggered by that push behind manual approval
# ("action_required"). Because `main` advances every few minutes
# while the impl pipeline merges, the branch is re-updated long
# before anyone can approve — so CI never completes and
# auto-merge can never fire. PR #9674 accumulated 174 runs in
# 22 h that way: 162 action_required (github-actions[bot]) vs.
# only 4 green (dependabot[bot], from the original push).
#
# Leaving them alone is safe because the `main` ruleset is NOT
# strict (see the header) — a Dependabot PR merges while behind,
# so it never needs this update at all. Do NOT justify this by
# assuming Dependabot re-syncs them for us: it rebases on
# conflict / schedule / reopen, not on "merely behind", and it
# stops rebasing a branch entirely once a foreign commit lands on
# it. The point is that the head now stays put, so a branch this
# workflow already touched needs its gated run approved exactly
# once instead of being re-gated every few minutes.
CANDIDATES=$(echo "$PRS" | jq -c '
[ .[]
| select(.autoMergeRequest != null)
| select(.headRefName | startswith("dependabot/") | not)
]
')
SKIPPED=$(echo "$PRS" | jq '
[ .[]
| select(.autoMergeRequest != null)
| select(.headRefName | startswith("dependabot/"))
] | length
')
if [[ "$SKIPPED" -gt 0 ]]; then
echo "::notice::Skipping $SKIPPED Dependabot PR(s) — updating them gates their CI behind manual approval, and the non-strict ruleset means being behind does not block the merge (see comment above)"
fi
COUNT=$(echo "$CANDIDATES" | jq 'length')
echo "::notice::Found $COUNT open PR(s) with auto-merge enabled"
if [[ "$COUNT" -eq 0 ]]; then
exit 0
fi
echo "$CANDIDATES" | jq -c '.[]' | while read -r pr; do
NUM=$(echo "$pr" | jq -r '.number')
TITLE=$(echo "$pr" | jq -r '.title')
BRANCH=$(echo "$pr" | jq -r '.headRefName')
echo "::notice::Updating PR #${NUM} (${BRANCH}): ${TITLE}"
# Capture stderr so the actual GitHub error (conflict,
# SHA mismatch, etc.) lands in the workflow log instead
# of being swallowed. Failures here don't fail the job —
# one stuck PR shouldn't block the others.
if ! OUT=$(gh api -X PUT \
"repos/${GH_REPO}/pulls/${NUM}/update-branch" \
-H "Accept: application/vnd.github+json" 2>&1); then
echo "::warning::Could not update PR #${NUM}: ${OUT}"
fi
done