複数の Next.js リポジトリを一人で維持していると、依存パッケージの更新は静かに溜まっていきます。私自身、ある時点で npm outdated を流したら未更新が 47 件並んでいて、しばらく画面を眺めてしまいました。月に一度「まとめて更新する日」を作って一気に上げる運用を続けていたのですが、これが一番壊れやすいやり方だったと、いまは考えています。
まとめて上げると、ビルドが落ちたときに「47 件のうちどれが原因か」の切り分けから始まります。二分探索で戻していけば特定はできますが、その時間が惜しくて更新自体を先送りする悪循環に入ります。そこで発想を変えて、更新を週次の小さな束に分割し、束ごとの判断と作業を Antigravity のエージェントに任せる形に組み替えました。今回はその分類基準・プレイブック・検証ゲートを、実際に使っている実装と一緒に紹介します。
まとめ更新が壊れやすいのは「差分の大きさ」ではなく「切り分け不能性」
最初に整理しておきたいのは、まとめ更新の何が問題かという点です。差分が大きいこと自体は、テストが通れば問題になりません。問題は、失敗したときに原因の候補が多すぎて、失敗コストが件数に比例して膨らむことです。
週次に分割する価値はここにあります。1 束あたりの更新を 5〜8 件に抑え、しかも後述のリスク分類で「同じリスク帯のものだけ」を束ねると、失敗したときの容疑者が最初から絞られています。パッチ更新だけの束が落ちたなら、ほぼ間違いなく lockfile の解決か peerDependencies の連鎖です。メジャー更新を混ぜていないので、API 変更を疑う必要がありません。
エージェントに任せる前提でも、この「束の設計」は人間側の仕事です。エージェントは束の中の作業を高速にこなしてくれますが、束の切り方を誤ると、結局まとめ更新と同じ切り分け問題に戻ります。
更新を3つのリスク帯に分ける
私は semver の差分と「そのパッケージがビルドと実行のどちらに効くか」の 2 軸で、更新を 3 帯に分けています。
| リスク帯 | 条件 | 扱い |
|---|
| Tier 1(自動) | patch 更新、または devDependencies の minor 更新 | エージェントが更新〜検証〜コミットまで無人で実施 |
| Tier 2(半自動) | dependencies の minor 更新、型定義・ビルドツールの major | エージェントが変更ログ要約と検証まで実施し、マージは人間 |
| Tier 3(人間) | フレームワーク本体(Next.js・React 等)の major、認証・決済に関わるパッケージ全般 | エージェントは調査メモの作成のみ。作業自体は人間が着手 |
ポイントは 2 つあります。まず、devDependencies と dependencies を区別することです。ESLint のプラグインが minor で上がっても本番の実行コードには影響しませんが、ランタイム側の minor は挙動が変わり得ます。次に、semver 上は小さくても「決済・認証」に触るものは無条件で Tier 3 に送ることです。Stripe の SDK は patch でも私は手動で見ます。ここで事故ると金額の桁が違うからです。
リスク分類を機械化する Node スクリプト
分類を毎回目視でやると続かないので、npm outdated --json の出力を Tier に振り分ける小さなスクリプトを用意しました。これが週次ランの入口になります。
// scripts/dep-triage.mjs — 依存更新を Tier 1/2/3 に分類して JSON 出力する
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
// 無条件で Tier 3 に送るパッケージ(決済・認証・フレームワーク本体)
const ALWAYS_HUMAN = [/^next$/, /^react(-dom)?$/, /^stripe$/, /@auth\//, /^next-intl$/];
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const devDeps = new Set(Object.keys(pkg.devDependencies ?? {}));
// npm outdated は更新があると exit 1 を返すため、出力だけ拾う
let raw = "{}";
try { raw = execSync("npm outdated --json", { encoding: "utf8" }); }
catch (e) { raw = e.stdout || "{}"; }
const outdated = JSON.parse(raw);
const diffLevel = (cur, latest) => {
const [cM, cm] = cur.split(".").map(Number);
const [lM, lm] = latest.split(".").map(Number);
if (lM > cM) return "major";
if (lm > cm) return "minor";
return "patch";
};
const triage = { tier1: [], tier2: [], tier3: [] };
for (const [name, info] of Object.entries(outdated)) {
const level = diffLevel(info.current ?? "0.0.0", info.latest);
const entry = { name, current: info.current, latest: info.latest, level };
if (ALWAYS_HUMAN.some((re) => re.test(name)) || level === "major" && !devDeps.has(name)) {
triage.tier3.push(entry);
} else if (level === "patch" || (devDeps.has(name) && level === "minor")) {
triage.tier1.push(entry);
} else {
triage.tier2.push(entry);
}
}
writeFileSync(".dep-triage.json", JSON.stringify(triage, null, 2));
console.log(`Tier1: ${triage.tier1.length} / Tier2: ${triage.tier2.length} / Tier3: ${triage.tier3.length}`);
実行すると .dep-triage.json に分類結果が書き出され、標準出力には件数だけが出ます(手元のリポジトリでは Tier1: 6 / Tier2: 3 / Tier3: 2 のような出方になります)。エージェントにはこのファイルを読ませて、Tier 1 と Tier 2 だけを扱わせます。ALWAYS_HUMAN の正規表現リストがこの運用の安全弁なので、決済や認証のパッケージを追加したら必ずここにも足します。
なお、エージェントが作業中に依存を新規追加してしまうケースの棚卸しは別の仕組みで扱っており、エージェントが足した依存を、あとから棚卸しする — ライセンスと出所を追える形で残す設計にまとめています。今回の週次更新とは役割が異なりますが、組で運用すると依存まわりの見通しが揃います。
エージェントに渡すプレイブック — 「やらないこと」を先に書く
Antigravity のエージェントに渡す指示は、リポジトリ内に YAML のプレイブックとして置いています。プロンプトに毎回書くのではなくファイルに固定するのは、週次のスケジュール実行で指示が少しずつ言い換えられて劣化するのを防ぐためです。
# .antigravity/playbooks/weekly-deps.yaml
task: weekly-dependency-update
input: .dep-triage.json
scope:
allowed:
- "package.json と lockfile の更新(tier1, tier2 のみ)"
- "npm run build / npm run test の実行と結果記録"
- "tier2 の各パッケージについて公式変更ログの要約を作成"
forbidden:
- "tier3 のパッケージに触ること"
- "ビルド失敗時に依存以外のソースコードを書き換えて通すこと"
- "変更ログが見つからないパッケージについて内容を推測で書くこと"
procedure:
- "tier1 を一括で npm install <name>@<latest> し、build と test を実行"
- "失敗したら tier1 の束を二分し、通る側だけコミットして残りは report に記録"
- "tier2 は 1 パッケージずつ更新し、それぞれ build まで確認"
report: .antigravity/reports/weekly-deps-{date}.md
いちばん効いているのは forbidden の 2 行目です。エージェントは「ビルドを通す」ことに忠実なので、依存更新で型エラーが出ると、アプリ側のコードを書き換えてでも通そうとすることがあります。それは依存更新のスコープを超えた変更であり、レビューで見落とすと挙動の変化が紛れ込みます。更新が通らないなら「通らなかった」という報告が正解だと、明示的に書いておく必要がありました。
変更ログの確認を任せるときの幻覚対策
Tier 2 で人間がマージ判断をするために、エージェントに各パッケージの変更ログ要約を作らせています。ここで一度失敗しました。存在しないリリースノートの内容を、それらしく要約してきたことがあるのです。
対策として、要約には必ず「参照した URL」と「原文からの引用を 1 行以上」を含める形式を義務付けました。引用が捏造なら URL を開いた瞬間に分かります。プレイブックの forbidden に「推測で書かない」と入れた上で、レポートのテンプレートを次のように固定しています。
### <package>@<version>
- source: <参照した変更ログの URL>
- quote: "<原文からの逐語引用(1行)>"
- summary: <破壊的変更の有無と、このリポジトリへの影響を2文以内>
- verdict: merge / hold / needs-human
この形式にしてから、要約の信頼性を疑って自分で全部読み直す、という本末転倒な時間がなくなりました。引用の逐語性という機械的に確認できる制約が、そのまま幻覚の検出器になってくれています。
検証ゲート — lockfile 差分は「行数」ではなく「範囲」を見る
更新のコミット前に、エージェント自身に通させるゲートは 3 つです。ビルド、テスト、そして lockfile 差分の範囲チェックです。3 つ目は見落とされがちですが、私はここを一番重視しています。
patch 更新 6 件の束なのに lockfile の差分が数千行に及ぶ場合、transitive な依存解決が大きく動いています。それ自体が悪ではないものの、「小さい束」という前提が崩れているサインなので、無人でコミットさせずに止めます。判定はシンプルで十分です。
# 更新対象パッケージ名のリストを .dep-triage.json から取り出し、
# lockfile 差分に「対象外パッケージの version 変更」が混ざっていないか確認する
TARGETS=$(node -e "const t=require('./.dep-triage.json');console.log([...t.tier1,...t.tier2].map(e=>e.name).join('|'))")
UNRELATED=$(git diff package-lock.json | grep -E '^\+\s+"node_modules/' | grep -vE "node_modules/(${TARGETS})[/\"]" | wc -l)
if [ "$UNRELATED" -gt 40 ]; then
echo "⚠️ 対象外パッケージの解決が ${UNRELATED} 行動いています。無人コミットを中止します"
exit 1
fi
しきい値の 40 行は私のリポジトリでの経験値で、根拠のある普遍的な数字ではありません。最初の数週間はゲートを警告のみにして、正常な週次ランの差分がどの程度に収まるかを観測してから決めました。この「まず観測してから閾値を切る」順序は、どのリポジトリでも同じように使えると思います。
なお、複数リポジトリで同時にランを走らせると lockfile の書き込みが競合する問題は依存更新でも起こります。私はリポジトリ間は直列、リポジトリ内の調査だけ並列という構えにしていて、この判断の背景は並列で走らせたエージェントの依存インストールが lockfile を壊すとき、インストールだけを直列化するに書いた事情と同じです。
週次スケジュールに載せる — 二重起動と「空振り週」の扱い
運用は毎週金曜の夜に固定しています。スケジュール実行にした途端に出てくる二重起動や再実行の問題は、依存更新では特に危険です(同じ束を二重に適用しようとして lockfile が壊れます)。ロックファイルによる重複ガードの組み方はスケジュール実行のエージェントが二重に走る — 重複と再実行に耐える冪等設計の方式をそのまま流用しました。
もう 1 つ、実際に運用して分かったのは「空振り週」の扱いの大切さです。更新が 0 件の週でも、triage スクリプトの実行結果とレポートは必ず残します。レポートが無い週があると、「走らなかったのか、走って 0 件だったのか」が後から区別できません。無人運用の監視は、成功の証跡を毎回残すところから始まると感じています。
6週間の実測メモ
個人開発の 4 リポジトリ(いずれも Next.js + TypeScript 構成)でこの運用に切り替えて 6 週間の数字です。滞留していた未更新 47 件は、週あたり 5〜9 件の消化で 6 週目にほぼ解消しました(Tier 3 の 4 件は意図的に残しています)。週次ラン 1 回の所要はエージェント側が 20〜30 分、人間のレビューは Tier 2 のレポート確認で 5 分前後です。月イチまとめ更新の頃は 1 回 2〜3 時間かけた上で壊れることがあったので、拘束時間そのものより「壊れたときの切り分けが要らなくなった」ことの方が大きな変化でした。
期間中に無人ゲートで止まったのは 2 回で、1 回は lockfile 差分の範囲超過(esbuild の transitive 更新)、もう 1 回はテスト失敗(タイムゾーン依存のテストが原因で、更新とは無関係の flaky でした)。どちらも止まってくれたこと自体が収穫です。誤検知を含めて「疑わしければ止まる」側に倒しておくのが、無人運用では正しいバランスだと考えています。
まとめ — 最初の一歩は分類スクリプトだけでいい
いきなり週次の無人ランまで組む必要はありません。まず dep-triage.mjs を手元のリポジトリで一度実行して、47 件(あるいはそれ以上)の未更新が 3 つの Tier にどう割れるかを見てみてください。Tier 1 が多ければ、その束はもう今日エージェントに渡せます。分類が運用の骨格で、自動化はその上に少しずつ載せていくものだと、この 6 週間で実感しました。