AIエージェントとの開発でmainを守る — gh apiでGitHubブランチ保護を掛ける手順と、paths-ignoreの罠

Claude Code

AIエージェント (Claude Code) と一緒に開発していて、こういうことが起きた。

私のプロジェクトの規約には「変更はブランチ+PR単位で回す」と書いてある。エージェントはそれを読んでいる。それでも、ドキュメントだけを直したとき、こう報告してきた。

docsのみの変更なのでCIは走らず、mainに直接pushしてあります。

「docsだけだから」という判断が挟まった。 悪意も手抜きもない。むしろ気を利かせている。CIが走らない変更にPRの手間をかけるのは無駄だ、という判断は、それ自体としては筋が通っている。

問題は、そこに判断の余地があったことである。

規約は破られる。設定は破れない

これは人間でも同じで、「今回は急ぎだから」「1行だけだから」と例外が生まれる。ただAIエージェントは判断が速くて、しかも大量に手を動かすので、例外の発生頻度が桁で上がる。1日に何十回もコミットするなかで1回でも「今回はいいだろう」が挟まれば、mainは汚れる。

だから、規約を強く書き直すのではなく、判断の余地そのものを消すことにした。

私のプロジェクトのCIドキュメントには、以前こう書いていた。

検査を足す前に「その事故は構造で不可能にできないか」を先に問う。

まさにこれの出番だった。「mainに直接pushしない」を守らせるのではなく、pushできなくする

  【指示で守る】                      【構造で防ぐ】
  ┌──────────────┐                  ┌──────────────┐
  │ CLAUDE.md    │                  │ ブランチ保護  │
  │ 「PRを通す」  │                  │  (GitHub側)  │
  └──────┬───────┘                  └──────┬───────┘
         │ 読む                             │ 拒否する
         ▼                                  ▼
  ┌──────────────┐  「docsだけ    ┌──────────────┐
  │ エージェント  │ ─ だからいいか」→│ git push     │ → ✗ rejected
  │ (判断する)   │                  │              │
  └──────────────┘                  └──────────────┘
       判断の余地がある                 判断の余地がない

慣習と空気は、AIには渡らない

人間のチームには、文書になっていない判断がたくさんある。「docsだけの修正でも一応PRにしておく」「リリース直前はmainを触らない」——理屈で言えば無駄が多いが、一貫していること自体に価値があるから続いている。新しく入った人も、数週間いれば空気で分かる。

AIエージェントには、これが渡らない。

誤解されやすいが、AIは空気を読まないのではない。読んだうえで、合理的に判断する。 今回で言えばこうである。

  • 規約には「変更はブランチ+PR単位で回す」とある
  • しかしCIはdocsの変更では走らない設定になっている
  • ならPRを作ってもレビューもCIも動かない。手間だけが残る
  • → 直接pushの方が合理的

この推論は筋が通っている。人間のレビュアーだって、同じ状況で「まあdocsだけだしいいよ」と言う場面は実際にある。局所的には最適なのだ。

問題は、局所最適が積み重なると一貫性が消えることである。「どこまでが例外か」を毎回判断することになり、その判断コストは人間の側が払う。しかも判断の速いAIは、人間が気づく前に何度もそれをやる。

そして厄介なことに、慣習は文書に書かれていない。書かれていないものは、AIにとって存在しない。私の規約には「ブランチ+PR単位で回す」と書いてあったが、「例外を作らない」とは書いていなかった。書いていない以上、例外の余地は開いたままだった。

これはAIの欠陥ではなく、チームの構成が変わったのに運用を変えていなかった側の問題だと思っている。人間同士なら「なんとなく揃える」で保たれていたものが、AIが入った瞬間に根拠を要求される。根拠を示せないなら、明文化するか、設定にするしかない。

そして明文化より設定の方が強い。明文化は判断の材料を増やすだけだが、設定は判断そのものを不要にする。

AIと組むと開発は速くなる。速くなるぶん、曖昧なまま回していた部分が最初に壊れる。ブランチ保護は、その戒めとして掛けている。

掛ける保護の中身

GitHubのブランチ保護で、mainに対して5つ設定した。

項目 設定 何を防ぐか
PR必須 承認0人 PRを経ないpushを拒否する。1人開発なので承認者は要らない
必須ステータスチェック OK (全層) CIが緑でないとマージできない
管理者にも適用 有効 オーナーも例外なし
force push 禁止 履歴の書き換えを防ぐ
ブランチ削除 禁止 mainを消せない

要は管理者にも適用 (enforce_admins) である。ここを外すと、オーナー権限のトークンで素通しできてしまう。エージェントは普通オーナーのトークンで動いているので、ここを有効にしないと何も防げない

承認者を0人にしているのは1人開発だからで、チームなら1以上にすればいい。承認0人でもPRは必須になる — 「PRを作る」という手順自体が強制される点が重要で、差分がPRの画面に残り、CIが走り、後から辿れる。

手順 (gh api)

Web UIでもできるが、設定を再現できる形にしたいのでCLIで掛ける。

cat > /tmp/protection.json <<'JSON'
{
  "required_status_checks": {
    "strict": false,
    "contexts": ["OK (全層)"]
  },
  "enforce_admins": true,
  "required_pull_request_reviews": {
    "required_approving_review_count": 0,
    "dismiss_stale_reviews": false,
    "require_code_owner_reviews": false
  },
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false
}
JSON

