ある PR のマージ前に CI を確認したところ、**全ジョブ(8ジョブ)が「失敗」**と表示されていました。一見すると、明らかなマージのブロッカーです。コードのどこかが壊れている——そう判断したくなる場面です。
ですが、よく見ると様子がおかしいことに気づきました。各ジョブが 2〜4秒という、実際にテストを走らせたにしては不自然に短い時間で失敗していたのです。
本記事は、この「全ジョブ赤」を反射的に「コード失敗」と断ぜず、失敗の性質を分類し、ローカル再現で機械的に検証した記録です。CI の赤には2種類あり、その見分け方を持っているかどうかで、無駄な時間の使い方が大きく変わります。
事の発端:失敗が「速すぎる」
最初の違和感は、失敗までの所要時間でした。テストスイートを実際に流せば、それなりの秒数がかかります。にもかかわらず、8つのジョブがそろって 2〜4 秒で「失敗」になっていました。
実テストを実行して落ちたのなら、こんなに速く終わるはずがありません。速すぎる失敗は、テストが落ちたのではなく、テストにたどり着く前に落ちている可能性を示唆します。
そこで、CI のジョブ詳細をもう一段掘ってみました。すると、決定的な手がかりが出てきました。
1job: test (3.12)2 steps: [] ← ステップ配列が空3 log: log not found ← ジョブログが取得できない4 duration: 3ssteps 配列が空で、ジョブログは「log not found」。これはつまり、ジョブがテストステップを1つも実行する前に落ちていたということです。ランナーの起動・プロビジョニング段階で失敗しており、そもそもテストコードは走っていなかったのです。
なぜ問題か:赤の見た目は同じでも、原因は別物
ここで重要なのは、CI の「赤」は見た目が同じでも、原因が2種類あるという点です。
| 赤の種類 | 何が起きているか | 本当の対処 |
|---|---|---|
| コード起因の本物の失敗 | テストが実行され、アサーションが落ちた | コードを直す |
| 構造的不発(インフラ起因) | ランナーが起動段階で落ち、テストは未実行 | コードは無罪。CI 側の問題 |
両者は UI 上ではどちらも「失敗(赤)」として同じように見えます。だからこそ、見た目だけで「コードが壊れた」と即断すると、存在しないバグを探し始めるという最悪の時間の使い方をしてしまいます。
今回の steps: [] と「log not found」は、後者すなわちランナー起動段階の構造的不発のシグネチャでした。テストが落ちたのではなく、テストが始まってすらいなかったのです。
決定的検証:ローカルで再現して機械的に確かめる
ログ特性から「これは偽ブロッカーだろう」という仮説は立ちました。ですが、仮説のままマージするわけにはいきません。最終的な真実の源(SoT)はローカルでの再現です。
そこで、PR と同じテストスイートをローカルで流しました。結果はこうです。
1============ test session ============2143 passed3(baseline 129 + 新規 14)4
5mypy: Successテスト件数は完全に一致しました(既存ぶんに新規ぶんを加えた数が、そのまま通っています)。型チェックも Success です。これで、「コードは正常で、CI の赤は偽ブロッカーである」と確定できました。
この判定は、プロジェクトのルール「CI fail を偽ブロッカーと断ずる前に、ローカル再現で機械検証する」を履行したものです。「たぶんインフラのせい」で止めず、手元で動かして数字を突き合わせるところまでやって、初めて結論になります。
もうひとつの確認:そもそも CI は発火すべきだったか
偽ブロッカーを疑うとき、もう1つ紛らわしいケースがあります。「CI の設定上、このブランチでは本来ジョブが発火しない」という場合です。発火しないこと自体が「赤」に見えてしまうことがあります。
そこで念のため、CI のトリガー設定も確認しました。具体的には、対象ブランチに対して発火する条件が設定されているかどうかです。確認の結果、この PR は本来 CI が発火すべき条件を満たしていました。
つまり今回は、「設定で発火しないから赤に見えた」のではなく、「発火した上で、ランナーが起動段階で落ちていた」という構図でした。この切り分けをしておくと、対処先(設定を直すのか、CI 基盤の再実行を待つのか)を間違えずに済みます。
学び:赤を見たら、まず「失敗の性質」を分類する
今回の一件を普遍化すると、こうなります。
AI(や人)は CI の「全ジョブ赤」を見ると、反射的に「コードが壊れている」と判断しがちです。ですが、それは早計です。赤には「コード起因の本物の失敗」と「ランナー起動段階の構造的不発(インフラ起因の偽ブロッカー)」があり、両者は見た目では区別できません。
見分けるための機械的なサインは次の3つです。
| サイン | 内容 | 何を示すか |
|---|---|---|
| (a) 所要時間が異常に短い | ジョブが数秒で終わっている | テスト実行前に落ちている疑い |
| (b) ステップが空・ログが無い | steps: []、ログ取得不可 | ランナー起動段階での不発 |
| (c) diff の範囲が無関係 | 変更箇所がテスト結果に影響しない | コード起因では説明できない |
そして、これらのサインで仮説を立てたら、最終的な SoT はローカルでの再現です。結論を出す前に、複数の根拠層(ログ特性 → diff 範囲 → ローカル再現)で検証し、層が矛盾したときは最下位の層(実際の動作=ローカル再現)を信じます。ログ表示と手元の動作が食い違ったら、信じるべきは手元で実際に動いた結果のほうです。
運用Tips:AI に CI 結果を渡すときの指示
この見分け方は、AI に CI のログを渡して判断させるときにも、そのまま指示に落とせます。ポイントは、「赤=コード失敗」と決めつけさせないことです。
依頼テンプレートはこうしています。
1## CI失敗の分析依頼2
3CIが赤になっている。マージしてよいか判断してほしい。4ただし「赤=コードが壊れている」と決めつけず、5まず失敗の性質を分類せよ。6
7確認手順:81. 各ジョブの所要時間を見る9 (数秒で終わっていれば、テスト未実行の疑い)102. steps配列とジョブログを見る11 (空 or ログ無しなら、ランナー起動段階の不発)123. diffの範囲を見る13 (テスト結果に無関係なら、コード起因では説明不能)144. 上記で「インフラ起因の偽ブロッカー」が疑われたら、15 ローカルで同じスイートを再現実行し、件数と型チェックを突き合わせる5 collapsed lines
16
17出力:18- 「コード起因の本物の失敗」「構造的不発(偽ブロッカー)」に分類19- 分類の根拠をログ特性で示す20- 偽ブロッカーと判断するなら、ローカル再現の結果を必ず添えるこの指示の肝は、結論より先に分類をさせることです。分類のステップを挟むだけで、「存在しないバグを直そうとする」という典型的な空回りを防げます。
まとめ:CIの赤は、信号であって判決ではない
CI の「全ジョブ失敗」は、強い既視感とともに「コードが壊れた」と思わせてきます。ですが、赤はあくまで信号であって、判決ではありません。
今回の流れを整理すると、こうなります。
- 違和感を拾う——失敗が数秒と速すぎることに気づく
- シグネチャを読む——
stepsが空・ログ無しは、ランナー起動段階の不発のサイン - 層を重ねて検証する——ログ特性 → diff 範囲 → ローカル再現の順で根拠を積む
- 最下位層を信じる——表示と実動作が食い違ったら、ローカル再現を真実とする
- トリガー設定も確認する——「発火しないから赤」と「発火した上での不発」を切り分ける
AI に CI 結果を渡すときも同じです。「赤=コード失敗と決めつけず、まず失敗の性質を分類せよ」と一言添えるだけで、判断の精度が変わります。赤を見たら、直す前に分類する——これが、偽ブロッカーに振り回されないための習慣です。