はじめに
AI エージェントに自分の admin 権限で git push させていて、main への直 push や force push が怖い方へ。
僕は個人 org 相当のアカウントで、OSS を中心に 30 本強の公開リポジトリを持っています。しかも日々の実装の一部を AI エージェントに任せていて、そのエージェントは僕の権限(= owner / admin)で git push します。
この構成で一番怖いのは「admin だから何でも通る」ことです。うっかり main に直 push する、force push で履歴を潰す、ブランチを消す — これらは全部、権限が強いほど静かに起こります。人間の規律で防ぐには数が多すぎるし、そもそもエージェントに「規律を持て」とは言えません。
そこで、公開リポジトリ全部に同じ branch protection を機械的に載せることにしました。この記事は、それを gh api で一括適用したときの手順を再現できるコマンドに整理し直したものと、詰まった点の記録です。派手な話ではありません。「31 本に同じ設定を確実に配って、あとから drift を検出できる状態にする」だけの、地味な横断作業の実録です。
この記事で分かること:
- branch protection が既定で admin をすり抜ける事実と、
enforce_admins: trueが唯一まともに効くガードレールである理由 -
gh apiで public 非アーカイブの全リポに固定ペイロードを一括適用する実コマンド(PUT の全キー必須・default branch 可変の罠つき) - 配ったあと「31 本ぜんぶ同じはず」の drift を継続検出する照合スクリプト
何を「保護」と定義したか
先に、今回すべてのリポジトリへ配った保護のかたちを出します。
| ルール | 値 | 意図 |
|---|---|---|
| PR 必須 | 有効(承認 0 件) |
main 直 push を禁止。ただし一人 + エージェント運用なので承認者は求めない |
enforce_admins |
有効 | admin(=僕とエージェント)にも上のルールを効かせる。ここが本体 |
| force push | 禁止 | 履歴の破壊的な書き換えを止める |
| ブランチ削除 | 禁止 |
main を消せなくする |
| 必須ステータスチェック | 付けない | CI 構成がリポごとに違うので一律には強制しない(後述) |
ポイントは「承認 0 件の PR 必須」です。矛盾して見えますが、狙いは「レビュー強制」ではなく「main に直接コミットが積まれる経路を塞ぐ」ことです。変更は必ず PR を経由する、というワークフローだけを強制し、承認そのものは求めません。一人開発 + エージェントで承認者を要求すると自分の PR を自分でマージできなくなるだけなので、そこは割り切ります。
対象を数える
「全公開リポ」と言っても、アーカイブ済みのものまで触ると事故のもとです。対象は public かつ非アーカイブに限定しました。
# 対象リポジトリ名を配列に取る(public・非アーカイブのみ)
mapfile -t REPOS < <(
gh repo list "$OWNER" --limit 100 \
--json name,visibility,isArchived \
--jq '.[] | select(.visibility=="PUBLIC" and .isArchived==false) | .name'
)
echo "対象: ${#REPOS[@]} 本"
このとき僕のアカウントは全 52 リポジトリで、public 非アーカイブは 31 本でした。この「31」は上の select の結果そのままで、感覚値ではありません。母数を毎回コマンドで数え直すのが横断作業の第一歩です(アーカイブや新規追加で普通に変わるので)。
gh api で一括適用する
branch protection の設定は REST の PUT /repos/{owner}/{repo}/branches/{branch}/protection です。まず、配るペイロードを 1 枚の JSON として固定します。
{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 0
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
これを全リポのデフォルトブランチへ適用します。
OWNER="<自分の owner 名>"
PAYLOAD="$(cat protection.json)"
for repo in "${REPOS[@]}"; do
# デフォルトブランチを取り違えない(main とは限らない)
branch="$(gh repo view "$OWNER/$repo" --json defaultBranchRef \
--jq '.defaultBranchRef.name')"
echo "==> $repo ($branch)"
echo "$PAYLOAD" | gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"repos/$OWNER/$repo/branches/$branch/protection" \
--input - >/dev/null \
&& echo " ok" \
|| echo " FAILED"
done
--input - で標準入力から JSON ボディを渡せるので、ペイロードを 1 箇所に固定したまま全リポへ回せます。
詰まった点 1: PUT は「全キー必須」
この API の PUT は PATCH ではありません。トップレベルの required_status_checks / enforce_admins / required_pull_request_reviews / restrictions は、使わなくても明示的に null を渡す必要があります。省くと 422 Invalid request で弾かれます。
「ステータスチェックは要らない」を表現するのが「キーを書かない」ではなく「"required_status_checks": null」である、という点に最初ハマりました。ペイロードを JSON ファイルに固定した理由の半分はこれで、キーの取りこぼしを防ぐためです。
詰まった点 2: デフォルトブランチは main とは限らない
branches/main/protection を決め打ちすると、master や別名のリポで 404 になります。上のループでは毎回 defaultBranchRef.name を引いてからパスを組み立てています。横断スクリプトでブランチ名をハードコードしない、は鉄則です。
なぜ enforce_admins が本体なのか
このペイロードで一番効いているのは enforce_admins: true の 1 行です。
branch protection は既定では admin をすり抜けさせます。「PR 必須・force push 禁止」と設定しても、admin 権限のトークンからは平然と直 push できてしまう。つまり enforce_admins を有効にしない branch protection は、admin にとっては存在しないのと同じです。
僕の運用ではコミットする主体(僕自身も、エージェントも)が admin なので、この 1 行を落とすと保護は完全に空文になります。「AI エージェントに owner 権限を渡す」構成では、enforce_admins こそが唯一まともに効くガードレールだと言っていいです。適用後、実際に効いているかは読み戻して確認できます。
gh api "repos/$OWNER/$repo/branches/$branch/protection" \
--jq '.enforce_admins.enabled'
# => true
required_status_checks を一律には入れなかった理由
保護のかたちからあえて外したのが「必須ステータスチェック」です。
理由は単純で、リポジトリごとに CI のジョブ名が違うからです。あるリポは Composer Check / npm Check、別のリポは別名、CI をまだ持たないリポもあります。存在しないチェック名を required に指定すると、そのチェックは永遠に「pending」のままになり、PR がマージ不能で凍結します。
「全リポに同じ 1 枚を配る」という横断アプローチと、「チェック名はリポ固有」という現実は相性が悪い。なので必須チェックは一律適用の対象から外し、各リポの CI が安定してからリポ単位で足す方針にしました。一括で配るのは「経路の強制(PR 必須・admin にも効く・破壊禁止)」まで、と割り切ったわけです。
台帳化して drift を検出する
一度配って終わり、にすると必ずズレます。新規リポの追加、手動での一時解除の戻し忘れ、GitHub UI での設定変更 — 「31 本ぜんぶ同じはず」は、放っておくと崩れます。
そこで、期待するかたちに一致しているかを読み戻して照合できるようにしました。
for repo in "${REPOS[@]}"; do
branch="$(gh repo view "$OWNER/$repo" --json defaultBranchRef \
--jq '.defaultBranchRef.name')"
gh api "repos/$OWNER/$repo/branches/$branch/protection" \
--jq '[
"\(.enforce_admins.enabled)",
"\(.required_pull_request_reviews.required_approving_review_count)",
"\(.allow_force_pushes.enabled)",
"\(.allow_deletions.enabled)"
] | join(" ")' 2>/dev/null \
| { read -r line
# 期待値: enforce_admins=true / 承認0 / force_push=false / deletion=false
if [ "$line" = "true 0 false false" ]; then
echo "OK $repo"
else
echo "DRIFT $repo : $line"
fi
}
done
保護そのものが未設定なら gh api が 404 を返すので、その行は空になって DRIFT に落ちます。これで「保護が外れているリポ」と「値がズレたリポ」を同じループで拾えます。ガバナンスは「一度きちんと設定すること」ではなく「ズレを継続的に検出できること」だと考えているので、配る手順と照合する手順はセットで持つようにしています。
まとめ
| やったこと | 手段 |
|---|---|
| 対象を数える |
gh repo list --json visibility,isArchived で public 非アーカイブに限定(31 本) |
| 保護を配る |
PUT branches/{branch}/protection に固定ペイロードを --input - で流す |
| admin にも効かせる |
enforce_admins: true(これが本体) |
| ブランチ名の事故を防ぐ |
defaultBranchRef.name を毎回引く |
| PUT の罠を避ける | 使わないトップレベルキーも null を明示 |
| ズレを検出する | 読み戻して期待値と照合、不一致を DRIFT 表示 |
派手さはありませんが、「AI エージェントが自分の admin 権限で push する」構成では、規律ではなく設定で main を守るしかありません。そして守り方の核心は、承認フローでも必須チェックでもなく、enforce_admins の 1 行に admin 自身を含めることでした。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp