Auto-update PR branches after push to main #3881
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |