この記事は 2026 年 10 月時点の情報です。GitHub の Issue から AI(Claude Code)が実装し、ビルド・テスト・E2E で確かめてから PR を作る仕組みを、iOS アプリで作ったときの話です。記事中のスクリプトは説明用に書き起こした最小の例で、手元で動作を確認しています。
はじめに
AI に実装を任せる仕組みを作ると、「失敗したらエラーを渡して、もう一度やらせる」というループをまず作ります。これ自体は正しいのですが、動かしてみると次のことが起きます。
- 一生通らないテストを、AI が通そうとし続ける
- 通すために、変えてほしくない所まで書き換える
- そもそも依頼の内容が足りないのに、AI が想像で埋めて進む
原因は、失敗を 1 種類として扱っていることでした。失敗には「やり直せば直るもの」と「何回やり直しても直らないもの」があります。分けないままループに入れると、AI は直らない失敗を直そうとします。
この記事では、失敗をどう分けたか、分け間違えやすい所はどこか、を書きます。前半は言語を問わない話、後半は iOS(Xcode とシミュレータ)ならではの話です。
なお、AI に触らせてはいけないファイルの守り方は、AI に iOS アプリを実装させるとき、触らせてはいけないファイルと守り方 に書いています。
失敗は 5 種類。やり直す価値があるのは 1 つだけ
失敗を、「次に誰が動くべきか」で分けました。
| 種類 | 例 | やり直しで直るか | 次に動く人 |
|---|---|---|---|
| 依頼が足りない | 完成の条件が書かれていない | 直らない | 依頼を書いた人が、条件を足す |
| 大きすぎる | 変更が上限(ファイル数・行数)を超えた | 直らない | 依頼を書いた人が、依頼を分ける |
| 環境の問題 | 道具がない、AI が未認証、シミュレータが決まらない | 直らない | 環境を管理する人 |
| 禁止ファイルに触った | CI の設定やビルドの設定を書き換えた | 直らない | 人が中身を確かめる |
| 実装の問題 | ビルド・テスト・E2E が落ちた | 直るかもしれない | AI がやり直す |
AI にやり直させる意味があるのは、最後の 1 種類だけです。上の 3 つは、AI がコードをどう直しても解決しません。環境に SwiftLint が入っていなければ、AI が何回実装し直しても lint は通りません。
4 つ目の「禁止ファイルに触った」は少し性質が違います。やり直せば、触らずに済むかもしれません。それでもやり直させないのは、AI が触る必要があると判断したこと自体が、人が見るべき出来事だからです。依頼が禁止の範囲を必要としているのか、AI が依頼から外れたのかは、人が確かめないと分かりません。
分ける基準を「エラーの内容」ではなく「次に動く人」にしたのは、失敗の通知を受け取った人が、最初に知りたいのがそれだからです。全部まとめて「失敗しました」と通知すると、人はログを読んで原因を探す所から始めることになります。
いつ分けるか
種類によって、分かるタイミングが違います。早く分かるものほど、安く止められます。
| タイミング | 分かる失敗 | 調べ方 |
|---|---|---|
| AI を動かす前 | 依頼が足りない | 依頼の本文に、完成の条件が書かれているか |
| AI を動かす前 | 環境の問題 | 道具、バージョン、シミュレータ |
| AI の実装の直後(コミットの前) | 禁止ファイルに触った、大きすぎる | 変更されたファイルの名前、数、行数 |
| 検証 | 実装の問題 | ビルド・lint・テスト・E2E |
「依頼が足りない」の検査には、落とし穴がありました。最初は「完成の条件」という見出しがあるかを見ていましたが、依頼のテンプレートを使うと、見出しは必ず入ります。中身が空の依頼がそのまま通り、AI が動き出してしまいました。見るべきは、見出しの有無ではなく、見出しの下に中身があるかです。
分類は、終了コードで返す
分類は、スクリプトの終了コードで返します。終了コードは、プログラムが終わるときに返す数字です。0 が成功、それ以外が失敗という決まりだけがあり、失敗の種類は作る側が決められます。
# 0 = 実装できた
# 2 = 使い方の誤り(必要な設定が渡されていない、など)
# 10 = input_insufficient 依頼が足りない
# 11 = scope_too_large 大きすぎる
# 12 = environment_failure 環境の問題
# 13 = forbidden_change 禁止ファイルに触った
# 14 = implementation_failed 実装の問題
10 から始めているのは、小さい番号がすでに使われているからです。1 は「一般的な失敗」として多くのコマンドが返し、2 はシェルの組み込みコマンドが「使い方の誤り」に使います。自分で決める番号を 10 以上にしておけば、呼び出した別のコマンドが返した番号と見間違えません。
名前(environment_failure など)も、番号とは別にファイルへ書き出しておきます。後で人への通知を組み立てるときに使います。
やり直しのループと、2 つの上限
「実装の問題」だけを、ループでやり直します。
#!/usr/bin/env bash
# AI の実装と検証を往復させる。
# 終了コード: 0=すべて通過 / 10〜13=やり直しても直らない失敗 / 14=上限まで試して直らなかった
# 決めていない終了コードは、環境か仕組み自体の問題として 12 にまとめる
set -uo pipefail
PHASES=(build lint test e2e) # 安いものから順に
MAX_SAME_FAILURE=2 # 同じ段階でのやり直しは 2 回まで(3 回目の失敗で止める)
MAX_TOTAL_ATTEMPTS=6 # 全体の試行は 6 回まで
LOG_DIR="${LOG_DIR:-./logs}"
mkdir -p "$LOG_DIR"
rm -f "$LOG_DIR"/failcount-* # 前回の実行の数え残しを消す
: > "$LOG_DIR/previous-failure.txt" # 1 回目は「前回の失敗」が空
give_up() { # give_up <理由>
echo "implementation_failed: $1" >&2
echo "implementation_failed" > "$LOG_DIR/classification.txt"
exit 14
}
env_failure() { # env_failure <理由>
echo "environment_failure: $1" >&2
echo "environment_failure" > "$LOG_DIR/classification.txt"
exit 12
}
attempt=1
while :; do
[ "$attempt" -le "$MAX_TOTAL_ATTEMPTS" ] || give_up "全体の試行が上限(${MAX_TOTAL_ATTEMPTS} 回)を超えた"
# 1. AI に実装させる(2 回目以降は、前回の失敗ログを渡す)
./implement.sh "$LOG_DIR/previous-failure.txt"
code=$?
case "$code" in
0) ;;
10|11|12|13|14) exit "$code" ;; # 決めた分類は、そのまま上へ返す。やり直さない
*) env_failure "implement.sh が想定外の終了コード $code を返した" ;;
esac
# 2. 検証を順に実行し、最初に落ちた段階で止める
failed=""
for phase in "${PHASES[@]}"; do
./verify.sh "$phase" > "$LOG_DIR/attempt${attempt}-${phase}.log" 2>&1
code=$?
case "$code" in
0) continue ;;
12) env_failure "$phase の途中で環境の問題が起きた" ;; # シミュレータが落ちた、など
*) failed="$phase"; break ;;
esac
done
# 3. 全部通ったら成功
if [ -z "$failed" ]; then
echo "verified: attempt=$attempt"
exit 0
fi
# 4. 同じ段階で落ちた回数を数える
count=$(( $(cat "$LOG_DIR/failcount-$failed" 2>/dev/null || echo 0) + 1 ))
echo "$count" > "$LOG_DIR/failcount-$failed"
echo "attempt $attempt: $failed で失敗(この段階で ${count} 回目)" >&2
if [ "$count" -gt "$MAX_SAME_FAILURE" ]; then
echo "$failed" > "$LOG_DIR/failed-phase.txt"
give_up "$failed の失敗が上限(${MAX_SAME_FAILURE} 回)を超えた"
fi
# 5. 失敗ログの末尾を、次の実装へ渡す
tail -n 50 "$LOG_DIR/attempt${attempt}-${failed}.log" > "$LOG_DIR/previous-failure.txt"
attempt=$((attempt + 1))
done
ポイントは 4 つです。
-
直らない失敗は、ループに入れない。
implement.shが 10〜13 を返したら、そのまま上へ返して終わります。やり直しません - 決めていない終了コードは、環境の問題にまとめる。 コマンドが見つからない(127)、途中で止められた(130)など、自分で決めた番号以外が返ってきたら、仕組みの側の問題として 12 にします。そのまま外へ返すと、通知の側が分類できません
- 検証の途中の環境の問題も、やり直さない。 事前に環境を調べていても、シミュレータが落ちる、Xcode が一時的に不調になる、といったことは検証中にも起きます。検証のスクリプトが 12 を返したら、AI にやり直させずに止めます
- 検証は、安いものから並べる。 ビルド → lint → テスト → E2E の順です。手前で落ちれば、時間のかかる E2E を動かさずに済みます
- やり直したら、最初の段階から全部やり直す。 E2E で落ちて直した場合も、ビルドから確かめ直します。直した結果、別の所が壊れているかもしれないからです
- 上限を 2 種類持つ。 下で説明します
なぜ上限が 2 種類要るか
最初は「同じ段階でのやり直しは 2 回まで」だけでした。同じ段階で 3 回目に落ちたら止めます。同じ所を 2 回直しても通らないなら、AI の手に負えないか、依頼そのものが間違っている可能性が高いからです。
ところが、これだけだと抜けがあります。ビルドで落ち、直すと lint で落ち、直すとテストで落ち、と違う段階で落ち続けると、段階ごとの回数は増えないまま、試行だけが重なります。4 段階あれば、理屈の上では 9 回まで回れます(各段階で 2 回ずつ落ちて 8 回、その次で 9 回目)。
iOS のビルドとテストと E2E は、1 周に十数分かかります。9 回も回ると CI の制限時間に当たり、強制終了されます。強制終了されると、「どの種類の失敗だったか」の記録も残りません。
そこで、全体の試行回数にも上限を付けました。実際に動かすと、こうなります。
attempt 1: build で失敗(この段階で 1 回目)
attempt 2: lint で失敗(この段階で 1 回目)
attempt 3: test で失敗(この段階で 1 回目)
attempt 4: e2e で失敗(この段階で 1 回目)
attempt 5: build で失敗(この段階で 2 回目)
attempt 6: lint で失敗(この段階で 2 回目)
implementation_failed: 全体の試行が上限(6 回)を超えた
どの段階も 3 回目の失敗には達していませんが、全体の上限で止まっています。
このループ自体も、偽物の implement.sh と verify.sh を差し込んでテストしています。「同じ段階で 3 回落ちたら 14 を返す」「環境の失敗は 1 回もやり直さずに 12 を返す」「想定外の終了コードは 12 にまとめる」といった動きを、AI もビルドも動かさずに数秒で確かめられます。
分け間違えやすい所
分類の表を作るのは簡単です。難しいのは、実際の失敗を正しい種類に振り分けることでした。間違えやすかった所を 4 つ書きます(「依頼が足りない」の見出しの話も、同じ種類の間違いです)。
1. 未認証の AI は「成功」で終わる
私が確かめた時点の Claude Code は、認証していない状態で動かすと「Not logged in」と表示して、成功の終了コード(0)で終わりました。エラーにはなりません。
すると、仕組みの側からは「AI は正常に終わったが、何も変更しなかった」と見えます。これを素直に扱うと「実装の問題」に分類され、「AI が実装できませんでした」と人に通知されます。本当は環境の問題なのに、依頼を書いた人が「依頼の書き方が悪かったのか」と悩むことになります。
対策として、AI の出力に未認証を示す文言がないかを先に調べ、あれば「環境の問題」に振り分けています。
出力の文言で判定すると、誤って当たることがあります。ログイン機能の実装を頼むと、AI の説明文に「not logged in」という言葉が出てくるからです。出力の先頭の数行だけを見るなど、範囲を絞る必要があります。
教訓は、終了コードが 0 でも、成功とは限らないことです。「変更が 1 つもない」は、成功とは別の状態として調べます。
2. 変更する前から落ちているテスト
テストが落ちたとき、それは AI の変更のせいでしょうか。iOS のテストには、実行の順番や通信の状態によって、たまに落ちるものがあります。
AI が触る前のテスト結果を知らないと、元から落ちていたテストを AI のせいにしてしまいます。AI は自分の変更と関係ないテストを直そうとし、関係ないコードを書き換え始めます。
そこで、AI に実装させる前に、一度テストを動かして結果を記録しておきます。記録が「失敗」だった場合は、AI に渡す失敗ログの先頭に、次の注意を足します。
注意: 変更前の時点でも、テストは失敗していた。
以下が自分の変更に起因するものか、既存の失敗かを見極めること。
これがあると、AI は「この失敗は既存のもので、今回の変更では直せない」と判断できるようになります。
ただし、これは AI の判断に任せる方法です。AI が気を利かせて、既存の失敗まで直しに行く可能性は残ります。より確実にするなら、次のどちらかの運用があります。
| 運用 | 内容 | 向いている場面 |
|---|---|---|
| 既存の失敗を、別の種類として人に戻す | 変更前から落ちていたら、AI を動かさずに「既存の失敗」として止める | 既存の失敗がまれで、まず直してほしいとき |
| 既存の失敗を一覧にして、増えた分だけを見る | 変更前に落ちていたテストの名前を記録し、変更後に新しく落ちたテストだけを「実装の問題」にする | 不安定なテストがいくつか残っていて、すぐには直せないとき |
私の仕組みは、今は AI に注意を渡す方法です。不安定なテストが残っている間は、2 つ目の運用に寄せるのが次の改善だと考えています。
3. 環境の問題は、始める前に調べる
環境の問題は、AI を動かしてから気づくより、動かす前に調べるほうが安く済みます。道具があるか、バージョンが合っているかを先に確かめて、足りなければそこで止めます。AI を動かさないので、時間もトークンも使いません。
始める前に調べておくと、後でビルドが落ちたときに「環境は確認済みだから、原因はコードだ」と言い切れます。失敗の原因の候補を、先に 1 つ消しておく作業です。iOS で何を調べるかは、後半に書きます。
4. 時間切れは、分類が残らない
CI の制限時間で強制終了されると、スクリプトは終了コードを返す機会がありません。分類のファイルも書かれません。人に届くのは「タイムアウトしました」だけです。
対策は 2 つです。
- 上で書いた、全体の試行回数の上限。制限時間に当たる前に、自分で「実装の問題」として止まる
- 最後に動く時間のかかる処理(後で書く AI のレビュー)には、専用の制限時間を付ける。全体の制限時間に巻き込まれる前に、自分で「判定できなかった」として終わる
自分で止まれば記録が残り、止められると何も残りません。
iOS ならではの、分け間違えやすい所
ここからは、Xcode とシミュレータに特有の話です。どれも「失敗が成功に見える」か、「環境の失敗がコードの失敗に見える」かのどちらかです。
1. xcodebuild | xcpretty は、失敗を成功に変える
xcodebuild の出力は長いので、xcpretty などに通して読みやすくすることがよくあります。
xcodebuild test -scheme App -destination "$DESTINATION" | xcpretty
このままだと、テストが落ちても、この行は成功として扱われます。シェルは、パイプでつないだコマンドのうち、最後のコマンドの終了コードを全体の結果にするからです。xcodebuild が失敗しても、xcpretty が正常に終われば 0 です。
false | cat; echo "exit=$?" # exit=0
set -o pipefail; false | cat; echo "exit=$?" # exit=1
set -o pipefail を付けると、途中のコマンドの失敗が全体の結果になります。これがないと、ループは「全部通った」と判断して先へ進み、壊れた変更で PR を作ります。失敗を分ける以前に、失敗に気づけません。
pipefail は、設定したシェルの中でだけ効きます。ここにも落とし穴があります。
-
Makefile: 1 行ごとに別のシェルで動きます。
set -o pipefailの行と、xcodebuild | xcprettyの行を分けると、効きません。set -o pipefail && \のように、同じ行につなげます -
GitHub Actions:
run:のshellを指定しないと、bash -e {0}で動き、pipefailは付きません。shell: bashと明示すると、bash --noprofile --norc -eo pipefail {0}になり、付きます。どちらにしても、パイプを使うステップの中でset -o pipefailを書いておくと確実です
Makefile については、手元で確かめました。
all:
@set -o pipefail; false | cat; echo "同じ行: exit=$$?" # → 1
@false | cat; echo "次の行: exit=$$?" # → 0(効いていない)
2. 同じ名前のシミュレータが 2 台あると、ビルドが始まらない
ビルド先を名前で指定していると、同じ名前・同じ OS のシミュレータが 2 台あるだけで xcodebuild は失敗します。使い捨てのシミュレータを 2 台作って確かめました。
xcodebuild: error: Unable to find a device matching the provided destination specifier:
{ platform:iOS Simulator, OS:26.5, name:article-dup-sim }
The requested device could not be found because multiple devices matched the request.
1 台のときは同じコマンドでビルドが通り、2 台にすると終了コード 70 で失敗しました。
これは AI のコードと無関係ですが、AI を動かした後に起きると「ビルドの段階で落ちた」としか見えません。AI は直せないコードを直そうとして、上限まで回ります。
自分たちで用意した Mac を CI に使っていると、シミュレータは前回の実行や人の作業で増えていきます。そこで、AI を動かす前に xcrun simctl list -j の結果を調べ、次の 3 つを確かめています。
- 指定した OS のランタイムが入っているか
- 指定した名前の端末があるか
- 同じ名前の端末が 2 台以上ないか
どれかに当たれば「環境の問題」として止め、消すべき端末の ID をエラー文に出します。
3. Xcode は、ビルド番号まで比べる
xcodebuild -version は 2 行を返します。
Xcode 26.6
Build version 17F113
バージョンの数字(1 行目)だけを比べていると、ベータ版を選んだままの Mac を見逃します。ベータ版と正式版は、同じバージョンの数字でビルド番号だけが違うことがあるからです。リポジトリに「使う Xcode のバージョンとビルド番号」を書いたファイルを置き、2 行とも比べています。
もう 1 つ、xcodebuild -version 自体が失敗する場合があります。ライセンスに同意していない、xcode-select が Command Line Tools を指している、などです。これを「バージョンが合わない」と報告すると、人は間違った所を調べます。コマンドの失敗と、値の不一致は、別のエラー文にしています。
4. E2E が、古いビルドを動かしてしまう
E2E では、ビルドでできた .app をシミュレータに入れて動かします。手元では「DerivedData の中で一番新しい .app を探す」ようにしておくと便利です。
CI でこれをやると、危険です。ビルドの置き場所が違っていたり、前回の実行の残りがあったりすると、今回の変更が入っていない .app を見つけて動かします。E2E は通りますが、確かめたのは別のビルドです。
これは「検証の対象が違うのに成功する」という、一番気づきにくい種類の間違いです。CI では自動で探すのをやめ、ビルドの段階で使った置き場所から .app のパスを求めて、E2E に明示して渡しています。パスが渡されなければ、探しに行かずに止まります。
人に戻すときは、「次に何をするか」を書く
分類ができたら、人への通知を種類ごとに変えます。最初は、どの失敗でも同じ文でした。
パイプラインが失敗しました。実行ログ: (URL)
これを、種類ごとに「何が起きたか」と「次に何をするか」を書く形に変えました。
| 種類 | 通知に書くこと |
|---|---|
| 依頼が足りない | 完成の条件が不足しています。条件を足して、もう一度ラベルを付けてください |
| 大きすぎる | 変更が上限を超えました(○ ファイル / ○ 行)。依頼を分けてください |
| 環境の問題 | 環境の不備です。依頼の内容に問題はありません。管理者が対応します |
| 禁止ファイルに触った | 禁止ファイルへの変更がありました。変更は破棄済みです |
| 実装の問題 | ○○ の段階で、上限まで試して通りませんでした |
もう 1 つ、やってよかったことがあります。AI 自身の診断を、通知に載せることです。
試しに動かしたとき、AI は「このテストの失敗は、既存の不安定なテストが原因で、今回の変更では直せない」と正しく診断していました。ところが、その診断はログの奥に埋もれ、人に届いたのは「実装の問題」という分類だけでした。「AI の実装が悪かった」という印象だけが伝わったことになります。
そこで、AI の出力の最後の行に、決まった形で診断を 1 行書かせ、それを通知に転記するようにしました。分類は機械が付け、理由は AI が書く、という分担です。
AI が書いた文を通知に載せるときは、信頼できない入力として扱います。秘密の値に見える文字列を伏せる、メンション(@)が飛ばないようにする、長さを切り詰める、といった処理を挟みます。
事実の失敗と、意見の失敗
最後に、ループに入れなかった失敗の話をします。
ビルド・テスト・E2E を全部通った後に、別の AI に変更をレビューさせています。機械の検証は「壊れていないか」しか見ないので、「頼んだことをやったか」「やりすぎていないか」を見るためです。
このレビューで重大な指摘が出たとき、どうするか。自然に考えると「AI に直させる」です。しかし、今はそうしていません。流れを止めず、PR を下書き(draft)にして、人に見せています。
| 起きたこと | 確かさ | 扱い |
|---|---|---|
| テストが落ちた | 事実 | AI がやり直す |
| AI のレビューが「重大」と言った | 意見 | 下書きの PR にして、人が判断する |
理由は 3 つあります。
- レビューの指摘は、外れることがある。 テストが落ちたのは事実ですが、レビューの「重大」は意見です。外れた指摘に従って AI が直すと、通っていたものを壊します
- 終わりがない。 テストには「通る」という終点がありますが、レビューは毎回違う指摘を出せます。直す → 別の指摘 → 直す、と続く恐れがあります
- 動くものは、もうできている。 ここで止めると、検証を通った成果物が、人の目に触れないまま消えます
「判定できなかった」(レビューが途中で失敗した、時間切れになった)も、同じく下書きにします。ここで大事なのは、「判定できなかった」を「指摘 0 件」と区別することです。件数が取れなかったときに 0 件として扱うと、レビューが動いていないのに「問題なし」として通ってしまいます。
将来は、レビューの指摘もやり直しのループに入れたいと考えています。ただ、その前に、AI の「重大」が人の判断とどれくらい合うかを測る必要があります。合う率が分かるまでは人に渡し、分かってから機械の輪に入れる、という順番です。
まとめ
- 失敗を「次に誰が動くか」で分ける。AI にやり直させる価値があるのは、実装の問題だけ
- 分類は終了コードで返し、直らない失敗はループに入れない。決めていない終了コードは、環境の問題にまとめる
- 上限は 2 種類。同じ段階での回数と、全体の回数
- 終了コードが 0 でも成功とは限らない。未認証の AI、変更前から落ちているテスト、時間切れは、分け間違えやすい
- iOS では、
xcodebuildのパイプ、同じ名前のシミュレータ、Xcode のベータ版、古い.appが、失敗を見えなくしたり、環境の失敗をコードの失敗に見せたりする - 人に戻すときは、次に何をするかと、AI 自身の診断を書く
- 事実の失敗は機械がやり直し、意見の失敗は人に渡す
AI に任せる範囲を広げるほど、「うまくいかなかったとき、どう止まるか」の設計が効いてきます。全部を自動にするより、どこまでを自動にして、どこを人に残すかを決めるほうが、作っていて難しく、面白い所でした。
参考
-
Workflow syntax for GitHub Actions - GitHub Docs(
runの既定のシェル)