前回は、public リポジトリに配布条件のあるバイナリを commit してしまい、git filter-repo で履歴から抜くまでの経緯を書いた。あれは「何が起きて、どう考えたか」の記録である。
この記事はその手順書版だ。同じことがまた起きたとき (自分でも他人でも)、上から順になぞれば止血から再発防止まで終わるように書く。対象は「秘密鍵を commit した」「ライセンス上まずいバイナリを入れた」「個人情報の入ったデータを上げた」のどれでも同じで、違うのは緊急度だけである。
以下、画面とログのリポジトリ名・SHA・PR 番号は伏せてある (手順と出力の形はそのまま)。
0. 落ち着いて、3 つ数える
手を動かす前に、被害の範囲を数字で押さえる。ここを飛ばすと「消したつもり」が量産される。
# 何が (パスと大きさ)
git ls-tree -r -l HEAD | awk '$4 > 1000000'
# いつから (最初に入った commit)
git log --diff-filter=A --format='%h %ad %s' --date=short -- web/guest-a.img.gz
# どこまで広がったか (fork・clone は後で数える)
gh api repos/OWNER/REPO/forks --jq length
秘密鍵や API トークンなら、ここで先にローテーション (無効化) する。履歴からの除去は「もう取られた後」には効かない。鍵を新しくしてから消す。
何を入れたかで、急ぐ工程が変わる。 秘密なら止血はローテーションで済むが、著作物と個人情報には差し替えられる鍵が無い。
| 入れたもの | 本当の止血 | 履歴の除去 | Support の扱い |
|---|---|---|---|
| 秘密 (鍵・トークン) | ローテーション。取られた前提で無効化すれば、履歴に残っていても価値が無い | 体裁と再利用防止。ローテーション後なら急がない | 「ローテーションで足りるなら purge は不要」と言われることがある (docs に明記) |
| 著作物・ライセンス違反 | 無い。消すこと自体が唯一の手当て | 必須 (HEAD・履歴・PR ref の全部) | “sensitive data” の定義に入るか微妙。断られる可能性があるのはここ |
| 個人情報 | 無い。漏れた事実は戻らない | 必須 + 通知義務の判断 | sensitive として扱われる本命。一番通りやすい |
秘密は「消さなくても守れる」、個人情報は「消さないと法的に詰む (から向こうも動く)」。著作物だけが「消すしか無いのに、消す側の協力が保証されない」位置にある。今回の筆者のケースはこれだ。
1. まず HEAD から外す — ただし、これは止血ではない
git rm --cached web/guest-a.img.gz web/guest-b.img.gz
echo '/web/*.img.gz' >> .gitignore
git commit -m "追跡外へ"
PR を出してマージすれば、最新のツリーからは消える。だが古い commit の SHA を知っていれば、誰でもまだ取れる。

