Auto Queue #1439
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
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # Temporary stand-in for GitHub Merge Queue. | |
| # | |
| # Triggers: | |
| # * push to main: advance the queue right after a merge. | |
| # * pull_request {auto_merge_enabled, ready_for_review}: a PR just | |
| # became eligible — kick the queue without waiting for cron. | |
| # * pull_request_review {submitted}: an approval may have just made | |
| # a PR eligible (script filters non-approval review states). | |
| # * workflow_run {Required Checks, completed}: the head PR's CI | |
| # just finished. On success, auto-merge fires and the next push to | |
| # main triggers us; on failure, the head PR's CI moves from PENDING | |
| # to FAILURE so the in-flight guard releases — this trigger gives | |
| # us a same-second kick instead of waiting on cron. | |
| # * 5-minute cron: bounded safety net for any missed event delivery | |
| # and for PRs that became BEHIND without producing any of the above. | |
| # * workflow_dispatch: manual smoke test. | |
| # | |
| # Strategy: scan open PRs targeting main and pick the oldest eligible PR with | |
| # mergeStateStatus=BEHIND, then call updateBranch on it. A PR is eligible only | |
| # if it would actually merge once CI passes — auto-merge enabled, not a draft, | |
| # not conflicting, reviewDecision=APPROVED, and zero unresolved review threads. | |
| # This avoids burning CI on PRs blocked on review. | |
| # | |
| # Emergency priority: a PR carrying the `emergency` label is bumped before | |
| # any non-emergency PR regardless of CREATED_AT ordering, AND its presence | |
| # in BEHIND bypasses the in-flight guard so a non-emergency PR's running | |
| # CI does not delay the bump. Non-emergency PRs continue to wait for the | |
| # queue head as usual. | |
| # | |
| # In-flight guard: if any eligible PR is already past the BEHIND state and | |
| # its required CI is still running (mergeStateStatus != BEHIND and | |
| # statusCheckRollup state is PENDING/EXPECTED), the run exits without | |
| # bumping anyone else. That PR is the queue head; bumping a different PR | |
| # while it is in flight would just preempt CI capacity for a PR that | |
| # would still need re-bumping after the head merges. PRs that are | |
| # BEHIND with PENDING checks do NOT count as in-flight — that CI is on | |
| # pre-update code and would need to re-run after updateBranch anyway. | |
| # | |
| # mergeStateStatus is computed asynchronously and is UNKNOWN for a window | |
| # after a base-branch push. If at least one eligible PR is UNKNOWN, retry | |
| # with backoff up to ~2min to let it settle. If everything is settled and | |
| # nothing is BEHIND, exit without retrying — there's no work. | |
| # | |
| # Token: needs AUTO_MERGE_TOKEN with contents:write + pull_requests:write so | |
| # the resulting push retriggers required CI on the PR. Falls back to | |
| # GITHUB_TOKEN, in which case auto-merge will not actually fire (GITHUB_TOKEN | |
| # pushes don't trigger downstream workflows). | |
| name: Auto Queue | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| types: [auto_merge_enabled, ready_for_review] | |
| pull_request_review: | |
| types: [submitted] | |
| workflow_run: | |
| workflows: [Required Checks] | |
| types: [completed] | |
| schedule: | |
| - cron: '*/5 * * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: autoqueue-${{ github.repository }} | |
| cancel-in-progress: false | |
| jobs: | |
| update-next-auto-merge-pr: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| // pull_request_review fires for any submitted review (Comment / | |
| // Approve / Request changes). Only Approve can newly satisfy the | |
| // reviewDecision=APPROVED gate, so other states are pure no-ops | |
| // worth short-circuiting before the GraphQL call. | |
| if ( | |
| context.eventName === 'pull_request_review' && | |
| context.payload.review?.state !== 'approved' | |
| ) { | |
| core.info( | |
| `Skip: pull_request_review state=` + | |
| `${context.payload.review?.state} (only "approved" can ` + | |
| `change queue eligibility).` | |
| ); | |
| return; | |
| } | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| // 0, 10, 20, 30, 30, 30 = 120s total wall-clock budget across | |
| // attempts. Short ramp catches the common case where | |
| // mergeStateStatus settles within ~30s of a base-branch push; | |
| // the tail keeps trying for the rare slow case. | |
| const BACKOFFS_MS = [0, 10000, 20000, 30000, 30000, 30000]; | |
| const query = ` | |
| query($owner:String!, $name:String!) { | |
| repository(owner:$owner, name:$name) { | |
| pullRequests( | |
| states: OPEN, | |
| baseRefName: "main", | |
| first: 100, | |
| orderBy: {field: CREATED_AT, direction: ASC} | |
| ) { | |
| nodes { | |
| number | |
| title | |
| isDraft | |
| mergeable | |
| mergeStateStatus | |
| reviewDecision | |
| autoMergeRequest { enabledAt } | |
| labels(first: 20) { | |
| nodes { name } | |
| } | |
| reviewThreads(first: 100) { | |
| nodes { isResolved } | |
| } | |
| commits(last: 1) { | |
| nodes { | |
| commit { | |
| statusCheckRollup { state } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }`; | |
| // Carrying the `emergency` label lifts a PR above all other | |
| // eligible PRs: it is bumped first regardless of CREATED_AT, and | |
| // its presence in BEHIND bypasses the in-flight guard so a | |
| // non-emergency PR's running CI does not block the bump. | |
| const EMERGENCY_LABEL = 'emergency'; | |
| function isEmergency(p) { | |
| return (p.labels?.nodes ?? []).some( | |
| (l) => l.name === EMERGENCY_LABEL, | |
| ); | |
| } | |
| function classify(p) { | |
| if (!p.autoMergeRequest) return 'skip: auto-merge not enabled'; | |
| if (p.isDraft) return 'skip: draft'; | |
| if (p.mergeable === 'CONFLICTING') return 'skip: mergeable=CONFLICTING'; | |
| if (p.reviewDecision !== 'APPROVED') { | |
| return `skip: reviewDecision=${p.reviewDecision || 'NONE'}`; | |
| } | |
| const threads = p.reviewThreads?.nodes ?? []; | |
| const unresolved = threads.filter((t) => !t.isResolved).length; | |
| if (unresolved > 0) { | |
| return `skip: ${unresolved} unresolved review thread(s)`; | |
| } | |
| const tag = isEmergency(p) ? ' [emergency]' : ''; | |
| return `eligible${tag}: mergeable=${p.mergeable} state=${p.mergeStateStatus}`; | |
| } | |
| const start = Date.now(); | |
| for (let attempt = 0; attempt < BACKOFFS_MS.length; attempt++) { | |
| if (BACKOFFS_MS[attempt] > 0) { | |
| const elapsedS = Math.round((Date.now() - start) / 1000); | |
| core.info( | |
| `Waiting ${BACKOFFS_MS[attempt] / 1000}s before attempt ` + | |
| `${attempt + 1}/${BACKOFFS_MS.length} (elapsed ${elapsedS}s).` | |
| ); | |
| await sleep(BACKOFFS_MS[attempt]); | |
| } | |
| core.startGroup(`Attempt ${attempt + 1}/${BACKOFFS_MS.length}`); | |
| let data; | |
| try { | |
| data = await github.graphql(query, { owner, name: repo }); | |
| } catch (e) { | |
| // Transient GitHub API failures (5xx, "terminated", etc.) | |
| // shouldn't kill the whole run — the backoff loop is exactly | |
| // the right place to absorb them. Try again next attempt. | |
| core.warning( | |
| `GraphQL query failed (status ${e.status ?? '?'}): ` + | |
| `${e.message}. Retrying after backoff.` | |
| ); | |
| core.endGroup(); | |
| continue; | |
| } | |
| const all = data.repository.pullRequests.nodes; | |
| core.info(`Scanned ${all.length} open PR(s) targeting main.`); | |
| const behind = []; | |
| const unknown = []; | |
| const inFlight = []; | |
| for (const p of all) { | |
| const verdict = classify(p); | |
| core.info(` #${p.number} ${verdict} — ${p.title}`); | |
| if (!verdict.startsWith('eligible')) continue; | |
| if (p.mergeStateStatus === 'BEHIND') { | |
| behind.push(p); | |
| continue; | |
| } | |
| if (p.mergeStateStatus === 'UNKNOWN') { | |
| unknown.push(p); | |
| continue; | |
| } | |
| // Eligible AND not BEHIND/UNKNOWN: this PR is ahead of any | |
| // BEHIND PR in the queue. Treat it as in-flight only if its | |
| // current required CI is actually working toward a merge. | |
| // PENDING/EXPECTED (CI still running on the with-main code) | |
| // means "wait for it"; SUCCESS (about to auto-merge) means | |
| // "wait for it"; FAILURE/ERROR (CI failed) is NOT in-flight | |
| // — auto-merge will not fire, queue can advance past it. | |
| const ciState = | |
| p.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state; | |
| if ( | |
| ciState === 'PENDING' || | |
| ciState === 'EXPECTED' || | |
| ciState === 'SUCCESS' | |
| ) { | |
| inFlight.push({ pr: p, ciState }); | |
| } | |
| } | |
| // Stable partition: emergency-labeled PRs go first; within | |
| // each priority class the GraphQL ASC-by-CREATED_AT order | |
| // is preserved. | |
| const emergencyBehind = behind.filter(isEmergency); | |
| const normalBehind = behind.filter((p) => !isEmergency(p)); | |
| const orderedBehind = [...emergencyBehind, ...normalBehind]; | |
| core.info( | |
| `Eligible: ${behind.length} BEHIND ` + | |
| `(${emergencyBehind.length} emergency), ` + | |
| `${unknown.length} UNKNOWN, ` + | |
| `${inFlight.length} in-flight (queue head still merging), ` + | |
| `rest blocked on failed CI or non-CI gates.` | |
| ); | |
| // Emergency BEHIND bypasses the in-flight guard: an emergency | |
| // is by definition something that should preempt CI capacity | |
| // on a non-emergency PR. Without an emergency, fall back to | |
| // the normal "wait for the queue head" behavior. | |
| if (inFlight.length > 0 && emergencyBehind.length === 0) { | |
| const head = inFlight[0]; | |
| core.info( | |
| `Skip: PR #${head.pr.number} is in flight ` + | |
| `(state=${head.pr.mergeStateStatus}, ci=${head.ciState}). ` + | |
| `Letting it finish to avoid preempting CI on a PR we may ` + | |
| `need to re-bump.` | |
| ); | |
| core.endGroup(); | |
| return; | |
| } | |
| if (inFlight.length > 0 && emergencyBehind.length > 0) { | |
| core.info( | |
| `${emergencyBehind.length} emergency PR(s) BEHIND — ` + | |
| `bypassing in-flight guard for #${inFlight[0].pr.number}.` | |
| ); | |
| } | |
| if (orderedBehind.length > 0) { | |
| let updated = null; | |
| for (const pr of orderedBehind) { | |
| const tag = isEmergency(pr) ? ' [emergency]' : ''; | |
| core.info(`→ updateBranch #${pr.number}${tag}`); | |
| try { | |
| const res = await github.rest.pulls.updateBranch({ | |
| owner, repo, pull_number: pr.number, | |
| }); | |
| core.info( | |
| `✓ #${pr.number} updateBranch dispatched (HTTP ${res.status}).` | |
| ); | |
| updated = pr.number; | |
| break; | |
| } catch (e) { | |
| core.warning( | |
| `✗ #${pr.number} updateBranch failed ` + | |
| `(status ${e.status ?? '?'}): ${e.message}` | |
| ); | |
| } | |
| } | |
| core.endGroup(); | |
| if (updated !== null) { | |
| core.info(`Done: #${updated} updated on attempt ${attempt + 1}.`); | |
| return; | |
| } | |
| core.info( | |
| 'All BEHIND PRs failed updateBranch this attempt; retrying after backoff.' | |
| ); | |
| continue; | |
| } | |
| if (unknown.length > 0) { | |
| core.info( | |
| `No BEHIND PRs yet; ${unknown.length} eligible PR(s) ` + | |
| 'still UNKNOWN — retrying after backoff to let GitHub settle.' | |
| ); | |
| core.endGroup(); | |
| continue; | |
| } | |
| core.info( | |
| 'No BEHIND or UNKNOWN eligible PRs — nothing to do this run.' | |
| ); | |
| core.endGroup(); | |
| return; | |
| } | |
| const totalS = Math.round((Date.now() - start) / 1000); | |
| core.info( | |
| `Exhausted ${BACKOFFS_MS.length} attempt(s) over ${totalS}s ` + | |
| `without finding a BEHIND PR to update.` | |
| ); |