お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」では、GitHub ActionsのCIを、自前のセルフホストランナー(4コア・8GB)で動かしています。2026年10月4日から6日にかけて、変更したファイルの種類に応じてWebのビルドやE2E、バックエンドのテストを省略する判定を広げました。今回は、省略する範囲をどう決め、省略してはいけない変更をどう取りこぼさないようにしたかを書きます。
スクリプトを1本直しただけで、WebビルドとE2Eが走っていた
DevPickのCIには、以前から「Webに関係ない変更なら、Webのビルド(build-frontend)とE2Eを飛ばす」判定がありました。モバイルアプリのコード、設計文書、Claude Codeのスキル定義、バックエンドのテストコードだけを変えたPRでは、Webの画面を作り直してブラウザで確かめても結果は変わらないからです。
ところが、この判定は scripts/ 配下のファイルを分類していませんでした。scripts/ にはデプロイやビルドに使うスクリプトと、AIエージェントが手元で使う診断・集計用のツールが同居しています。区別がないので、どれを変えても「Webに影響しうる変更」として扱われていました。
きっかけになったのは、CIの失敗ログを要約する診断ツールを追加したPRです。変えたのは診断スクリプト本体、そのテスト、運用文書、エージェント用のスキル定義だけでした。それでもこのPRのCIでは、次のジョブがフルで走っていました。
| ジョブ | 実行時間 |
|---|---|
| build-frontend | 75秒 |
| E2E(1つ目のシャード) | 160秒 |
| E2E(2つ目のシャード) | 157秒 |
診断ツールはWebの画面から一切呼ばれません。このPRでWebのビルドとE2Eが失敗する可能性は、実質的にありませんでした。
同じ形の無駄は、エージェント用ツール以外でもありました。CIの判定スクリプトやAIレビュー用のスクリプトも、Web用のジョブからは呼ばれていません。それでも scripts/ 配下なので、Web扱いになっていました。10月6日の時点で直近にマージした60件のPRを分類し直すと、そのうち6件(10%)は、Web扱いになった理由がこの種のスクリプトだけでした。
build-frontendとE2Eは直列につながっています。直近の成功したCIの中央値で見ると、build-frontendが119秒、E2Eの各シャードが178秒でした。1件のPRの待ち時間に約5分が上乗せされ、ランナーの占有時間は合計で約8分になる計算です。
IT女子 アラ美省略の判定は「全部が許可リスト内なら省略」、迷ったら走らせる
変更ファイルでジョブを省略する方法は、大きく分けて2通りあります。
- 除外リスト方式:「この変更があったら走らせる」を列挙し、当てはまらなければ省略する
- 許可リスト方式:「この変更だけなら省略してよい」を列挙し、1件でも外れたら走らせる
GitHub Actions標準の paths フィルターは前者、paths-ignore は後者に近い書き方です。ただ、どちらもワークフロー全体を起動するかどうかの指定なので、ジョブごとには使い分けられません。DevPickでは判定スクリプトの出力をジョブの実行条件に渡しており、判定は許可リスト方式です。新しいディレクトリやファイルが増えたとき、除外リスト方式では書き足し忘れが「省略」の側に倒れます。許可リスト方式なら「実行」の側に倒れます。省略しすぎて壊れた変更が本番へ出るより、余計に走らせて数分待つほうがましなので、許可リスト方式を選んでいます。
判定スクリプトの骨格は、単純化すると次のとおりです。
# 単純化した例
if ! detect_changed_files "$changed_files"; then
output_result "true" "差分特定不可のため安全側に倒してWebテストを実行"
exit 0
fi
total_count=$(grep -c . "$changed_files" || true)
if [[ "$total_count" -eq 0 ]]; then
output_result "true" "変更ファイルが0件のため安全側に倒してWebテストを実行"
exit 0
fi
# 許可リストに当てはまらないファイルが1件でもあればフル実行
web_changes=$(grep -v -E "$ALLOWLIST_PATTERN" "$changed_files" || true)
if [[ -n "$web_changes" ]]; then
output_result "true" "Web成果物・挙動に影響しうる変更があります"
else
output_result "false" "変更はすべて許可リスト内のためWebビルド・E2Eをスキップします"
fi
ポイントは、省略になる出口が最後の1か所しかないことです。差分を取れなかったとき、変更が0件のとき、許可リスト外のファイルが混ざったときは、どれも「走らせる」に倒れます。こういう、判定に迷ったら安全側(実行する側)に倒す作りをfail-closedと呼びます。今回はこの骨格は変えず、許可リストの中身を増やす形で省略範囲を広げました。



