Claude Codeでブランチ保護をgh apiで棚卸しする手順 — 必須チェック名のずれ探し
複数リポジトリのブランチ保護を読み取り専用のgh apiで一覧し、必須ステータスチェックの名前と実際のcheck run名を突き合わせる手順と、PUTを通さない権限の切り方をまとめます。
ブランチ保護の設定は、リポジトリが増えるほど「どこに何が入っているか」が分からなくなります。必須ステータスチェックに古い名前が残ると、PRはチェックの報告を待ち続けます。この記事では、Claude Codeに読み取り専用のgh apiだけを許可し、複数リポジトリの保護ルールを一覧して名前のずれを探す手順を書きます。
先に結論です。保護ルールの取得はGETだけで済みます。更新系のPUT・PATCH・DELETEは、denyルールと権限の弱いトークンの二重で止めます。
棚卸しに使うエンドポイントは3つだけ
ブランチ保護のREST APIは、取得・更新・削除が同じパスに並んでいます。棚卸しで叩くのは取得だけです。
棚卸しで使うGETエンドポイント
保護ルール全体
GET /repos/{owner}/{repo}/branches/{branch}/protection。必須チェック、管理者への強制、プッシュ制限などをまとめて返します。必須チェックだけ
GET /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks。strict、contexts、checksの3つが主な項目です。実際のcheck run
GET /repos/{owner}/{repo}/commits/{ref}/check-runs。ブランチ名・タグ名・SHAをrefに渡せます。
必須チェックの応答には、同じ内容を表す項目が2つあります。contextsはチェック名の文字列配列で、更新APIの説明では「closing down notice」が付いています。checksはcontextとapp_idの組の配列で、app_idは整数かnullです。棚卸しでは新しい形式のchecksを読みます。
取得系のステータスコードは、保護ルールの取得も必須チェックの取得も200と404の2つしか載っていません。404は「保護がない」場合も「見えない」場合もあるので、一覧表には「保護なし」とは書かず「取得不可」の列を分けておくと後で迷いません。
プランも404の原因になります。取得APIの説明では、保護ブランチが使えるのは、GitHub Freeと組織向けGitHub Freeでは公開リポジトリのみ、GitHub Pro・GitHub Team・GitHub Enterprise Cloudでは公開と非公開の両方です。GitHub Freeの非公開リポジトリは、そもそも保護を設定できる土俵に乗っていません。個人アカウントの非公開リポジトリが混ざったrepos.txtで404が続くときは、権限より先にプランを疑うと切り分けが早く済みます。
読み取りだけを許可する権限の切り方
gh apiは、既定ではGETで動きます。ところが-fや-Fでパラメータを足すと、メソッドが自動でPOSTに切り替わります。--methodで上書きもできます。つまり「gh apiを許可する」だけでは、書き込みも通ってしまう余地があります。
Claude Codeの組み込みの読み取り専用コマンド集合はlsやcat、grep、読み取り形のgitなどで、ghは入っていません。許可しなければ毎回確認が出ます。次の設定は、保護関連のパスだけ許可し、書き込みに使うフラグをdenyで塞ぐ例です。
{
"permissions": {
"allow": [
"Bash(gh api repos/*/branches/*/protection*)",
"Bash(gh api repos/*/commits/*/check-runs*)"
],
"deny": [
"Bash(gh api *-X*)",
"Bash(gh api *--method*)",
"Bash(gh api *-f *)",
"Bash(gh api *-F *)",
"Bash(gh api *--field*)",
"Bash(gh api *--raw-field*)",
"Bash(gh api *--input*)"
]
}
}ルールはdeny、ask、allowの順に評価され、最初に一致したものが結果を決めます。allowがprotection*で末尾まで開いていても、-X PUTを含む呼び出しはdenyが先に拾います。クエリは-fではなくURLの?per_page=100で渡すので、読み取りの用途ではdenyのフラグを使いません。
このdenyだけでは足りない理由
公式の説明では、BashルールはClaudeが書いたコマンド文字列にだけ一致します。curl *をdenyしても/usr/bin/curlやsh -c '...'は止まらない、という表が載っています。同じ理屈で、gh apiのルールも/opt/homebrew/bin/gh apiのようなパス指定やsh -c経由は別物です。
もう一つの守りは、ghが使うトークン側です。更新系のAPIには「adminまたはownerの権限が必要」と書かれています。棚卸し専用のアカウントやトークンを用意し、リポジトリの管理権限を持たせなければ、denyをすり抜けても更新は通りません。取得系が管理権限なしで200を返すかは、手元のリポジトリで一度試して確かめてください。権限が足りないリポジトリは404になり、前節の「取得不可」の列に入ります。
ファイルシステムとネットワークをコマンド文字列に頼らず縛りたいときは、サンドボックスの出番です。ネットワーク側の設定はsandbox.network.tlsTerminateの解説にあります。
複数リポジトリを一覧にするスクリプト
リポジトリ名をrepos.txtに1行ずつ書き、必須チェックを順に引きます。--paginateと--jqはそのまま組み合わせられ、--jqで整形すればTSVにできます。
while read -r repo; do
gh api "repos/$repo/branches/main/protection/required_status_checks" \
--jq ".checks[] | [\"$repo\", .context, (.app_id|tostring)] | @tsv" \
2>/dev/null || echo "$repo 取得不可"
done < repos.txt応答に含まれないリポジトリ名は、--jqの式に$repoを埋め込んで付け足します。出力は「リポジトリ、必須チェック名、app_id」の3列になり、スプレッドシートに貼ってそのまま並べ替えられます。
デフォルトブランチ名がmainでないリポジトリは、ここで404になります。ブランチ名の列をrepos.txtに足し、read -r repo branchで受けると取りこぼしません。パスの{branch}にはワイルドカードを書けないとAPIの説明にあるので、release/*のような命名規則の保護は、この方法では拾えません。ワイルドカードを使うならGraphQL APIを使うとも書かれています。
必須チェック名と実際のcheck run名を突き合わせる
棚卸しの本題はここです。必須チェックのcontextと、実際に報告されているcheck runのnameを並べて、必須側にしかない名前を探します。
名前のずれを探す流れ
- 1
必須側の名前を取る
required_status_checksのchecks[].contextを一覧にして、sortしておきます。 - 2
報告側の名前を取る
デフォルトブランチの先頭コミットに付いたcheck runの
nameを、commits/main/check-runsで取ります。 - 3
差分を出す
必須側にあって報告側にない名前が、待ち続ける候補です。
repo=owner/name
gh api "repos/$repo/branches/main/protection/required_status_checks" \
--jq '.checks[].context' | sort -u > required.txt
gh api "repos/$repo/commits/main/check-runs?per_page=100" --paginate \
--jq '.check_runs[].name' | sort -u > reported.txt
comm -23 required.txt reported.txtcomm -23は、required.txtにだけある行を出します。出てきた名前は、そのコミットでは誰も報告していません。ワークフローのジョブ名を変えた、ワークフローを消した、マトリクスの組み合わせが変わってジョブ名が変わった、といった経緯がまず疑われます。
check run側には、押さえておく性質が2つあります。
- 返るのはcheck runです。commit statusとして報告された外部のステータスは、このエンドポイントの一覧に出ません(次の節で合流させます)
refにブランチ名を渡すと、そのブランチ先頭のコミットが対象です。1つの参照に1000を超えるcheck suiteがあると、直近1000件に絞られます
先頭コミットで一度も走らなかったジョブ(パスで絞ったワークフローなど)は、差分に出ても設定ミスとは限りません。差分に出た名前は、過去のPRで実際に報告されていたかを別のコミットで引き直して判断します。commits/{ref}/check-runsのrefにSHAを渡せば、特定のPRのマージコミットも見られます。
commit statusで報告されるチェックを合流させる
必須チェックは、check runだけでなくcommit statusのcontextでも満たされます。外部のCIや旧式の連携がstatusで報告している場合、comm -23の差分にそのチェックが出てしまい、設定ミスに見える偽陽性になります。報告側にstatusも足します。
GET /repos/{owner}/{repo}/commits/{ref}/statusは、refに付いたステータスをstatuses配列で返し、各要素にcontextが入っています。refにはSHA、ブランチ名、タグ名を渡せます。
{
gh api "repos/$repo/commits/main/check-runs?per_page=100" --paginate \
--jq '.check_runs[].name'
gh api "repos/$repo/commits/main/status?per_page=100" --paginate \
--jq '.statuses[].context'
} | sort -u > reported.txt
comm -23 required.txt reported.txtこの2本を足したreported.txtと比べても、まだ差分に残る名前が、どちらの形式でも報告されていないチェックです。なお、ステータスのAPIには「1つのSHAとcontextの組に付けられるのは1000件まで」という上限もあり、同じcontextを何度も更新し続けるCIでは頭に入れておく数字です。
app_idも見る価値があります。更新APIの説明には、特定のGitHub Appが最近設定したチェックは、今後そのアプリからの報告に限られる、とあります。名前が合っていてもapp_idの報告元が違えば、待ちは解けません。check runの応答にもappの項目があるので、--jqで.app.idを一緒に出して比べてください。
ルールセット側の必須チェックも見る
ブランチ保護とは別に、リポジトリや組織のルールセットでも必須ステータスチェックを設定できます。ルールセットのrequired_status_checksルールはcontextとintegration_idの組を持ち、ブランチ保護APIの応答とは別の場所にあります。ブランチ保護だけを引いて「必須チェックなし」と判断すると、ルールセットで課されているチェックを取りこぼします。
見るのはGET /repos/{owner}/{repo}/rules/branches/{branch}です。ブランチに適用される有効なルールを、リポジトリ単位か組織単位かを問わず返します。ブランチ自体が存在しなくても、その名前に適用されるルールが返ります。一方で、強制ステータスがevaluateやdisabledのルールセットは返りません。ここも{branch}にワイルドカードは書けません。
gh api "repos/$repo/rules/branches/main" \
--jq '.[] | select(.type=="required_status_checks")'出力されたルールのcontextを、ブランチ保護側のrequired.txtに足して同じ差分を取ります。応答の細かい形は、手元のリポジトリで一度確かめてから--jqを固めてください。ここでallowに足すパスはrepos/*/rules/branches/*で、取得系だけです。ルールセットの作成・更新にも別のPUT・POSTがあるため、denyのフラグはそのまま効かせます。
Claude Codeに任せる指示の書き方
ここまでの手順は、CLAUDE.mdに数行書けばClaude Codeが繰り返せます。権限の枠は前節の設定が持つので、指示は「何を出すか」に絞ります。
## ブランチ保護の棚卸し
- repos.txt の各リポジトリで、デフォルトブランチの required_status_checks を取る
- 同じブランチの check-runs の name と比べ、必須側だけにある名前を表にする
- 404 のリポジトリは「取得不可」と書き、保護なしとは書かない
- gh api は GET だけ。-X・-f・--input は使わない最後の行はdenyと重複しますが、書いておけば書き込み系の呼び出しを最初から試みにくくなり、拒否のやり取りが減ります。強制するのはdeny、方向づけるのはCLAUDE.mdと役割が違うので、両方に書きます。
保護ルールの変更まで進めたくなったときは、PUTを許可しない構成なら、変更案をdiffの形で出させる段階で止められます。マージキューの操作を任せる範囲の考え方は、Mergify CLIの権限設定にも近い整理があります。組織全体のメンバーや外部コラボレーターの棚卸しは、GitHub組織のメンバー棚卸しが別の入口です。
まとめ
棚卸しは、required_status_checksのchecksとcommits/{ref}/check-runsのnameを引き比べる作業に絞れます。gh apiは引数を足すとPOSTに変わるので、フラグをdenyで塞ぎ、トークンの権限でもう一段止めます。404は権限不足やプランの制限でも起きるため「保護なし」とは限らず、一覧には取得不可の列を残しておくと、後から見返す人が迷いません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。