auto-update-pr #2677
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
| # 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." |