scripts/ を丸ごと許可しなかった理由
一番手軽なのは、許可リストに scripts/ 配下を丸ごと足すことです。しかしDevPickの scripts/ には、Web用のジョブが実際に呼ぶものも並んでいます。デプロイ本体の deploy.sh、ビルドキャッシュを扱う ci-build-cache.sh、依存関係を復元する ci-dependency-cache.sh などです。これらを変えたPRでWebのビルドとE2Eを飛ばすと、壊れたデプロイ手順がそのまま本番へ出かねません。
さらに scripts/ 直下には、CIの実行側が読み込む共有ライブラリ(レビュー結果の集計やロックファイルを扱うものなど)も置かれていました。ディレクトリ単位で「エージェント用の置き場」と決めつけると、こうしたファイルまで巻き込みます。
そこで、省略してよいスクリプトはファイル名を1本ずつ書き出しました。最初の対応では、エージェントが手元で使う診断・集計・監視・検証のツールを11本並べています。
AGENT_SCRIPTS=(
agent-usage-report.py
ci-failure-diagnose.py
ci-queue-report.py
poll-ci-status.py
selfreview-cache.py
# ...ほか6本
)
# ファイル名を正規表現の選択肢にして、scripts/ 直下の完全一致だけを許可する
allowlist_scripts_pattern=$(printf '%s|' "${AGENT_SCRIPTS[@]}")
allowlist_scripts_pattern="${allowlist_scripts_pattern%|}"
allowlist_scripts_pattern="${allowlist_scripts_pattern//./\\.}"
ALLOWLIST_PATTERN="^(mobile/|docs/|backend/tests/|scripts/(${allowlist_scripts_pattern})\$)"
(実際の許可リストには、ほかにも .claude/ などのディレクトリが含まれます。上の例では一部を省いています。)
正規表現は scripts/ 直下の完全一致にしてあります。名前が同じでも、scripts/lib/ のような別のディレクトリにあるファイルや拡張子違いのファイルは、省略の対象になりません。少し後に、別の判定(負荷試験を省略するかどうか)にも同じ考え方で1本を足しています。そのときも、同名の別ディレクトリ・別拡張子のファイルが省略対象にならないことをテストで確かめました。
ワイルドカードを使わないので、新しいツールを足すたびに一覧の更新が要ります。手間は増えますが、更新を忘れたときに起きるのは「余計に走る」だけです。省略しすぎる方向には転びません。



