0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

緑の PR を 11 秒差でマージしたら main が赤になった話|PR CI は統合後の main を検証しない

0
Posted at

緑の 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 点がそろったときです。

  1. 単一の PR では同時に変更しきれないカップリングがある(コミット済みの生成物とスキーマ、interface と実装、関数のリネームと新規の呼び出し箇所、など)
  2. ブランチ保護の Require branches to be up to date before merging が OFF(古い main を基準にした緑のままマージできる)
  3. 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.gen stale / 発見まで 数時間 / 復旧 hotfix + self-merge 条件を npm run check 全体必須へ強化
  • 環境:TypeScript + スキーマ生成を含むフロントエンドの monorepo(社内複数リポで同一運用のため同型の穴を共有)。

リリース状況の注記:本稿は社内 monorepo での実運用中の事故に基づきます。merge queue はまだ導入検討段階で、現時点の恒久策は self-merge 条件の強化までです。

関連記事

  • (同シリーズ:CI が緑でも本番/統合が壊れる話 — 準備中)
  • (同シリーズ:再発防止は「気をつける」でなく lint/CI に落とす — 準備中)
  • (ハブ:CI と本番のギャップ集【随時更新】 — 準備中)

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?