先日、複数のリポジトリを Claude Code から自動で更新する仕組みを動かしていたところ、ある 1 つのリポジトリだけが git clone の時点で止まりました。返ってきたのは次のメッセージです。
remote: Write access to repository not granted.
fatal: unable to access 'https://github.com/youruser/yourrepo.git/': The requested URL returned error: 403
トークンは前日に作り直したばかりで、他のリポジトリは同じ仕組みで問題なく push できています。なのに 1 つだけ 403。原因は GitHub の fine-grained personal access token(細粒度 PAT)の権限設計を取り違えていたことでした。同じ落とし穴は、Claude Code でリポジトリ操作を自動化している方なら誰でも踏み得ます。エラーメッセージごとの見分け方と、確認すべき順番を整理します。
まず症状を切り分ける — 401 と 403 はまったく別物
最初にやるべきは、返ってきたエラーが認証(authentication)の失敗なのか、認可(authorization)の失敗なのかを見分けることです。ここを混同すると、トークンを何度作り直しても解決しません。
fatal: Authentication failed/ 401 — そもそもトークンが GitHub に受け付けられていない。文字列の貼り間違い、期限切れ、コピー時の改行混入などが原因です。Write access to repository not granted/Resource not accessible by personal access token/ 403 — トークンは有効だが、そのリポジトリに対する書き込み(または読み取り)権限がトークンに紐づいていない。
私が遭遇したのは後者の 403 でした。トークン自体は生きていて git ls-remote も通る場合すらあるのに、push だけが拒否される——これは権限スコープの問題だと判断できます。
切り分けの第一歩として、トークンが認証として通るかどうかだけを確認します。
# 認証が通るか(401 か 403 かの切り分け)
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
https://api.github.com/user
# 200 が返れば認証は成功。トークン自体は有効ここで 200 が返るのに push が 403 なら、問題はトークンの有効性ではなく「そのリポジトリへのアクセス権」に絞り込めます。
原因の本質 — classic PAT と fine-grained PAT は権限モデルが違う
ここが今回のつまずきの核心でした。GitHub の PAT には 2 種類あり、権限の考え方がまったく異なります。
classic PAT(ghp_ で始まる) は、repo スコープにチェックを入れると、そのアカウントがアクセスできる全リポジトリに対して読み書きできます。リポジトリを個別に選ぶ概念がありません。だから一度作れば、新しく追加したリポジトリにもそのまま使えます。
fine-grained PAT(github_pat_ で始まる) は、トークン作成時に「どのリポジトリに」「どの操作を」許可するかを明示的に選びます。ここで対象リポジトリを選び忘れていたり、後から作ったリポジトリが選択対象に入っていなかったりすると、トークンは有効なまま、そのリポジトリだけ 403 になります。
私のケースはまさにこれで、トークンを作り直した際に対象リポジトリの選択から 1 つだけ漏れていたのが原因でした。git ls-remote が通る別トークンと挙動が違ったのも、そちらが classic PAT で全リポジトリをカバーしていたからです。
fine-grained PAT を使う場合の確認手順
fine-grained PAT で 403 が出たら、次の 3 点を上から順に確認します。
1. Repository access に対象リポジトリが含まれているか
GitHub の Settings → Developer settings → Personal access tokens → Fine-grained tokens から該当トークンを開き、Repository access を確認します。Only select repositories を選んでいる場合、操作したいリポジトリがリストに入っていなければなりません。入っていなければ追加します。組織のリポジトリなら All repositories でも、その組織が選択肢に出ている必要があります。
2. Contents 権限が Read and write になっているか
fine-grained PAT は操作ごとに権限を分けます。git clone / git pull には Contents の Read、git push には Contents の Read and write が必要です。Repository permissions → Contents が Read-only のままだと、clone はできても push で 403 になります。これが「読めるのに書けない」状態の正体です。
# トークンに対象リポジトリへの権限があるか直接確認する
curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" \
https://api.github.com/repos/youruser/yourrepo \
| grep -E '"(full_name|permissions)"' -A4
# "permissions": { "push": true, ... } になっているかを見る
# "push": false なら Contents 権限が Read-only"push": false が返ってきたら、トークン設定で Contents を Read and write に変更してください。なお fine-grained PAT は権限を編集した後、反映に数十秒かかることがあります。
3. 組織の承認待ちになっていないか
リポジトリが組織(Organization)所有の場合、fine-grained PAT は組織側で承認されるまで機能しません。トークン一覧で対象トークンが Pending 表示なら、組織の管理者による approve が必要です。個人アカウントのリポジトリなら、この項目は無関係です。
自動化を止めないための実務的な対処
私は 2014年から個人開発でアプリを公開し続け、累計5,000万ダウンロードに育てる過程で、複数リポジトリを 1 つの仕組みでまとめて更新する運用に落ち着きました。その分、リポジトリを追加するたびに fine-grained PAT の選択を更新し忘れるリスクが残ります。そこで運用としては、横断的に使う自動化には classic PAT(repo スコープ)を割り当て、特定リポジトリだけを外部に開放したいときに fine-grained PAT を使う、という使い分けに落ち着きました。fine-grained は安全側に倒れている分、選択漏れが「無言の 403」として表面化しやすいからです。
緊急で push を通したいときは、同じアカウントで全リポジトリをカバーしている別の classic PAT に一時的に差し替えるのが最短です。その上で、本来使いたい fine-grained PAT の Repository access と Contents 権限を落ち着いて直す、という順番が安全です。
# 認証情報を URL に埋めず、環境変数経由で push する
# (履歴やプロセス一覧にトークンが残りにくい)
git remote set-url origin "https://github.com/youruser/yourrepo.git"
git -c credential.helper='!f() { echo "username=x-access-token"; echo "password=${GITHUB_TOKEN}"; }; f' \
push origin main切り分けの早見表
403 に出くわしたとき、次の順で潰していくと最短で原因にたどり着けます。
api.github.com/userが 200 か → No なら 401(トークン文字列・期限の問題)repos/.../のpermissions.pushが true か → false なら Contents 権限不足- fine-grained でリポジトリが選択対象に入っているか → 漏れていれば追加
- 組織所有なら承認が
Pendingでないか → Pending なら管理者に approve 依頼
トークンを作り直す前に、まずこの 4 つを確認してみてください。多くの場合、トークン自体は正常で、権限の選択が 1 か所抜けているだけです。同じ場所でつまずいた方の時間を少しでも節約できれば嬉しいです。