「参照されていない」をgit grepのテストで固定した
ファイル単位で列挙しても、まだ穴は残ります。今日は誰からも呼ばれていないツールが、来月にはデプロイスクリプトから呼ばれるようになるかもしれません。そうなると、そのツールの変更はWebのビルドやデプロイに影響します。それなのに、許可リストに載っている限り省略され続けます。
手で列挙した一覧が実態とずれていく問題は、以前プロフィール削除で依存レコードの手動列挙が漏れた話でも踏んでいます。今回は、一覧に載せる前提である「実行側から参照されていない」を、テストで機械的に確かめることにしました。許可リストに載っている各ファイルについて、名前で git grep をかけます。実行側のコードから1件でも見つかったら、テストを失敗させます。
# 単純化した例
@pytest.mark.parametrize("name", _agent_scripts())
def test_agent_scripts_are_not_referenced_by_execution_side_code(name):
# 自分自身と、スクリプトを実行しない領域(docs・mobile・backend/tests 等)は検索対象から外す
pathspecs = [f":!scripts/{name}"] + _NON_EXECUTION_PATHSPECS
stem = name.rsplit(".", 1)[0]
# ファイル名と、importされる場合のモジュール名の両方で探す
for pattern in {name, stem.replace("-", "_")}:
res = subprocess.run(
["git", "grep", "-l", "-F", pattern, "--", ".", *pathspecs],
cwd=REPO_ROOT, capture_output=True, text=True,
)
assert res.returncode in (0, 1), res.stderr
assert res.stdout.strip() == "", f"{name} が実行側コードから参照されています"
git grep は、見つかれば終了コード0、見つからなければ1を返します。それ以外はエラーです。エラーを「見つからなかった」と取り違えないよう、0と1以外は失敗にしています。あわせて、一覧の各要素が scripts/ 直下に実在するファイル名であること、ワイルドカードや重複を含まないことも別のテストで確かめています。
「何から参照されていないか」は、省略するジョブで決まる
2日後、CIの判定スクリプトとAIレビュー用のスクリプトも許可リストに加えました。このとき、最初の対応で除外していた共有ライブラリの一部が対象に入っています。たとえばレビューの集計やロックファイルを扱うライブラリです。
方針を変えたわけではありません。最初のエージェント用ツールでは、「CIの実行側のどこからも参照されていない」ことを条件にしていました。後から足したスクリプトは、lintやAIレビュー、バックエンドのテストのジョブでは実行されます。ただし、Web用のジョブ(build-frontend・E2E・deploy)からは呼ばれません。省略したいのはWeb用のジョブなので、確かめるべきは「Web用のジョブから参照されていないか」です。
そのため、後から足した一覧のテストでは、検索範囲を次の3つに絞っています。
frontend/配下の全ファイル- ワークフロー定義のうち、build-frontend・E2E・deployの各ジョブの部分
- それらのジョブが呼ぶスクリプト(デプロイ、ビルドキャッシュ、依存関係の復元など)のコード行
これらのスクリプト自体が壊れていないかは、ビルドやE2Eでは確かめられません。確かめているのは、バックエンドのテストとAIレビューです。そちらは省略されないので、検証が抜けることはありません。一方で、Webを省略するかどうかを判定するスクリプト自身や、ワークフロー定義は一覧に入れていません。これらを変えたPRでは、従来どおりWebのビルドとE2Eが走ります。



