跳到正文
DEV Community· Daniel Schroeder·· 3 小時前AI 評分64

boarding-gate 如何為 GitHub Actions 的 PR 與 main 工作流安排優先次序

Orchestrating Renovate, Dependabot, release PRs and tests on main: a priority queue for GitHub Actions

AI 導讀

Daniel Schroeder 介紹自己撰寫的 GitHub Action boarding-gate,按優先級協調開發者 PR、Renovate 和 Dependabot PR、發佈 PR,以及 main 上的工作流,並讓同一工作流一次只執行一項。

正文

A modern repository has four or more parties writing to main, and none of them talks to the others. Developers open pull requests. Renovate or Dependabot open more. A release tool opens one on top. And a workflow runs on main after every merge. Each of them triggers CI, each of them is affected by every merge, and GitHub Actions runs them in whatever order they happen to arrive.

If CI is cheap and stateless, that's fine. If CI deploys something real, a cloud stack, a staging environment, a database, a Terraform state, it is not fine. Then "whatever order they arrive" means wasted runs at best and two deployments colliding at worst.

I maintain a few projects where this got out of hand, and I ended up writing a GitHub Action for it: boarding-gate. This article is about the coordination problem it solves, and where I think the same problem lives in your repository.

Four actors, four agendas

Developers. Their PRs are the reason the repository exists. They are also the only ones who get impatient when a run is slow.

Renovate and Dependabot. Open several PRs at once, each with auto-merge. By default they rebase every open PR as soon as main moves: Renovate with rebaseWhen: auto, Dependabot unless rebase-strategy: disabled. Every rebase is a new commit, every new commit is a new CI run. They don't care whose turn it is.

The release PR. release-please, changesets ("Version Packages"), python-semantic-release and friends open a PR that bumps the version and the changelog. It wants to be merged last, because it describes everything merged before it. It is updated the instant main changes, faster than any other PR, so it is reliably the first branch that is up to date and the first run in line.

The pipeline on main. Whatever runs on push: main: the real deployment, the integration test, publishing. It might use the same shared resource as the PR runs, and after every merge it starts immediately.

The mismatch: the actor that should go last is the fastest, the ones that should go first are the slowest to react, and the bots in the middle produce the most runs.

The loop

Add branch protection with "Require branches to be up to date before merging" (you should; without it, two individually green PRs can break main together) and the four actors form a loop:

  1. A PR merges.
  2. The pipeline on main starts.
  3. The release PR is rebuilt and force-pushed. New run.
  4. Renovate and Dependabot rebase all their open PRs. One new run each.
  5. Your other PRs are now behind. You press "Update branch". New run each.
  6. The first of these runs to finish gets merged. Go to 1.

One merge, four reactions, a new run for every open PR

Every merge costs one run per remaining open PR, so merging n PRs costs on the order of n²/2 runs. That's the arithmetic in the Dependabot issue tracker, and it matches what I see. With a 20-minute pipeline and six open PRs, a day's work of merging is a day of waiting.

The order inside the loop is the real damage. The release PR is rebuilt first, so it wins the next slot and gets tested. Then a bot PR merges, the release PR is behind again, and its run was for nothing. I have watched a release PR go through CI five times in a day and never merge. Meanwhile the fix I actually wanted in was queued behind a linter bump.

Why GitHub doesn't coordinate this for you

concurrency groups serialize runs, and since May 2026 queue: max keeps up to 100 of them pending instead of cancelling all but one. But the queue is first come, first served, and it is blind: it will happily run a branch that is already behind main, and it has no idea that the run it just let through is about to be auto-merged and invalidate everyone behind it.

GitHub's merge queue is the proper answer to the rebase loop. It tests PRs stacked on each other and merges them itself. Two catches: it exists only for repositories owned by organizations, so nothing on a personal account gets one, and it orders by arrival, not by what the PR is. It also does nothing about the pipeline on main or the deploy that must not overlap.

The lock actions (softprops/turnstyle, ahmadnassri/action-workflow-queue, ben-z/gh-action-mutex, github/lock) solve "one at a time" and stop there. The hosted merge queues (Mergify, Trunk, Aviator, Graphite, Kodiak) do have priorities, but they order merges rather than workflow runs, and they are built and priced for organizations.

None of these let me say: humans first, bots next, release last, main after everyone, one at a time, and never test a branch that can't be merged.

The rule