GitHub は「この commit はどのブランチにも属さない」と警告を出しつつ、中身をそのまま見せる。raw の URL も 200 を返す。
$ curl -sIL -o /dev/null -w '%{http_code}\n' \
https://raw.githubusercontent.com/OWNER/REPO/<旧SHA>/web/guest-a.img.gz
200
つまり手順 1 は「これ以上広がらないようにする」だけで、消す作業は次からである。
2. 履歴から抜く (git filter-repo)
詳しい理屈と検証の型は前回に書いたので、ここは手順だけ。
# 別の場所に mirror を取る (作業ツリーでやらない)
git clone --mirror https://github.com/OWNER/REPO.git purge.git && cd purge.git
# そのパスだけを全コミットから抜く
uvx --from git-filter-repo git-filter-repo \
--invert-paths --path web/guest-a.img.gz --path web/guest-b.img.gz --force
# 「抜けただけ」を確かめる: 差分がそのファイルだけ・コミット数と件名の並びが一致
git fetch --no-tags https://github.com/OWNER/REPO.git main:refs/old/main
git diff --stat refs/old/main main
diff <(git log --pretty='%s' refs/old/main) <(git log --pretty='%s' main)
# 検証用の ref を消して掃除 (残すと blob が生き返って「消えていない」と誤診する)
git update-ref -d refs/old/main
git reflog expire --expire=now --all && git gc --prune=now
git remote add origin https://github.com/OWNER/REPO.git # filter-repo が外すので付け直す
# **どこから変わったかを必ず見る** — GitHub の merge commit の署名 (gpgsig) を filter-repo は
# 全部落とすので、混入点より前から SHA が変わっている (筆者は 787 commit 中 780 が変わった)
cat filter-repo/first-changed-commits
grep -c . filter-repo/changed-refs
署名付き commit があると書き換えは全 ref に及ぶ。force push は main だけでなく、全ブランチとタグを揃えて押す (git push --force origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*') か、旧 SHA のまま残る枝があることを承知で進める。Verified バッジは履歴から消える。
3. force push — ブランチ保護は「外す・押す・戻す」を 1 行で
main が保護されていれば GH006 で断られる。保護を外している時間を秒にする。
R=OWNER/REPO
gh api repos/$R/branches/main/protection > main-protection.json # 先に控える
gh api -X DELETE repos/$R/branches/main/protection \
&& git -C purge.git push --force origin main; \
gh api -X PUT repos/$R/branches/main/protection --input restore-protection.json
押した痕跡は GitHub の Activity に残る。これは消す側にとっても大事で、「いつ・誰が・どこからどこへ」を後から示せる。

手元のクローンは git fetch --prune → git reset --hard origin/main で貼り直す。追跡外にしたファイルは reset で消えるので、必要なら先に退避する。
4. 残っている参照を数える
ここが「消したつもり」と「消えた」の分かれ目である。force push で書き換わるのはブランチの先端だけで、次のものは残る。
| 残る場所 | 確かめ方 | 消し方 |
|---|---|---|
refs/pull/<N>/head (PR ごとの ref) |
gh api repos/$R/git/refs/pull/<N>/head |
GitHub Support (次節) |
| 旧 SHA 直指定の raw / commit ページ | curl -sI .../raw/<旧SHA>/<path> が 200 |
同上 (GC 後に 404) |
| fork | gh api repos/$R/forks |
fork の持ち主に依頼、または Support |
| 他人の clone | 数えられない | 諦める (鍵ならローテーション済みが前提) |
| CI のキャッシュ・アーティファクト | Actions の Caches / Artifacts | 手で消す |
5. GitHub Support に依頼する
旧 SHA と PR ref から辿れる分は、自分では消せない。GitHub Support に「force push 済みなので、到達不能になったオブジェクトの GC と PR のキャッシュ除去をお願いしたい」と頼む。

手順:
- https://support.github.com/contact を開き、GitHub アカウントでサインイン
- トピックは Repositories (「Removing sensitive data」に近いもの)
- 本文に次を書く。SHA は全長で、パスと blob も添える — 向こうが手で探さなくて済むほど速い
Subject: Please remove unreachable objects and PR cached views after a force-push
I accidentally committed files I am not entitled to redistribute to my public
repository OWNER/REPO, rewrote the history with git filter-repo, and force-pushed
the cleaned branches. The files are no longer reachable from any branch, but can
still be fetched via the old commit SHAs and the pull request refs. Could you
please run a garbage collection and remove the cached views?
Repository: https://github.com/OWNER/REPO
Files: web/guest-a.img.gz (blob <sha>), web/guest-b.img.gz (blob <sha>)
Old commits (now unreachable): <sha1> (introduced), <sha2>, ..., <old tip of main>
Pull requests referencing them: #<N>, #<M>
Force-pushed on: <date>. Forks: none.
材料は gh で一式揃う。
# 旧 main から混入前までの commit 一覧
gh api "repos/$R/commits?sha=<旧 main の SHA>&per_page=40" \
--jq '.[] | "\(.sha) \(.commit.message|split("\n")[0])"'
# blob の SHA (旧 main の木で)
gh api "repos/$R/contents/web/guest-a.img.gz?ref=<旧 main の SHA>" --jq '"\(.sha) \(.size)"'
# PR の ref
gh api "repos/$R/git/refs/pull/<N>/head" --jq .object.sha
フォームの前に AI の前さばきが 2 回出る (「このケースは Support の領分です、送信してください」という内容)。それは受付ではない — チケット番号のページ (support.github.com/ticket/personal/0/<番号>) が出て初めて受付である。向こうは filter-repo の first-changed-commits と影響 PR の数も欲しがるので、本文に足しておく。
返事は通常 1〜3 営業日。処理後に curl -sI .../raw/<旧SHA>/<path> が 404 になれば完了。筆者は 2026-08-22 に送信し、返事待ち (来たらここに追記する)。
依頼するかどうかは、入っていたものの性質で決めればよい。秘密鍵なら必須 (ただしローテーションが本体)、ライセンスの取りこぼし程度なら「利用者が増えたら」でも構わない。筆者は前回「誰も使っていないから」と見送り、今回は手順を書く機会なので出した。
6. 二度と入らないようにする — 約束ではなく仕組み
.gitignore は「置かない約束」で、名指しで伸ばしていると漏れる (前回の穴)。「置けない仕組み」を CI の必須チェックに入れる。
# tools/build/check-large-files.sh (抜粋)
# 1. 容量: 名前に関係なく 1MB 超
git ls-tree -r -l HEAD | awk -v l=1000000 '$4+0 > l'
# 2. 拡張子: 小さくても形から怪しいもの (.COM/.EXE/.ROM/.WAD/音/書庫/フォント/…)。
# ただし隣に同名のソース (.asm/.c/.rs/…) があれば自作物として通す
EXT_RE='\.(img|iso|bin|rom|com|exe|wad|dll|sys|zip|gz|7z|mp3|wav|mid|pdf|ttf|...)$'
# 3. PR では base からの範囲も見る (入れてすぐ消しても blob は履歴に残る)
git rev-list --objects origin/main..HEAD \
| git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
| awk '$1=="blob" && $2 > 1000000'
容量と拡張子は拾うものが違う (小さい .COM は容量をすり抜け、改名した像は拡張子をすり抜ける) ので両方掛ける。「隣に同名のソースがあれば通す」の出自ルールで、自作の .bin/.COM は誤検知しない。
同じスクリプトを .githooks/pre-commit から --staged で呼べば、手元でも止まる。
#!/bin/sh
exec sh "$(git rev-parse --show-toplevel)/tools/build/check-large-files.sh" --staged
git config core.hooksPath .githooks
サーバ側で止める push ruleset (max_file_size、拡張子制限) が本命だが、Organization 配下のリポジトリ限定で、個人所有の public には付かない (422 Source public repos cannot have push rules)。個人リポジトリは CI が最後の砦になる。
チェックリスト (緊急時はこれだけ見る)
- [ ] 何が・いつから・どこまで (fork) を数えた
- [ ] 秘密なら先にローテーションした
- [ ] HEAD から外して
.gitignoreをワイルドカードで塞いだ (PR) - [ ] mirror で
filter-repo→ 「抜けただけ」をdiff --statと件名の並びで確認した - [ ] 保護を外す・押す・戻すを 1 行で → 戻ったことを API で確認した
- [ ] 手元のクローンを貼り直した
- [ ] 残る参照 (PR ref・raw・fork・CI キャッシュ) を数えた
- [ ] Support に依頼した (SHA 全長・パス・blob・PR 番号つき) / 見送りなら理由を台帳に
- [ ] 容量 + 拡張子の門番を CI の必須チェックと pre-commit に入れた
一番効くのは最後の 1 行で、その次が最初の 1 行である。真ん中は、起きてしまった後の作業でしかない。


コメント