はじめに
依存パッケージの更新を放置しないために、Dependabot の PR を自動マージするようにしました。
自動マージそのものは数行で書けます。ただ、「マージ → main で CI → 本番デプロイ」までつなげると、エラーも出さずに静かに壊れる箇所がいくつもありました。
この記事は、GitHub Actions と Dependabot を使っている個人開発者・小規模チーム向けです。
そのまま持ち込めるコードに加えて、「なぜそう書くのか」を1つずつ説明します。
TL;DR
-
GITHUB_TOKENでマージすると、main への push で CI が起動しない。E2E もデプロイも走らず、エラーも出ない。マージには fine-grained PAT を使う -
トリガーは
pull_requestではなくworkflow_run(CI 完了)にする。Dependabot が起動したワークフローには読み取り専用トークンしか渡らず、Secrets も渡らない - PAT を持つワークフローでは、PR のコードを checkout も実行もしない。依存のインストールスクリプトに書き込み権限付きのトークンを晒さないため
- Dependabot の PR では Actions Secrets が渡らず、E2E が必ず落ちる。PR 段階では E2E をスキップし、main への push では必ず回してデプロイのゲートにする
- PAT の期限切れ・空のシークレット・放置 PR は「静かに」壊れる。疎通確認ジョブと放置 PR 通知で検知する
前提
- GitHub Actions で CI(lint / 型チェック / テスト / ビルド / E2E)を回している
- main への push で CI が全部通ったときだけ、CI の
deployジョブが本番デプロイする- ホスティング側の Git 連携による自動デプロイは止めておく(CI を通らずにデプロイされてしまうため)
- 例は npm ですが、言語やフレームワークは問いません
全体像
目的は「常に最新であること」ではありません。
まとめて上げる羽目にならないよう小さく継続的に追従し、1回あたりの移行コストとリスクを下げることです。
登場するファイルは3つ + CI です。
| ファイル | 役割 |
|---|---|
.github/dependabot.yml |
更新のグループ化と ignore |
.github/workflows/dependabot-auto-merge.yml |
CI 完了後の自動マージ + PAT の疎通確認 |
.github/workflows/dependabot-review-notify.yml |
人間の対応が必要な PR の通知 |
.github/workflows/ci.yml |
既存の CI(E2E の条件とデプロイのゲートだけ触る) |
1. Dependabot の設定:グループ化と ignore の基準
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Asia/Tokyo
open-pull-requests-limit: 5
groups:
# minor / patch は1本のPRにまとめる。メジャーだけ個別PR。
# グループ名はブランチ名 dependabot/npm_and_yarn/minor-and-patch-<hash> に現れ、
# 自動マージ判定に使われる。名前を変えるときはワークフロー側も直すこと。
minor-and-patch:
patterns: ["*"]
update-types: [minor, patch]
ignore:
# 型定義は実行環境の Node メジャーと揃える。Node 本体を上げるときに手動で。
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# 例: 上流が未対応で CI が落ちるもの。対応版が出たら外す(外す条件を必ず書く)。
# - dependency-name: "eslint"
# update-types: ["version-update:semver-major"]
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Asia/Tokyo
open-pull-requests-limit: 3
groups:
# npm 側と同じグループ名にする(ワークフローがブランチ名で判定するため)
minor-and-patch:
patterns: ["*"]
update-types: [minor, patch]
ブランチ名を「自動マージしてよい」ことの証明にする
グループの update-types を minor / patch に限定すると、グループ名がブランチ名に入ります。
dependabot/npm_and_yarn/minor-and-patch-<hash>
つまり、ブランチ名がそのまま「メジャー更新を含まない」ことの証明になるので、ワークフロー側はブランチ名だけで判定できます。
注意点は2つです。
- エコシステムが複数あるときはグループ名を揃える
- グループ名を変えるときはワークフローの判定条件も直す。忘れないよう、両方のファイルにコメントで相互参照を書いておく
なお、ブランチ名は手で真似できるので、ワークフロー側では「作成者が Dependabot であること」も確認します(後述)。
メジャー更新を ignore する基準
ignore するのは次の2種類に絞っています。
① 「新しいほど良い」とは限らない依存
代表例が @types/node のような実行環境の型定義です。本番の Node より新しいメジャーを入れると、実行環境に存在しない API が型の上では OK になります。
結果、型チェックは緑なのに本番で落ちるという最悪のパターンになります。実行環境を上げるときに手動で揃えます。
② 上流が未対応で、上げると CI が必ず落ちるもの
リンターや TypeScript のメジャー更新で、プラグインや周辺ツールが追いついていないケースです。
ここで学んだのは、peer 宣言や npm install --dry-run が通っても互換性の証明にはならないということです。実際に上げて CI で確かめるしかありません。
そして ignore には**「いつ外すか」の条件を必ずコメントで書きます**。書かないと永久に放置されます。
2. 自動マージのワークフロー
name: Dependabot auto-merge
# pull_request ではなく workflow_run: Dependabot 起動の実行は読み取り専用トークンのため。
# PR のコードは checkout しない: PAT を依存のインストールスクリプトに晒さないため。
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches:
- "dependabot/*/minor-and-patch-*"
workflow_dispatch: # token-check 用
permissions:
contents: read # マージは PAT で行うので既定トークンは読み取りのみ
jobs:
auto-merge:
runs-on: ubuntu-latest
if: >-
${{ github.event_name == 'workflow_run'
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
&& startsWith(github.event.workflow_run.head_branch, 'dependabot/')
&& contains(github.event.workflow_run.head_branch, '/minor-and-patch-') }}
steps:
- name: CI が通った Dependabot PR をマージ
env:
# GITHUB_TOKEN でマージすると main への push で CI が起動しない → PAT を使う
GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::AUTOMERGE_TOKEN が未設定です。"
exit 1
fi
# 作成者が Dependabot であることも確認(ブランチ名の偽装対策)
num="$(gh pr list --repo "$REPO" --head "$BRANCH" --state open \
--json number,author \
--jq '[.[] | select(.author.login == "app/dependabot" or .author.login == "dependabot[bot]") | .number] | first // empty')"
if [ -z "$num" ]; then
echo "対象の open な Dependabot PR がありません。"
exit 0
fi
gh pr merge "$num" --repo "$REPO" --squash --delete-branch
token-check:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
steps:
- name: AUTOMERGE_TOKEN の疎通確認(マージはしない)
env:
GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
[ -n "${GH_TOKEN:-}" ] || { echo "::error::AUTOMERGE_TOKEN が空です(--body \"\$(...)\" の失敗など)"; exit 1; }
gh api "repos/$REPO" --jq '.full_name' > /dev/null 2>&1 \
|| { echo "::error::リポジトリを読めません(期限切れ・Repository access 外)"; exit 1; }
gh pr list --repo "$REPO" --state open --limit 1 > /dev/null 2>&1 \
|| { echo "::error::Pull requests を読めません"; exit 1; }
push_perm="$(gh api "repos/$REPO" --jq '.permissions.push // "unknown"')"
[ "$push_perm" = "true" ] && echo "OK: 書き込み権限あり" \
|| echo "::warning::書き込み権限を確認できません (permissions.push=$push_perm)"
このファイルには、ハマりどころが3つ詰まっています。
罠①:GITHUB_TOKEN でマージすると、その後の CI もデプロイも起動しない
これが一番の罠です。
GitHub には、GITHUB_TOKEN で起こしたイベントからは新しいワークフローを起動しないという仕様があります(workflow_dispatch と repository_dispatch を除く)。ワークフローが無限に連鎖するのを防ぐためです。
そのため GITHUB_TOKEN でマージすると、こうなります。
- 自動マージは成功する
- main に push される
- でも CI が起動しない → E2E もデプロイも走らない
「マージされたのに、検証もデプロイもされていない」状態になり、エラーも出ません。 Actions の画面を見に行くまで気づけません。
対策は、fine-grained PAT(Personal Access Token)を作って Secrets に登録し、マージに使うことです。PAT でのマージは通常の push として扱われるので、CI がちゃんと起動します。
PAT の権限は最小限にします。
| 項目 | 設定 |
|---|---|
| Repository access | 対象リポジトリのみ |
| Contents | Read and write |
| Pull requests | Read and write |
罠②:トリガーを pull_request にすると、マージ API を呼べない
素直に書くと on: pull_request で Dependabot の PR を拾いたくなります。しかし、Dependabot が起動したワークフローには読み取り専用の GITHUB_TOKEN しか渡らず、Actions の Secrets も渡りません。これではマージできません。
一方、CI の完了イベント(workflow_run)から起動したワークフローは、元の実行が Dependabot 由来でも通常の権限と Secrets で動きます。なので「CI が完了した」タイミングでマージ判定をします。
workflow_run.branches の絞り込みも入れておくのがおすすめです。
branches:
- "dependabot/*/minor-and-patch-*"
-
*は/に一致しないので、dependabot/npm_and_yarn/minor-and-patch-xxxの形だけにマッチします - 絞らないと、main への push を含むすべての CI 完了で起動し、
ifで弾かれたskippedの実行が Actions 一覧に毎回並んでうるさくなります -
if条件は多層防御としてそのまま残しておきます
罠③:PAT を持つワークフローで PR のコードを動かしてはいけない
このワークフローは actions/checkout をしていません。意図的です。
更新後の依存をインストールすると、postinstall などのスクリプトが動きます。そこに書き込み権限付きの PAT が見える状態だと、サプライチェーン攻撃の格好の的になります。
そこで役割をきっちり分けます。
| 役割 | Secrets | やること |
|---|---|---|
| CI | 持たない | 依存を実際にインストールして検証する |
| auto-merge | PAT を持つ | GitHub API を叩くだけ(コードは触らない) |
「CI の最後にマージのジョブを足せば1ファイルで済むのでは?」とも考えましたが、やめました。
- Dependabot 起動の CI に PAT を渡すには、PAT を Dependabot secrets にも二重登録する必要がある → 期限更新の管理箇所が増える
- PAT と依存コードの実行が同じワークフローに来て、隔離の境界が曖昧になる
得られるのは見た目の整理だけなので、割に合いません。
GitHub 標準の auto-merge(gh pr merge --auto)は、ブランチ保護で必須チェックを設定していることが前提です。無料プランのプライベートリポジトリなど、ブランチ保護を使えない環境では、このように自作する意味があります。
3. Dependabot の PR と Secrets・E2E の扱い
罠④:Dependabot の PR では E2E が必ず落ちる
罠②と同じ理由で、Dependabot が起動した実行には Actions の Secrets が渡りません(渡るのは別枠の「Dependabot secrets」だけです)。
E2E が外部サービスの接続情報を Secrets から読んでいる場合、値が空になって必ず失敗します。
ここでは、Dependabot の PR では E2E をスキップすると判断しました。
毎週必ず赤い CI が出ると、CI そのものが信用されなくなるからです。
ただし、デプロイ前のゲートは緩めません。
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
lint-build: # lint → 型チェック → テスト → ビルド(Secrets 不要)
...
e2e:
# Dependabot 発の PR とフォークの PR は除外(Secrets が渡らず必ず落ちるため)。
# push イベントでは実行者に関わらず常に実行=デプロイ前のゲートは維持。
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }}
...
deploy:
needs: [lint-build, e2e]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
...
ポイントは次のとおりです。
- push イベントでは実行者に関係なく E2E を必ず回す(PAT でのマージ後の push はここに来る)
-
deployはneedsで E2E の通過を条件にする - E2E が落ちればデプロイはスキップされ、本番は前回の正常版のまま残る。main を revert するだけで復旧できる
つまり「PR 段階の E2E」は省略しても、「本番に出る前の E2E」は省略していません。
代替案として、Dependabot secrets に同じ値を登録して除外条件を外す方法もあります。二重管理のコストと引き換えに、PR の段階で E2E まで回せます。
罠⑤:0.x 系パッケージの minor 更新は破壊的変更を含みうる
semver 上、0.12 → 0.13 は minor 扱いです。しかし 0.x は安定性の保証がないため、実質的な破壊的変更が入ることがあります。そして自動マージの対象に入ってしまいます。
防波堤になるのは、CI(型チェック・テスト・ビルド)とマージ後の E2E です。上の構成なら落ちてもデプロイされないので、失敗は安全側に倒れます。
心配なパッケージがあれば、そのパッケージだけグループから外して個別 PR にする手もあります。
4. 静かに壊れる箇所への対策
自動化は「動いているうちは見なくなる」ので、止まったことに気づけないのが一番怖いところです。ここでは3つの「静かな故障」に備えます。
PAT の期限切れ → token-check で疎通確認
fine-grained PAT には有効期限があります。切れても、PR が open のまま残るだけで表面上は何も起きません。
そこで、auto-merge ワークフローに手動実行(workflow_dispatch)用の token-check ジョブを入れています(前掲のコード参照)。確認する順番は次のとおりで、マージは一切しません。
- トークンが空でないか
- リポジトリを読めるか(期限切れ・Repository access 外ならここで落ちる)
- PR を読めるか
- 書き込み権限があるか
PAT を更新したら、Actions 画面からこのジョブを手動実行して緑になることを確認します。
シークレットが空文字で登録される → コマンド置換を使わない
gh secret set でこう書くと危険です。
gh secret set AUTOMERGE_TOKEN --body "$(some-command)"
コマンド置換が失敗しても、空文字のまま登録に成功してしまいます。エラーにならないので気づけません。
対策は次のいずれかです。
- ブラウザの Settings → Secrets and variables → Actions から登録する
- クリップボード経由にする(macOS なら
--body "$(pbpaste)")
いずれにしても、トークン文字列をシェルのコマンドラインに直接置かないようにします(シェルの履歴に残るため)。
放置 PR → スキャン前日に GitHub 自身の通知で知らせる
次の2種類の PR を、Dependabot のスキャン前日に洗い出します。
- メジャー更新の PR(そもそも自動マージしない)
- 72時間以上 open のままの minor-and-patch PR(自動マージが機能していない疑い)
name: Dependabot 要レビューPR通知
on:
schedule:
- cron: "0 1 * * 0" # 毎週日曜 10:00 JST(Dependabot のスキャン前日)
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
notify:
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }} # マージしないので GITHUB_TOKEN で足りる
REPO: ${{ github.repository }}
OWNER: your-github-id
run: |
set -euo pipefail
prs="$(gh pr list --repo "$REPO" --state open \
--json number,title,headRefName,createdAt,author \
--jq '[.[] | select(.author.login == "app/dependabot" or .author.login == "dependabot[bot]")]')"
count="$(echo "$prs" | jq 'length')"
[ "$count" -eq 0 ] && exit 0
now_epoch="$(date -u +%s)"
for i in $(seq 0 $((count - 1))); do
number="$(echo "$prs" | jq -r ".[$i].number")"
branch="$(echo "$prs" | jq -r ".[$i].headRefName")"
created_epoch="$(date -u -d "$(echo "$prs" | jq -r ".[$i].createdAt")" +%s)"
age_hours=$(( (now_epoch - created_epoch) / 3600 ))
reason=""
if [[ "$branch" != dependabot/*/minor-and-patch-* ]]; then
reason="自動マージ対象外(メジャー更新など)"
elif [ "$age_hours" -ge 72 ]; then
reason="自動マージ対象のはずが ${age_hours} 時間 open のまま"
fi
if [ -n "$reason" ]; then
gh pr edit "$number" --repo "$REPO" --add-assignee "$OWNER" || true
gh pr edit "$number" --repo "$REPO" --add-reviewer "$OWNER" || true
gh pr comment "$number" --repo "$REPO" \
--body "🔔 @$OWNER このPRは自動マージされません(理由: ${reason})。"
fi
done
設計上のこだわりは3つです。
- 外部サービス(Slack など)は使わない。assign・レビュー依頼・メンションで、GitHub 自身の通知に載せる
- 該当が無ければ何もしない。空振りの通知を送るとノイズになり、そのうち見なくなる
- こちらはマージも push もしないので
GITHUB_TOKENで足りる。schedule起動は Dependabot 由来ではないので、書き込み権限も普通に使える
5. 導入手順
-
.github/dependabot.ymlを置く - fine-grained PAT を発行する(対象リポジトリのみ・Contents RW / Pull requests RW)
- ブラウザの Settings → Secrets and variables → Actions に
AUTOMERGE_TOKENとして登録する - 2つのワークフローを置き、CI の E2E / deploy の条件を調整する
- Actions 画面から「Dependabot auto-merge」を手動実行し、
token-checkが緑になることを確認する - 急ぐときは Insights → Dependency graph → Dependabot の「Check for updates」で即時スキャンする(設定の不備もここに表示される)
プレースホルダは次の3つです。
| プレースホルダ | 中身 |
|---|---|
your-github-id |
通知先の GitHub ID |
AUTOMERGE_TOKEN |
PAT を登録する Secret 名 |
CI |
CI ワークフローの name
|
まとめ
- 自動マージ自体は簡単。難しいのは「マージ後に CI とデプロイがちゃんと動くこと」
-
GITHUB_TOKENでマージすると後続のワークフローが起動しない → PAT でマージする - Dependabot 起動の実行は読み取り専用・Secrets なし →
workflow_runでマージ判定する - PAT を持つワークフローではコードを checkout も実行もしない。検証は Secrets を持たない CI に任せる
- Dependabot の PR では E2E をスキップしても、main への push では必ず E2E を回してデプロイのゲートにする
- PAT の期限切れ・空のシークレット・放置 PR は静かに壊れる → 疎通確認ジョブと放置 PR 通知で検知する
「自動化したから安心」ではなく、「自動化が止まったときに気づける」ところまで作って、ようやく放置しない仕組みになると感じました。
参考
- GITHUB_TOKEN(When GITHUB_TOKEN triggers workflow runs)- GitHub Docs
- Events that trigger workflows(workflow_run)- GitHub Docs
- Workflow syntax(Filter pattern cheat sheet)- GitHub Docs
- Automating Dependabot with GitHub Actions - GitHub Docs
- Dependabot options reference(groups / ignore)- GitHub Docs
- Managing your personal access tokens - GitHub Docs