gh api -X PUT repos/<owner>/<repo>/branches/main/protection --input /tmp/protection.json

いくつか注意点がある。

  • restrictionsnull が必須 (省略できない)。組織リポジトリでなければ null を明示する
  • strict: false にしている。true は「mainが進んだらブランチを最新にしないとマージ不可」で、1人開発だとPRごとに更新作業が増えるだけになる
  • contexts の名前はCIの表示名と完全一致させる。私の場合は日本語で OK (全層) という合流ジョブを置いているので、その名前をそのまま書く

落とし穴: paths-ignore と必須チェックの組み合わせ

ここが本題かもしれない。この2つを何も考えずに組み合わせると、PRがマージ不能になる。

私のCIは、ドキュメントだけの変更で重い検査を回さないよう、こう書いてあった。

on:
  pull_request:
    paths-ignore:
      - 'docs/**'
      - '**.md'

この状態で OK (全層) を必須チェックにするとどうなるか。

  docsだけのPR
       │
       ▼
  paths-ignore に当たる → ワークフローが起動しない
       │
       ▼
  「OK (全層)」は success でも failure でもなく **存在しない**
       │
       ▼
  GitHub「必須チェックが完了していません」→ **永久にマージできない**

skipped なら成功扱いにもできるが、そもそも走っていないので判定のしようがない。GitHubはこれを「まだ来ていない」として待ち続ける。

直し方: 止めるのをやめて、中で絞る

paths-ignore を外す。そのうえで、重い検査だけをジョブの側で落とす。

on:
  # paths-ignore は使わない。docsだけの変更でワークフローごと止めると、
  # 必須チェックが永久にpendingのまま残る (GitHubの定番の罠)
  pull_request:
  push:
    branches: [main]

絞るのは中で行う。私の場合は dorny/paths-filter で「CPUに関わるファイルが変わったか」を見て、重い検査 (エミュレータのCPU命令をUnicornと突き合わせるジョブなど) をスキップしている。

  cosim:
    name: 2 CPU層 — CPU照合
    if: github.event_name != 'pull_request' || needs.check.outputs.cpu == 'true'

そして合流ジョブは skipped を成功として数える

  ok:
    name: OK (全層)
    if: always()
    needs: [check, cosim, compat386, regress, wasmboot, snapshot, network]
    steps:
      - run: |
          test "${{ needs.check.result }}" = "success" \
            && { test "${{ needs.cosim.result }}" = "success" \
                 || test "${{ needs.cosim.result }}" = "skipped"; } \
            && ...

これで、

PRの中身 走るもの OK (全層)
コード 全層 (CPU照合・OS起動回帰・E2Eまで)
docsだけ 軽い土台のジョブ1つ

となり、「必ず結果が出る」ことと「重い検査を払わない」が両立する。必須チェックにするジョブは、どんな変更でも必ず走らせて、中で判断するのが原則である。

効いていることを確かめる

設定して満足してはいけない。実際に破ろうとして、拒否されることを見る。

git commit --allow-empty -m "保護の実験 (このコミットはpushできないはず)"
git push origin main

結果:

remote: error: GH006: Protected branch update failed for refs/heads/main.
remote:
remote: - Changes must be made through a pull request.
remote:
remote: - Required status check "OK (全層)" is expected.
To https://github.com/…/rustx86.git
 ! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs

蹴られた。エージェントのトークンでも、オーナーの手でも同じである。確認したら実験用のコミットは捨てる。

git reset --hard origin/main

緊急時に外すには

塞いだものは、外し方も書いておかないと運用が詰まる。

# 外す (理由を記録に残すこと)
gh api -X DELETE repos/<owner>/<repo>/branches/main/protection

# 現状を見る
gh api repos/<owner>/<repo>/branches/main/protection

私はこのコマンドをリポジトリのドキュメントに書いて、「外したことと理由を残す」を規約にした。外せないようにするのではなく、外したら目立つようにするのが落としどころだと思っている。

まとめ

AIエージェントとの開発では、規約を丁寧に書くほど「読んでくれるはず」という期待が生まれる。実際よく読んでくれるのだが、読んで理解したうえで、合理的に例外を作ることがある。今回がまさにそれだった。

  • 慣習や空気はAIに渡らない。 書かれていないものは存在しない。明文化するか、設定にする
  • 判断の余地がある限り、いつか判断される。 しかもその判断はたいてい局所的には正しい
  • enforce_admins を有効にしないと意味がない — エージェントはオーナー権限で動いている
  • paths-ignore と必須チェックは相性が悪い。 止めるのをやめて、中で絞る
  • 設定したら、実際に破ろうとして拒否を確認する

AIと人間のチームプレイだからこそ、設定が要る。人間だけのチームなら曖昧なまま回せていたものが、速度と手数が上がった途端に曖昧では回らなくなる。ルールを増やすのではなく、判断させない形に変えるのが、たぶん正しい向きの努力である。

余談だが、この記事の元になった一連の作業 (CIの改修・保護の設定・その確認) も、全部PRを通してマージした。保護を掛けた後は、自分で作ったルールに自分が縛られる。 それでいい。


題材にしたプロジェクト: rustx86 (Rust + WebAssemblyのx86エミュレータ)

続き: 新規リポジトリをゼロから固める通し手順(CIを先に作る順番、リリース自動化、そして私が enforce_admins: false で実際に素通しさせた話)は ghコマンドでGoリポジトリを固める通し手順 に書いた。

コメント

タイトルとURLをコピーしました