タグ
白いカードに青いロボットの頭部と、その下に小文字のdependabotロゴタイプ。頭部は白いバイザーに丸い目が二つ、短いアンテナと四角い耳を持つ

Dependabotで最小リリース経過日数と自動マージを設定する — プライベートリポジトリに潜む罠

プライベートリポジトリでは依存関係グラフが既定でオフのため、DependabotはnpmのPRを1件も出さず、自動マージのワークフローはCI完了の45秒前にマージしていた。

目次

はじめに

このブログのリポジトリでプルリクエストの一覧を眺めていて、あるべきものがないことに気づきました。依存関係の更新が 1 件もないのです。古いものも、失敗しているものも、そもそもありません。数週間前に最小リリース経過日数と自動マージのワークフローまで含めた Dependabot のポリシーを書いていたのに、それが実際に何かをした形跡をひとつも挙げられませんでした。

2 つの仕組みが動いていないことが分かり、そのどちらも健全に見える何かに隠されていました。npm のエコシステムは一度も動いていませんでした。プライベートリポジトリでは依存関係グラフが既定でオフで、Dependabot はマニフェストの解析にそれを必要とするからです。自動マージのワークフローは何も待っていませんでしたgh pr merge --auto が待つのは 必須 チェックであり、このリポジトリのプランではどのチェックも必須にできないからです。

本記事では、その両方と、その下にあるポリシーの問いを整理します。最小リリース経過日数が実際に濾し取るもの、濾し取れないもの、そしてその 2 つの答えが揃うことで、なぜ無謀にならずに semver ゲートを省けるのかという話です。

すでに持っていたポリシー

cooldown のブロックは私が書いたもので、今回の一件より前からありました。リリースが公開されてから一定日数が経つまで、提案される更新を遅らせる設定です。日数はバージョンの跳び幅に応じて変えています。

.github/dependabot.yml
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-major-days: 14
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 10
ignore:
# TypeScript 7 (the native compiler) does not expose the programmatic API
# that `astro check` relies on, so upgrading breaks `pnpm check`.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]

この設定について、はっきり書いておくべきことが 2 つあります。どちらも取り違えやすい点です。

cooldown はセキュリティ更新には適用されません。 これは GitHub 側の意図的な設計です。現に出ている脆弱性への修正が 1 週間もキューで待たされるべきではありません。同時にそれは、上のブロックがセキュリティの経路を一度も守っていなかったということでもあり、当時の私が思っていたよりも重い意味を持っていました。

その下には github-actions のブロックがあり、そちらは default-days: 3 だけです。 このエコシステムでは semver-*-days のキーは無視されるので、そこに書けばポリシーのように見えて何もしないものになっていたはずです。

npm の PR は 1 件も作られていなかった

設定は正しく見えたので、代わりに証拠から手をつけました。

ターミナルウィンドウ
gh pr list --author "app/dependabot" --state all --limit 20
39 build(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 MERGED 2026-08-10
2 build(deps): bump pnpm/action-setup from 6 to 6.0.9 MERGED 2026-08-03

PR は 2 件、どちらもマージ済みで、どちらも github-actions のエコシステムからのものです。npm からのものは 1 件もありません。そして pnpm outdated は仕事があると言っていました。astro は 7.1.6 で、7.2.2 が出ています。

ここでは、緑色の依存更新 PR が 2 件あることが最悪の証拠です。 自分が問いたいと思っている問いに、それが答えを返してしまうからです。Dependabot は確かに動いていました。PR を出し、それがマージされ、ラベルも正しい。目に見えるものはすべて、この仕組みは動いていると言っていました。

依存関係グラフがオフで、片方のエコシステムがそれを隠していた

一緒に作業していたエージェントは、設定ではなくリポジトリ自身の状態を見に行き、答えは API 3 回分の深さにありました。

ターミナルウィンドウ
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
# 404
gh api repos/{owner}/{repo}/vulnerability-alerts -i | head -1
# HTTP/2.0 404 Not Found
gh api graphql -f query='{ repository(owner:"…", name:"…") {
dependencyGraphManifests { totalCount } } }'
# {"dependencyGraphManifests":{"totalCount":0}}