backendテストは全部省略せず、frontendを読むテストだけ残した
逆向きの無駄もありました。Webの画面だけを変えたPRでも、バックエンドのテストが全件走っていたのです。当時のテストは約3,800件で、トップページの描画を変えただけのPRでもテストのジョブに129秒かかっていました。遅い回では458秒かかっています。
ここで「Webだけの変更ならバックエンドのテストは丸ごと省略」とはできませんでした。バックエンドのテストの中に、frontend/ のソースを読んでバックエンド側の設定と突き合わせるテストがあるからです。たとえば、フロントエンドが公開ページの描画に許している1分あたりのアクセス回数より、バックエンドがその描画用APIに掛けている上限が小さくなっていないかを確かめるテストです。フロントエンド側の上限値はソースファイルから直接読み取っています。これを省くと、フロントエンド側の数字を変えたときの食い違いを見逃します。
そこで、Web本体(frontend/src・frontend/public・frontend/e2e)だけの変更では、frontend/ を読む整合性テストだけを実行するようにしました。対象のテストファイルは一覧ファイルに1行ずつ書いてあり、この時点では2ファイルでした。
一覧への書き足し忘れを、ファイルを開いた瞬間に捕まえる
ここでも、手で書いた一覧が実態とずれる問題が出ます。新しく書いたテストが frontend/ を読むようになったのに、一覧に足し忘れたとします。すると、Webだけの変更ではそのテストが走らず、食い違いを見逃します。
この足し忘れは、Pythonの監査フック(sys.addaudithook)で検出しています。監査フックを登録すると、プロセス内でファイルが開かれるたびに呼ばれる関数を差し込めます。テスト中に frontend/ 配下のファイルが読み込み目的で開かれたら、それがどのテストかを記録します。そのテストファイルが一覧に無ければ、テストを失敗させます。
# 単純化した例
def _audit(event, args):
if event != "open" or not args:
return
mode = args[1] if len(args) > 1 else None
# 書き込み目的のopenは「読んで突き合わせる」テストではないので対象外
if isinstance(mode, str) and any(flag in mode for flag in "wax+"):
return
target = args[0]
if isinstance(target, str) and target.startswith(FRONTEND_DIR):
_reads.append(target)
sys.addaudithook(_audit)
@pytest.fixture(autouse=True)
def _frontend_reads_are_listed(request):
_reads.clear()
yield
relative = ... # テストファイルのパス
if _reads and relative not in load_manifest():
pytest.fail(f"{relative} が frontend/ を読んでいます。一覧に追加してください")
この検査は全件実行のときに効きます。バックエンドを変えたPRのように全件が走る実行で、足し忘れたテストが落ちます。同じ変更の中で、テストモジュールの読み込み時(テスト関数が動く前)に frontend/ を読むケースも検出対象に加えました。
ただし、監視できるのはテストのプロセス自身が開いたファイルだけです。テストから起動した子プロセス、たとえば scripts/ 配下のスクリプトが読むファイルは見えません。この限界はコード中のコメントと運用文書に書き、そうしたテストは書いた人が一覧へ足す運用にしています。
絞り込みが効くのは、全件で通った基準からの差分がWebだけのとき
PRの差分がWebだけでも、PRを作った後にmainへ別のバックエンド変更が入っていることがあります。そのため、絞り込むかどうかはPRの差分だけでは決めていません。PRをmainにマージした結果のツリーと、バックエンドのテストが全件で通ったツリーとの差分を取り、それもWebだけだと確認できたときだけ絞ります。差分を取れない、記録が無い、知らないパスがある、といった場合はすべて全件に戻ります。
絞り込んで通った結果は「全件で通った」とは区別して記録し、次の比較の基準には使いません。
効果は、この変更を入れた時点で手元で同じ条件で比べています。全件が3,778件で約110秒、絞り込みが77件で約17秒でした(どちらも4ワーカー)。見逃しがないかも確かめています。フロントエンド側のアクセス回数の上限値をわざと書き換えると、絞り込んだ77件の中で、先ほどの突き合わせテストが失敗しました。



設計文書が1つ混ざるだけで全件に戻っていた
絞り込みを入れた直後、Webの画面を変えたPRが2件ありました。CIを見ると、どちらもバックエンドのテストは全件で走っていました。
| PR | Web以外の変更 | バックエンドのテスト |
|---|---|---|
| サービス紹介ページの料金案内の修正 | 要件の文書、画面遷移の文書 | 3,907件・110.70秒 |
| 検索結果に出す画像プレビューの設定 | 要件の文書 | 3,986件・104.59秒 |
原因は、PRに設計文書が含まれていたことです。DevPickでは、利用者から見た振る舞いを変えたら要件の文書(docs/requirements.md)や画面遷移の文書(docs/screen-transition.md)も同じPRで更新する決まりにしています。絞り込みの条件は frontend/ の3ディレクトリだけだったので、文書が1つ混ざった時点で「知らない変更あり」として全件に戻っていました。fail-closedとしては正しい動きです。ただ、普段のPRの形に合っていないと、絞り込みはほとんど効きません。
モバイルアプリの変更でも同じことが起きていました。モバイルのコード、この2つの設計文書、更新履歴のデータだけを変えたPRで、バックエンドのテストが3,967件・126.69秒走っていました。
ここでも docs/ 配下をまとめて許可することはしていません。DevPickには、設計文書を読んで実装との食い違いを確かめるテストもあるからです。翌日の対応で、許可する文書をこの2ファイルに限ってファイル単位で列挙しました。そのうえで、これらの文書を読むテストと、変更を分類するスクリプト自身のテストを、絞り込み時に実行するテストの一覧へ加えています。
対応後、モバイルのマイページを変えたPR(モバイルのコードと、この2つの設計文書)では、バックエンドのテストが絞り込まれました。実行したのは7つのテストファイル、382件で、時間は15.62秒でした。テストのジョブ全体でも約30秒です。