Written down, the policy is short:

  • One run of the workflow at a time. Nothing else touches the stack.
  • Order by what the PR is, not when it arrived: developers, then dependency bots, then the release PR, then main.
  • An urgent PR can jump the line, including while it is already waiting.
  • Never spend a run on a branch that is behind main. Rebase it, or fail it, but don't test it.
  • When the run at the front succeeds and its PR will auto-merge, wait for the merge before letting the next one through. Otherwise the next one is stale the moment it starts.
  • After a merge, don't let the release PR win just because it rebased fastest. If a higher-priority PR is about to be tested, hold the door.

That last rule is the one that took the most iterations to get right, and the one no generic lock can express.

boarding-gate

boarding-gate is a composite action that enforces exactly that rule for one workflow. It goes into a first job; every other job needs it.

Priorities are label weights. A run's weight is the sum of its PR's label weights, highest first, ties by start time. Runs without a PR (the push to main) get no-pull-request-weight. Labels are re-read while a run waits, so adding urgent moves it.

Same six runs: arrival order vs. order by label weight

jobs:
  boarding-gate:
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: write
      pull-requests: read
      statuses: write
    concurrency:
      group: ${{ github.workflow }}-wait-${{ github.event.pull_request.number || github.ref }}
      cancel-in-progress: true
    steps:
      - uses: udondan/boarding-gate@v1
        with:
          require-up-to-date: true
          update-pending-branches: true
          update-token: ${{ secrets.UPDATE_TOKEN }}
          status-context: boarding-gate
          no-pull-request-weight: -100
          label-weights: |
            urgent=10
            renovate=-5
            dependencies=-5
            autorelease: pending=-10

  deploy:
    needs: boarding-gate
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    steps:
      - if: needs.boarding-gate.result != 'success'
        run: exit 1
      # the part that touches the shared resource

What the inputs map to:

  • require-up-to-date fails a waiting run as soon as its branch is behind. With update-token (a PAT or App token; github.token pushes don't trigger workflows) it rebases the branch instead, and the new commit queues fresh.
  • Auto-merge is detected: the turn stays held until the holder's PR is merged or auto-merge gives up (merge-wait-minutes).
  • update-pending-branches is the "hold the door" rule: after a merge, higher-weight PRs with auto-merge that are behind get rebased by the waiting run and go first (yield-to-pending-minutes).
  • status-context writes a commit status with the live position, because composite-action logs don't stream.
  • The concurrency group on the wait job cancels a run the moment its PR gets a newer commit. It sits on the wait job only, so a job already using the resource is never cancelled mid-deploy.

boarding-gate commit statuses: took the turn, position in the queue, waiting for a merge, letting a higher-weight PR go first, branch behind, outdated commit

Under the hood the lock is a git ref: refs/boarding-gate/<workflow>/<n>. Creating a ref that exists fails atomically, so there is no race and no external service. Forks and Dependabot PRs work; the README covers the workflow_run companion for forks.

A reusable workflow with these defaults exists if you'd rather not copy the block into every repository.

Where that leaves the merge queue: it removes the cost of the rebase loop, so if you have one, let it handle up-to-date branches and auto-merge, and you won't need require-up-to-date or the pending-branch handling. It still merges in arrival order and still runs the pipeline on main whenever it likes. The ordering, the priorities and the one-at-a-time on main are what boarding-gate adds on top, with or without a merge queue.

Same rule, other stacks

My shared resource is an AWS CDK test stack. Swap it out and the four actors are still there:

  • Infrastructure as code. Terraform, OpenTofu, Pulumi, CloudFormation. "Error acquiring the state lock" in CI is this problem wearing a different error message. -lock-timeout makes the second run wait; not starting it until the branch is current is cheaper.
  • A single staging environment. The deploy must not overlap, and the hotfix must go before the dependency bumps. That's urgent=10.
  • Publishing. npm, PyPI, crates.io, Maven Central, GitHub Pages. Whether the release comes from a PR (release-please, changesets) or straight from main (semantic-release, goreleaser, release-it, cargo-release, lerna, nx release, projen), the publishing run and the PR runs share the registry and the version sequence.
  • Database migrations and E2E environments. Two migration runs against one database is the same collision as two deploys against one stack.
  • Scarce runners. Let build jobs wait at the gate as well. Runs whose branch is already stale never take a runner.
  • Agents. Letting coding agents open PRs in parallel produces a bot storm indistinguishable from Renovate's. The same ordering applies, with a label per agent if you want them behind humans.

boarding-gate queues the runs of one workflow. That's the whole scope. It isn't a merge queue, and it won't predict a merge you click by hand.

Links

Apache-2.0. Bash, bats tests against a fake GitHub API, end-to-end tests in a real repository with bursts of parallel PRs, Renovate auto-merges and relabelling while waiting. Issues and PRs welcome, especially from setups with a shared resource I haven't thought of.

來源:DEV Community · dev.to