マニフェストは 0 件。依存関係グラフが無効になっており、これはプライベートリポジトリの既定です。 Dependabot は package.jsonpnpm-lock.yaml を読むためにそれを必要とします。github-actions のエコシステムは必要としません。更新ジョブの内側で .github/workflows/*.yml を解析し、グラフには一切触れないからです。ほかがすべて死んでいるあいだ、これだけが動き続けていた理由がまさにそこにあります。

依存関係グラフを必要とするのは片方のエコシステムだけ2 つのレーン。上のレーンは npm のマニフェストから依存関係グラフを通るが、そのグラフはプライベートリポジトリでは既定でオフのため、プルリクエストは生成されない。下のレーンはワークフローファイルから直接、更新ジョブ内での解析へ進み、グラフを完全に迂回してプルリクエストを出し続ける。動いていた下のレーンが、壊れていた上のレーンを覆い隠していた。npm エコシステムpackage.json, pnpm-lock.yaml依存関係グラフプライベートリポジトリでは既定でオフPR は 1 件も出ないgithub-actions エコシステム.github/workflows/*.yml更新ジョブ内で解析PR は届き続けた動いていた側が、壊れていた側を隠していた
動いていた側が、動いていなかった側を覆い隠していました。マージ済みのアクション更新 2 件が「Dependabot は動いている」と読めてしまい、マニフェストを使うエコシステムはすべて止まっていました。

アラートを有効にしました。これで副作用として依存関係グラフが有効になります。

ターミナルウィンドウ
gh api -X PUT repos/{owner}/{repo}/vulnerability-alerts
gh api -X PUT repos/{owner}/{repo}/automated-security-fixes

グラフのマニフェストは 0 件から 5 件になり、SBOM は 404 から 600 パッケージになりました。このリポジトリにはセキュリティ更新もそれまで存在しておらず、有効にした途端に実際の仕事が出てきました。nanoidpath-to-regexp の推移的依存に対する 2 件のセキュリティアドバイザリで、いずれも同日に修正しています。

プライベートリポジトリの罠は一言で言えばこれです。何も警告してくれません。 バナーも、失敗するチェックも、「グラフを有効にすれば動きます」と告げる空状態もありません。設定ファイルは妥当で、スケジュールは発火し、更新ジョブは更新すべきものを何も見つけないだけです。

なぜ semver ゲートを足さなかったか

npm の更新が流れ始めようとしていたので、次に来る当然の問いは、パッチだけ自動マージすべきかどうかでした。私は足さないと決めました。その理由こそ、今回の一連の作業が私にはっきりさせてくれたものです。

不良なリリースには 2 つのクラスがあり、それぞれ別の道具を必要とします。

2 つの不良クラスと、それぞれ 1 つのフィルタ2 つの列。左の列は誰にとっても不良なリリースで、リリース数時間後の取り下げ、翌朝の修正版、侵害された公開物が該当する。フィルタは最小リリース経過日数であり、時間だけで 3 つとも捕らえられる。右の列は自分にとってだけ不良なリリースで、他所では問題なくビルドでき、自分が呼ぶ API だけを壊し、30 日目でも壊れたままである。フィルタは自分の CI であり、待っても何も分からない。誰にとっても不良リリース数時間後に取り下げ翌朝に修正版が出る侵害された公開物最小リリース経過日数dependabot.yml の cooldown:時間が濾し取る。待つこと自体が仕組み。自分にとってだけ不良他所では問題なくビルドできる自分が使う API だけ壊れる30 日目でも壊れたまま自分の CI実際に動くゲート自分のビルドにしか見えない。待っても何も分からない。
最小リリース経過日数とビルドは、同じフィルタの強弱ではありません。見ている失敗が違い、どちらも他方の代わりにはなりません。

誰にとっても不良なリリースは、時間が捕まえます。 数時間で取り下げられるか、翌朝に修正版が出るか、侵害された公開物が誰かの指摘で引っ込められるかです。待つことそのものが仕組みであり、cooldown はまさにその道具です。このクラスの検知について、私のビルドがエコシステム全体より優れている理由は何もありません。

自分にとってだけ不良なリリースは、時間には見えません。 他所では問題なくビルドでき、公開されたまま残り、30 日目は 1 日目とまったく同じに見えます。それを見られるのは自分のビルドだけです。壊れているのは、そのリリースとこのコードベースの交わるところだからです。

semver ゲートは 2 つ目のクラスの代用品であり、CI の網羅が弱いか遅い場所で使うものです。「マイナーはパッチより危険だ」と言っているのであって、それは平均としては正しく、この マイナーが この リポジトリを壊すかどうかについては何も言っていません。CI が実際に動き、実際に止めるのであれば、この代用品は本物の計測がすでに与えているもの以上を何も足しません。

その「であれば」は重い仕事をしており、ここではそれが成り立っていませんでした。

自動マージのワークフローは一度も何も止めていなかった

ワークフローはゲートのように読めました。

.github/workflows/dependabot-auto-merge.yml
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
steps:
# …
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --squash "$PR_URL"

--auto は「必須チェックが通ったらマージしてほしい」と GitHub に頼むものです。落とし穴はその形容詞にあります。このリポジトリは無料の個人プランなので、ブランチ保護もルールセットもエラーを返します。

Upgrade to GitHub Pro or make this repository public to enable this feature.

必須チェックがないということは待つ対象がないということで、gh は即座にマージしました。 直近の依存更新のタイムスタンプがそれを具体的に示しています。

PR #39 はビルド完了の 45 秒前にマージされた1 分ほどを示す 1 本の時間軸。プルリクエストは 0 秒で作成され、13 秒でマージされ、Check & build ジョブが終わるのは 58 秒。マージとビルド完了のあいだは網かけで示され、ビルド結果がないまま変更が main に載っていた 45 秒を表す。0s15s30s45s60sプルリクエスト作成からの経過秒数PR 作成マージCheck & build 完了ビルド結果のないまま main にいた 45 秒
PR #39 は 16:35:56 に作成され、16:36:09 にマージされました。その Check & build ジョブが終わったのは 16:36:54 で、ブランチがすでに main に載ってから 45 秒後です。

知っておく価値のある二次的な効果があり、これが実害の大きさを変えます。GITHUB_TOKEN で行ったマージはワークフローを起動しません。証拠はコミット自体にあります。ボットがマージした 8fce42b には チェック実行が 1 件もなく、人がマージした 67c3d57 には成功した CI の push 実行があります。つまり依存更新のマージでは本番デプロイのジョブが一度も動いていません。不良な更新による被害は、誰かの次の push で表面化する壊れた main であって、壊れたサイトではありませんでした。

当然の修正は自分自身を待ってしまう

誰もが最初に手を伸ばす修正は、ワークフローに待たせることです。pull_request_target はそのままに、--autogh pr checks --watch に置き換えて、そのあとマージする。エージェントはまさにそれを提案し、そのうえでなぜ成立しないかを見つけました。

ターミナルウィンドウ
gh pr checks 39
Deploy to Vercel (production) skipping 0
Check & build pass 50s
Vercel pass 0
auto-merge pass 9s

auto-merge がその一覧に入っています。pull_request_target のジョブ自体が PR 上のチェックであるため、その内側で全チェックの完了を待つステップは、自分が動いているジョブを待つことになります。

自分が監視している対象の内側にある監視ステップ3 つのステップからなる循環。pull_request_target で起動する自動マージのジョブは、プルリクエスト上のチェックとして現れる。そのうえで当該プルリクエストの全チェックの完了を待つが、その集合には自分自身が含まれるため、矢印はジョブへ戻る。何も完了できず、6 時間のジョブタイムアウトでようやく終わる。自動マージのジョブon: pull_request_targetPR 上のチェックとして現れる全チェックの完了を待つ自分自身を待つ。6 時間のタイムアウト後に失敗する。gh pr checks 39 は Check & build と並べて auto-merge を表示する
循環は出発した箱に閉じます。何も完了せず、実行はマージではなく 6 時間のジョブタイムアウトで終わります。

--required も逃げ道にはなりません。このプランには必須チェックが存在せず、それは元の問題が別の顔をしているだけです。

成功した方法。CI 完了後の workflow_run

私は workflow_run の経路を採りました。CI の実行が終わったあとに発火し、ベースリポジトリの文脈で書き込みトークンを持って動き、PR にチェックを付けず、待機でランナーの時間を使いません。

.github/workflows/dependabot-auto-merge.yml
on:
workflow_run:
workflows: ["CI"]
types: [completed]
concurrency:
group: dependabot-auto-merge-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
jobs:
auto-merge:
if: >-
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'

この条件の下で 4 つのステップが動きます。PR の特定、すでに失敗しているチェックの上でのマージ拒否、承認、マージです。

そのうち 2 つが、微妙に間違えやすい部分を担っています。

ヘッド SHA は CI がテストしたコミットと一致していなければなりません。 Dependabot はブランチをリベースしますし、gh pr merge はコマンドを実行した時点でブランチが指しているものをマージするのであって、workflow_run のイベントが記述したものをマージするわけではありません。特定のステップで確認することは必要ですが十分ではありません。そのあいだに API のやり取りが 2 往復入るからです。

ターミナルウィンドウ
gh pr merge --squash --match-head-commit "$HEAD_SHA" "$NUMBER"

保留中のチェックは意図的に無視します。 プレビューのスモークテストは CI ではなくデプロイ先のタイムラインで動き、あるブランチでは永遠に来ないこともあります。そのためこのステップは failcancel のバケットを拒否し、pending を待つことはしません。失敗の仕方は「スモークの結果を見ずにマージした」であって、これは取り返しがつきます。「タイムアウトまで固まる」ほうは取り返しがつきません。

すべてを無意味にしていたはずの permissions ブロック

マージ前のコードレビューが、ローカルの試し撃ちにも CI にも捕まえられないものを見つけました。

ワークフローは、書き込みに必要なものを宣言していました。

permissions:
contents: write
pull-requests: write

いずれかの権限を宣言すると、書かなかったスコープはすべて none になります。つまり checksstatusesactions はどれもゼロでした。そして gh pr checks は REST の呼び出しではありません。ステータスコンテキスト、チェック実行、各チェックスイートの背後にあるワークフローを常に選択する、固定の GraphQL ロールアップクエリを 1 本投げます。--json はクライアント側で絞るだけで、通信内容を狭めることはできません。この呼び出しは毎回失敗していたはずです。

GraphQL: Resource not accessible by integration

これはフェイルクローズなので、間違ったものがマージされることはありません。正しいものもマージされません。

permissions:
contents: write
pull-requests: write
checks: read
statuses: read
actions: read

私のローカル検証がこれを取り逃がした理由は、覚えておく価値があります。私は gh pr checks 39 --json name,bucket を、全スコープのトークンを持つシェルから実行し、まともな JSON が返るのを見ていました。それが証明するのは出力の形だけで、ワークフロー自身のトークンについては何ひとつ証明していません。 絞られた資格情報は別の資格情報です。

これと一緒に、小さめの指摘が 2 つ出ました。shell: キーのない run: ステップは pipefail なしの bash -e {0} で動くため、gh … | head -1head の終了ステータスを拾っており、レート制限にかかった API 呼び出しを「Dependabot の PR はない」として緑の実行のまま報告していたはずです。もうひとつ、キャンセルされたチェックは gh の fail ではなく cancel バケットに入るので、拒否のフィルタは両方を名指しする必要がありました。

緑が実際に証明していること

このリポジトリの CI が動かすのは pnpm checkpnpm build だけです。この話に出てきた欠陥はすべて、バッジが赤くなったからではなく、読んだから見つかりました。

新しいワークフローが初めて本当に試されるのは、マイナーの cooldown が切れて対象になる astro の更新です。確認はコマンド 1 本です。

ターミナルウィンドウ
gh pr view <n> --json mergedAt,statusCheckRollup

mergedAtCheck & build の項目の completedAt より後でなければなりません。その逆転こそが元の欠陥を露わにしたので、修正に対して主張すべきものとして正しいのはそれです。

まとめ

プライベートリポジトリの罠は、設定が難しいことではありません。既定値が違い、しかも失敗が静かなことです。依存関係グラフはオフなので、マニフェストを使うエコシステムは一度も動きません。必須チェックは存在し得ないので、--auto の上に建てたものは、待っているように見えながら即座にマージします。

そこから出てきたポリシーは、以前の私が書いたであろうものより単純です。

問い 答え
誰にとっても不良(取り下げ、修正版、侵害) cooldown。semver の距離に応じて調整
このリポジトリにとってだけ不良 CI。実際に止めるゲートの上で
パッチ/マイナー/メジャーの別 フィルタではない。弱い CI の代用品
セキュリティアドバイザリ 決して遅らせない。cooldown は適用されない

そして、Dependabot の設定があるのに Dependabot の動きがないリポジトリに対して、走らせる価値のある確認が 2 つあります。

ターミナルウィンドウ
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
gh pr view <n> --json mergedAt,statusCheckRollup

1 つ目は Dependabot が依存関係を見えているかを教えます。2 つ目はマージのゲートがゲートになっているかを教えます。

参考リンク

この記事をシェア