導入後のCI
エージェント用ツールだけを変えたPRで、導入前と導入後のCIを並べます。導入前は、最初に書いたCI診断ツールを追加したPRです。導入後の2件は、CIの状態を待つツールの修正と、エージェントの利用量を集計するツールの修正です。どちらも変更はスクリプト本体とそのテスト、文書だけでした(2件目はスキル定義も含みます)。
| ジョブ | 導入前 | 導入後(1件目) | 導入後(2件目) |
|---|---|---|---|
| lint | 実行 | 実行 | 実行 |
| AIレビュー | 実行 | 実行 | 実行 |
| バックエンドのテスト | 実行 | 実行 | 実行 |
| build-frontend | 実行(75秒) | 省略 | 省略 |
| E2E(2シャード) | 実行(160秒・157秒) | 省略 | 省略 |
| CI全体(開始から完了まで) | 8分18秒 | 4分45秒 | 4分59秒 |
狙いどおり、build-frontendとE2Eだけが省略されています。スクリプトの回帰を確かめるバックエンドのテストとAIレビューは、導入後も走っています。
ただし、CI全体の時間は同じ条件での比較ではありません。PRの中身が違うので、AIレビューやバックエンドのテストにかかる時間も回ごとに違います。同じ時期にほかのCI高速化も入れていたので、この差がすべて今回の省略の効果だとは言えません。言えるのは、導入前の回でbuild-frontendに75秒、E2Eに約160秒(2シャード並列)かかっていたジョブが、丸ごと走らなくなったというところまでです。ランナーの占有で見れば、3ジョブ合わせて約6分半の分です。
CIの待ち時間の削減は、以前レビューで落ちるPRにテストもE2Eも流していたCIを組み替えた話で、ジョブの実行順を見直しました。今回はその続きで、「そもそもこの変更でこのジョブは要るのか」を、ファイルの種類ごとに詰めた形です。



まとめ
変更したファイルでCIのジョブを省略する仕組みを広げてみて、大事だったのは省略を増やすことそのものより、省略の前提が崩れたときに気づける形にすることでした。今回の判断を整理すると、次のようになります。
- 判定は許可リスト方式・fail-closedのままにする:省略になる出口は「全部が許可リスト内」の1か所だけにし、差分が取れない・0件・知らないファイルは全部走らせる
- 許可はファイル単位で書く:
scripts/やdocs/の配下をまとめて許可せず、1本ずつ列挙する。書き忘れたときは「余計に走る」側に倒れる - 「参照されていない」をテストで固定する:
git grepで実行側からの参照を探し、見つかったらテストを落とす。どこを探すかは、省略するジョブから決める - 丸ごと省略できないテストは、必要な分だけ残す:
frontend/を読むバックエンドのテストは残し、一覧への書き足し忘れはファイルを開いた時点で監査フックが検出する - 実際のPRの形を見て条件を直す:設計文書が必ず混ざる運用なら、そのファイルも列挙に入れないと絞り込みは効かない
一覧を手で書く以上、いつかずれます。ずれたときに省略が静かに続くのではなく、テストが落ちるかフル実行に戻るかのどちらかになるようにしておきます。今はそれが、個人開発のCIで省略を増やすときの最低ラインだと考えています。












