automerge-gate: GitHubのAuto Mergeをひとつの必須チェックに集約するGitHub Action
GitHubのAuto Mergeをひとつの必須チェックに集約するためのGitHub Action automerge-gate を作ったので紹介します。
- GitHub: pkgdeps/automerge-gate
背景: GitHubの必須チェック設定はPRごとの揺らぎに弱い
前提として、GitHubのAuto Mergeを使うには、必須チェック未達成のPRをマージできない状態にするBranch protection ruleやRulesetの設定が必要です。 これらの保護機能でPRがブロックされる状態を作ったうえで、すべての必須チェックが成功した時点でAuto Mergeが発火する、という仕組みになっています。 逆に言うと、Auto Mergeを使うには何かしらのステータスチェックを必ず必須に入れる必要があります。
そして、Branch protection ruleやRulesetは、マージに必要なステータスチェックを名前で列挙する形式です。 この方式は次のような場面で壊れやすいという問題があります。
- RenovateやDependabotなど外部のGitHub Appが追加するチェックは、PRごとにあったりなかったりする
- monorepoでパスフィルタを使っていると、ワークフローがPRによってスキップされたりされなかったりする
- 新しいワークフローを追加する度に、Rulesetを書き換える必要がある
GitHubのRulesetは複数の必須チェックをANDでつなぐ(全部成功すること)しか表現できないため、「チェック群のうちどれかが走っていればよい」みたいな条件は書けません。 そのため、PRごとに発火するチェックが違うケースだと、片方のPRでは存在しないチェックを必須にしてしまい、いつまでもマージできないという状態が発生します。
この問題への対処として、必須チェックを1つのステータスに集約するupsidr/merge-gatekeeperを使っているケースも多いです。 自分もtextlintなどのオープンソースプロジェクトや、プライベートリポジトリで使っていました。
最近はmerge-gatekeeperからautomerge-gateに入れ替えて使っています。
automerge-gateも同じ「集約された1つの必須チェック」というアプローチですが、GitHubのAuto Mergeと組み合わせて使うことを前提に作られています。
必須チェックとして登録するのはautomerge-gate/all-passedの1つだけで、ワークフローやGitHub App由来のチェックをこのアクションが集約してくれます。
仕組み
automerge-gateには2つのモードがあります。
- Private mode — フォークPRを受け取らないリポジトリ向けのコスト最適化モード。マージ意図のないPRではアクション自体が早期returnして、ランナー時間を消費しない
- Public mode — フォークPRを受け取るリポジトリ向け。フォークPRでは
GITHUB_TOKENが読み取り専用になるため、ジョブ自身のcheck_runの終了コードがゲート信号になる
メインのユースケースはPrivate modeで、Public modeはオープンソースプロジェクトのようなフォークPR対応用です。
Private mode (メインのユースケース)
Private modeでは、アクションがREST APIで集約結果をcommit statusとして書き込みます。 重要なのは、PRが開かれただけでマージ意図がない状態(Auto Merge未有効 & write権限のApproveなし)では、アクション側はポーリングをせずに何も書き込まずにすぐに終了する点です。
このとき、必須チェックautomerge-gate/all-passedはGitHubのデフォルトのExpected — Waiting for status to be reportedのままです。
そのため、マージはブロックされた状態が維持されます。
メンテナがAuto Mergeを有効化したタイミング、またはwrite権限を持つレビュアーがApproveしたタイミングで、初めてアクションがポーリングを開始してチェックを集約しにいきます。
ジョブ自体は軽量で、PRのcheck_runを一定間隔(デフォルト30秒)でポーリングして集約結果を計算するだけです。
依存関係のビルドもなく、runs-on: ubuntu-latestの標準ランナーで十分動きます。
ポーリングしない場合のジョブは数秒で終わるので、Auto MergeがONになる前のPRではランナー時間をほぼ消費しません。
このスキップ動作によって、プライベートリポジトリでのコスト効率が改善できます。 GitHub Actionsの料金はジョブ単位の1分未満切り上げで、利用できるランナーごとに単価が異なります。 代表的なものを抜粋すると次のとおりです。
runs-on: |
スペック | 料金 |
|---|---|---|
ubuntu-latest |
Linux 2-core (x64) | $0.006 / 分 |
ubuntu-24.04-arm |
Linux 2-core (arm64) | $0.005 / 分 |
ubuntu-slim |
Linux 1-core (x64) | $0.002 / 分 |
merge-gatekeeperは同等の集約処理をしてくれますが、Auto MergeがONかどうかに関わらず、PRが開かれた時点から他のチェックが揃うまでポーリングを続ける設計です。
そのため、1回のポーリングはCI全体が完了するのにかかる時間(例: 10分)とほぼ同じだけ走り続けます。
さらにpushするたびに同じポーリングが起きるので、push数 × ポーリング時間の課金が発生します。
さらにmerge-gatekeeperは内部でDockerコマンドを使うため、Dockerが使えないubuntu-slimでは動きません。
そのため、$0.006/分のubuntu-latestを選ぶ必要がありました。
automerge-gateの場合、本体はNode.js製のActionでDockerに依存していないため、ubuntu-slim(1-core, $0.002/分)でも動かせます。
加えてPrivate modeでは、Auto Mergeが有効化されておらずwrite権限ありのApproveもないPRに対してはポーリング自体を開始しません。
ジョブはトリガーされるので1分切り上げの最低課金(ubuntu-slimなら$0.002)は発生しますが、CI完了までポーリングし続けることはなくなります。
ただし、merge-gatekeeperとは視覚的な違いがあります。
merge-gatekeeperの場合、常にポーリングしているので、すべてのチェックが揃えば集約チェックがグリーンになります。
一方automerge-gateのPrivate modeでは、Auto MergeまたはApproveまでautomerge-gate/all-passedはpendingのままです。
GitHubの表示上はExpected — Waiting for status to be reportedと出ます。
Commit statusは(SHA, context)の組をキーにしてGitHubが評価するので、新しいコミットがpushされても自動的に新しいSHAに対して再評価が走ります。Auto Mergeを一度有効にしたら、その後はpush毎に有効/無効を切り替える必要はありません。
設定方法
設定は次の5ステップです。
- モードを選ぶ(Private / Public)
.github/workflows/automerge-gate.yamlを追加- Ruleset(またはBranch protection)で
automerge-gate/all-passedを必須チェックに登録 - リポジトリ設定で「Allow auto-merge」を有効化
- PRで「Enable Auto Merge」をクリック
ワークフローファイル (Private mode)
.github/workflows/automerge-gate.yamlは次のような内容になります。
name: automerge-gate
on:
pull_request:
types: [opened, synchronize, reopened, auto_merge_enabled]
pull_request_review:
types: [submitted]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
if: >-
github.event_name != 'pull_request_review' ||
github.event.review.state == 'approved'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
statuses: write
checks: read
pull-requests: read
actions: read
steps:
- uses: pkgdeps/[email protected]
with:
gate-mode: 'private'
context: 'automerge-gate/all-passed'
ポイントは次のとおりです。
pull_request_reviewのif:でapprovedのみを通している。GitHubのon:はレビューのstateで絞り込めないので、ジョブのif:で弾いて空振りでもrunnerが立ち上がらないようにしているtimeout-minutes: 10がポーリングループの唯一のタイムアウト。アクション側に独立したtimeout-seconds入力はあえて用意されておらず、設定箇所を二重化しない方針になっている- 権限は
statuses: write(commit status書き込み)とchecks: read(チェックの集約読み取り)で十分
Approveをマージ意図として扱いたくないチームは、on:からpull_request_reviewを外せば、Auto Mergeを明示的に有効化したときだけポーリングが走るようになります。
必須チェックの登録
Settings → Rules → Rulesetsを開いて、automerge-gate/all-passedを必須チェックに追加します。
📝 必須チェックのドロップダウンは、過去にそのリポジトリで実行されたチェック名しかオートコンプリートしないので、初回設定時は候補に出てきません。手入力でautomerge-gate/all-passedと入れる必要があります。
Auto Mergeの有効化
Settings → General → Pull Requestsで「Allow auto-merge」をチェックします。これをしないとPRに「Enable Auto Merge」ボタンが出ないので、ステップ5が動きません。
除外パターンの指定
CodecovやNetlifyのプレビュー、Renovateなど特定のチェック/Appをゲートから外したい場合は、ignore-appsまたはignore-checksで除外できます。
- uses: pkgdeps/[email protected]
with:
gate-mode: 'private'
ignore-apps: |
dependabot
renovate
ignore-checksはglob(* / ?)が使えます。
- uses: pkgdeps/[email protected]
with:
gate-mode: 'private'
ignore-checks: |
optional-*
docs-only
ignore-checksがチェックするのはGitHub APIのcheck_run.name(=jobs.<key>.name)です。
GitHubのUIで見える<workflow> / <job>形式ではない点に注意してください。
実際にどの名前で記録されているかは、次のコマンドで確認できます。
gh api "repos/{owner}/{repo}/commits/{sha}/check-runs" \
--jq '.check_runs[] | {name, app: .app.slug, conclusion}'
Public modeについて
オープンソースプロジェクトのようにフォークPRを受け付けるリポジトリでは、フォークPRに対してGITHUB_TOKENが読み取り専用になります。
そのため、Private modeのようにcommit statusをPOSTする方法は使えません。
書き込みできないと「待機中(=ステータス未設定)」の状態も表現できないため、Private modeでやっている「マージ意図がなければスキップ」もそのままでは成り立ちません。
そこでPublic modeでは、ジョブ自身のcheck_run(GitHub Actionsが自動で作るもの)の終了コードをゲート信号として扱います。
ジョブのname:を必須チェックの名前(automerge-gate/all-passed)に揃えておくことで、ジョブの結果がそのまま必須チェックの結果になります。
この点はmerge-gatekeeperとほぼ同じ仕組みです。
代わりに「スキップで節約」はできなくなるため、Public modeでは全イベントで常にポーリングする形になります。
代替案としてpull_request_targetでGITHUB_TOKENに書き込み権限を持たせるアプローチもあります。
しかし、フォーク由来のコードを書き込み権限付きで動かすことになり、セキュリティ上の問題が大きいです。
そのため、この方式は採らずに「ジョブ自身の終了コードを信号にする」形に落ち着きました。
詳しい設計の背景は、architecture.mdにまとまっています。
ワークフローは次のようになります。
name: automerge-gate
on:
pull_request:
types: [opened, synchronize, reopened, auto_merge_enabled]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
name: automerge-gate/all-passed # ruleset側の必須チェック名と一致させる
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
checks: read
pull-requests: read
actions: read
steps:
- uses: pkgdeps/[email protected]
with:
gate-mode: 'public'
Private modeとの違いは次のとおりです。
- 権限は
checks: readのみでよい(commit statusを書き込まないため) - 「マージ意図がないPRはスキップ」というコスト最適化は行わない。常にトリガーごとにポーリングする(
GITHUB_TOKENが読み取り専用だと”待機中”の信号を書き込めないため) - ジョブの
name:が必須チェック名そのものになる
実際のPublic modeでの実行例として、secretlint/secretlint#1557のログを見てみます。
ubuntu-24.04の標準ランナー上で、17個のチェックを集約している様子です。
##[group][00:05] Poll #1 — pending, 14/15 completed
🟡 Agent (in_progress)
✅ Analyze (javascript-typescript) (success)
✅ Analyze (javascript) (success)
✅ binary-test (success)
✅ CodeQL (success)
...
##[group][00:38] Poll #2 — success, 17/17 completed
✅ Agent (success)
...
✅ Passed (17):
CodeQL、hadolint、secretlint、各OS/Node.jsのテストなど複数ワークフロー由来のチェックが、automerge-gate/all-passedの1つに集約されています。
ジョブ自体はチェック結果を読んで待つだけなので、約38秒で集約完了しています。
オープンソースプロジェクトのようにフォークPRを受け付ける環境でなければ、Private modeを使うのが基本になります。
merge-gatekeeperとの細かな違い: GitHub Actionsが作ったPRのデッドロック
GitHub ActionsがPRを作る場合、secrets.GITHUB_TOKENで作成されたPRに対しては、無限ループ防止のために他のGitHub Actionsワークフローが発火しません。
このとき、ゲート用のワークフローも発火しないため、必須チェックがいつまでも報告されません。 merge-gatekeeperの場合は、PRイベントでしか動かないため、このPRはマージできないままデッドロックします。
automerge-gateはPrivate/Publicどちらのモードでも、pull_requestのauto_merge_enabledイベントをトリガーに含めています。
さらにPrivate modeではApprove(pull_request_reviewのsubmitted)もトリガーに含まれます。
そのため、手動でAuto Mergeを有効化するかApproveすれば、人手起点でゲートを動かせます。
完全な自動化はできませんが、デッドロック状態からは一応抜け出せる構造になっています。
制限事項
automerge-gateには次の制限があります。
- Merge Queue非対応 — GitHubの
merge_groupイベントには非対応 - ジョブのタイムアウト —
timeout-minutesに達するとジョブがfailure/cancelledで終わり、必須チェックは赤のまま残る。GitHub Actionsの「Re-run failed jobs」でリトライするか、Auto Mergeを一度無効化して有効化し直す - Legacy commit status APIのみのCI — AtlantisやJenkinsの一部のような、legacy commit status APIだけを使うCIは集約対象にならない
- 該当するCIはRulesetに直接必須チェックとして追加し、
automerge-gate/all-passedと並列に置く必要がある
- 該当するCIはRulesetに直接必須チェックとして追加し、
バージョニング
automerge-gateのリリースは、v4.0.0のような不変のSemVerタグで公開されます。
v4のように移動するメジャータグは意図的に作っていないので、ワークフロー側では固定バージョンを指定して、RenovateやDependabotで更新するスタイルが推奨されています。これは、移動するタグが書き換えられるサプライチェーンリスクを避けるための設計です。
まとめ
automerge-gateは、GitHubのAuto Mergeとあわせて使う集約チェックのGitHub Actionです。
- Rulesetに登録する必須チェックは
automerge-gate/all-passedの1つだけで済む - Renovate/DependabotやmonorepoのパスフィルタによってPRごとにチェックが増減しても、自動的に集約される
- Private modeでは、マージ意図のないPRではポーリングをスキップするためrunner時間をほぼ消費しない
- フォークPRを受け取るオープンソースプロジェクトなどはPublic modeで対応できる
merge-gatekeeperと似たコンセプトですが、Auto Merge前提でコストを最適化している点が違います。
また、Private/Publicの2モードに分けてGITHUB_TOKENの権限差に対応している点も異なります。
参考
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。