緑の PR を 11 秒差でマージしたら main が赤になった話|PR CI は統合後の main を検証しない
はじめに
コード生成(スキーマ→型)を含む monorepo を、複数人・複数エージェントで回している方へ。
ある日、独立した 2 本の PR が「両方とも CI 緑」でマージされ、その直後に main だけが赤くなりました。マージの間隔は 11 秒。しかも誰も気づかないまま数時間が過ぎました。
最初は「11 秒差なんて際どいタイミングでマージしたせいだ」と考えました。これが間違いでした。 調べてみると、この穴は 11 秒だろうと 3 日だろうと同じように開いていて、むしろ「PR を数日寝かせてからマージする」普通の運用のほうが危険窓は広い、という構造をしていました。
この記事では、なぜ「PR が緑」でも「main が赤」になるのか、危険窓の正しい測り方、そして 3 つの対策(merge queue / main の赤の通知昇格 / Require branches to be up to date)がそれぞれ何を直すのかを、実測ベースで示します。
この記事で分かること:
- 「ブランチで緑」×「ブランチで緑」は「統合後の main で緑」を保証しない理由(semantic conflict)
- 危険窓は「マージ間隔」ではなく **「その PR が最後に CI を回してから、マージされるまで」**であること
- 3 つの対策のどれが何を直し、何を直さないか
症状:2 本の PR は緑、統合した main だけが赤
登場するのは、互いに独立した 2 本の PR です。
- 先行 PR:機能追加。自分のブランチで CI 緑。
- Phase 2 PR:別の機能追加。こちらも自分のブランチで CI 緑。
2 本は 11 秒差でマージされました。マージ直後の main で、Frontend CI が赤になりました。原因は schema.gen(スキーマから生成する型定義)が古い(stale)状態だったこと。そして発見まで数時間かかりました——誰も main の CI を見ていなかったからです。
実測:何が起きたか
両 PR は、それぞれのブランチで以下を通していました。
- 型チェック / lint / format / test /
codegen:check(生成物がスキーマと一致しているか)
ここで肝心なのは、codegen:check は「その PR のブランチ時点のスキーマと生成物」しか照合しないという点です。
先行 PR がスキーマ側に触れていた場合、Phase 2 PR のブランチはそのスキーマ変更を知らないまま緑になっています。両者を main に重ねた瞬間、どちらのブランチにも存在しなかった組み合わせが main 上で初めて生まれ、生成物が stale になります。各 PR の CI は「自分のブランチ基準」でしか回っていないので、この組み合わせを誰も検証していません。
注意したいのは、git のマージ自体はテキストとしては衝突なく通っていることです。壊れているのは意味のほうで、この種の衝突には semantic conflict(意味的衝突) という名前がついています。テキストの重なりを見る git のコンフリクト検出では、原理的に捕まりません。
原因:危険窓は「マージ間隔」ではなく「最後の CI 実行〜マージ」
結論はシンプルです。
PR CI が緑にするのは「PR ブランチ」であって「マージ後の main」ではありません。
では、いつからいつまでが危険なのでしょうか。事故直後は「11 秒差というほぼ同時のマージが引き金だった」と考えていましたが、これは違いました。危険窓の正しい定義はこうです。
その PR が最後に CI を回した時刻から、マージされる時刻まで。 この窓の間に main へ入った変更との組み合わせは、誰も検証していません。
ここで GitHub Actions の仕様が効いてきます。pull_request の CI が回り直すのは PR の head が動いたとき(push=synchronize)であって、base(main)が進んでも再実行されません。つまり main が何回動こうと、PR の緑バッジは「昔の main を基準にした緑」のまま表示され続けます。
この定義で普通の運用を見直すと、印象が逆転します。月曜に PR を開いて CI 緑、水曜にレビュー、金曜にマージ——この間に main が 15 回進んでいれば、危険窓は 4 日です。11 秒差の同時マージは珍しくても、「緑になってから数日後にマージ」は多くのチームが毎週やっています。私たちの 11 秒は「窓がどれだけ短くても踏むときは踏む」ことを示しただけで、本質は窓の長さではなく、窓の中に「カップリングされた変更」が入るかどうかでした。
壊れる条件を一般化すると、次の 3 点がそろったときです。
- 単一の PR では同時に変更しきれないカップリングがある(コミット済みの生成物とスキーマ、interface と実装、関数のリネームと新規の呼び出し箇所、など)
- ブランチ保護の Require branches to be up to date before merging が OFF(古い main を基準にした緑のままマージできる)
- main の CI を誰も見ていない(壊れても気づけない)
生成物(schema.gen)をコミットする monorepo は (1) の発生率が高いだけで、穴そのものはコード生成のないリポにもあります。「関数をリネームして呼び出し側を全部直した」PR と「旧名の呼び出しを 1 個増やした」PR は、両方緑のまま main を赤にします。
「マージ前に最新 main を取り込んで確認すれば防げるのでは?」
方向は正しいです。ただし、そのままだと 2 箇所に穴が残ります。
1 つ目:コンフリクト確認では捕まりません。 今回のケースは、最新 main を取り込んでも git はコンフリクトを 1 件も出しません。先行 PR が触ったのはスキーマ、こちらのブランチが持っていたのは古い生成物——テキストとしては綺麗にマージできてしまいます(semantic conflict と呼ばれる所以です)。検出器はコンフリクトではなく CI です。取り込んだ状態(=main +自分の変更の合成)で codegen:check を含むフルの CI を回し直して、初めて stale が赤で出ます。
2 つ目:取り込んだ後にも窓は開きます。 取り込んで CI 緑になっても、自分がマージするまでの間に別の PR が main に入れば、また「誰も検証していない組み合わせ」に戻ります。窓が縮むだけで、閉じてはいません。そして手動運用の最大の弱点は技術ではなく、「毎回・全員が・忘れずに」を人間に要求することです。
PR の流量が少ないチームなら、この規律だけでも実用上はかなり守れます。次の 3 対策は、この「手動の規律」を機械に肩代わりさせていく順路として読んでください——手で取り込むのを設定で強制するのが (c)、その待ち行列ごと自動化するのが (a) です。
対策:3 つの候補と、それぞれが直すもの
(a) merge queue を入れる
マージを直列化し、「main にこの PR を重ねた状態」を先に作ってから required checks を回し、緑だったものだけを取り込む仕組みです(GitHub merge queue のほか、Bors や Zuul など同系のツールがあります)。「統合後の組み合わせを誰も検証していない」という穴そのものを塞ぐので、根治に最も近い対策です。トレードオフはマージのスループット低下と導入の手間です。
(b) main push CI の赤を「通知」へ昇格する
穴そのものは塞ぎませんが、赤を数時間放置する事態をなくします。main への push で走る CI が赤になったら人に飛ばす。今回の事故で数時間かかった「発見」は、これがあれば数分でした。安価で、まず入れるべき最低ラインです。
(c) Require branches to be up to date + required check
codegen:check を required check にするだけでは足りません。それは「自分のブランチ基準の鮮度」を必須にするだけで、統合後の組み合わせはやはり見ていないからです。効くのは 「Require branches to be up to date before merging」を併せて有効にすることです。base が進んだ PR はマージ前にブランチ更新(=最新 main の取り込み)を強制され、その push で CI が回り直します。つまり**「古い main を基準にした緑」でのマージ自体ができなくなり、危険窓が実質ゼロになります**。
ただし PR の流量が多いリポでは「更新 → CI 待ち → その間に別 PR がマージされてまた更新」の行列ができます。この行列を自動化したものが (a) の merge queue なので、流量が増えたら (c) から (a) へ育てるのが自然な順路です。
私たちの現在地
まず壊れた main を hotfix で復旧し、恒久策として self-merge の条件を「チェック全体(npm run check)の完走」必須へ強化しました。次の一手は (b) の通知昇格で、merge queue は流量を見ながらの検討段階です。「全部直しました」という話ではありません。
学び(3 行)
- 「緑」は PR の緑であって main の緑ではありません。 ブランチ緑 × ブランチ緑 ≠ 統合緑(semantic conflict)。
- 危険窓は「マージ間隔」ではなく「最後の CI 実行〜マージ」。 base が進んでも PR の CI は回り直さないので、PR を数日寝かせる普通の運用こそ窓が広い。
- 穴を塞ぐ前に、まず赤を通知へ昇格する。 気づけない赤がいちばん高くつきます。
一次資料
- 事故の再現条件:スキーマ→生成物のチェックを持つ monorepo で、(1) スキーマを変える PR と (2) 古い生成物のまま緑になる別 PR を、後発 PR が先行マージを取り込まないままマージする(マージ間隔は無関係)。
- 実測値:マージ間隔 11 秒 / main の赤の要因
schema.genstale / 発見まで 数時間 / 復旧 hotfix + self-merge 条件をnpm run check全体必須へ強化。 - 環境:TypeScript + スキーマ生成を含むフロントエンドの monorepo(社内複数リポで同一運用のため同型の穴を共有)。
リリース状況の注記:本稿は社内 monorepo での実運用中の事故に基づきます。merge queue はまだ導入検討段階で、現時点の恒久策は self-merge 条件の強化までです。
関連記事
- (同シリーズ:CI が緑でも本番/統合が壊れる話 — 準備中)
- (同シリーズ:再発防止は「気をつける」でなく lint/CI に落とす — 準備中)
- (ハブ:CI と本番のギャップ集【随時更新】 — 準備中)
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp