Engineering

GitHub Pages は Unpublish しても設定が残るので棚卸しでは DELETE API まで叩く

  • GitHub
  • GitHub Pages
  • gh CLI
  • DevOps

GitHub Pages を移行元リポジトリで片付けるとき、Settings から Unpublish しただけだと Pages 設定オブジェクトが残り続け、GET /repos/{owner}/{repo}/pages は 200 を返し続けます。設定ごと消して 404 にしたい場合は REST APIDELETE まで踏み込みます。

gh api -X DELETE "repos/$ORG/$REPO/pages"   # → 204 No Content

この記事では、ソース層と設定層が別レイヤーで残ること、Unpublish と Delete の挙動差、GET /pages のレスポンス(200 と 404、status フィールド)の読み方、そして棚卸しのノイズを消すために DELETE まで踏み込む判断の根拠を順に扱います。

複数プロダクトのドキュメントサイトをモノレポへ集約した話の続きで、移行元リポジトリ側に残っていた旧 GitHub Pages を片付けたときに、リポジトリの Pages 設定そのものが思ったより根強く残ることに気づきました。画面上は無効化したはずなのに、API で見るとまだ生きているという状態です。

ブログプロダクトサイト群を 1 つの pnpm workspace に集約した複数のプロダクトサイトを別リポジトリで運用していたところを、`sites/` と `packages/` の2層構成を持つ単一の pnpm workspace モノレポに集約しました。catalog で依存バージョンを揃え、共有パッケージはロジックと構造だけを渡してデザインは各サイトに残す設計、仮移植のサイトを抱えたまま動かす運用までを扱います。

ソース層と設定層を別々に消す

最初に消したのはソース層です。pages/ ディレクトリと、それをデプロイする deploy-pages.yml を削除する PR を、git clone を一度もせずに gh api だけで投げました。手口は別記事と同じです。

ブログRenovateの参照差し替え先の変更をgh apiで楽をするRenovateプリセットの参照先差し替えで、Org配下のリポジトリ群に同じ修正PRを冪等に投げるBashスクリプトを、`git clone`を一度もせずに`gh api`だけで書きました。GitHub Contents APIとGit Refs APIの組み合わせ、再実行を前提にした冪等性の4箇所、macOSとLinuxで動く`mktemp`テンプレートまでをまとめます。

問題はその先でした。ファイルを消しても、リポジトリの Pages 設定(公開設定そのもの)は別レイヤーで残ります。移行済みリポジトリで Pages の状態を確認すると、こうなっていました。

$ gh api "repos/$ORG/$REPO/pages" \
    --jq '"status=\(.status) build_type=\(.build_type)"'
status=built build_type=workflow

status=built は、まだ <org>.github.io/<repo> が実際に配信され続けているという意味です。pages/deploy-pages.yml も消えているのに、最後にビルドされた成果物がそのまま生き残っていました。build_type=workflow は GitHub Actions ワークフロー経由でデプロイされていた履歴を示します。legacy だとブランチ直結(旧来の gh-pages 配信)です。

ここでつまずいたのは、削除の対象が次の 2 層に分かれている点でした。

flowchart TB
  subgraph Repo["移行元リポジトリ"]
    Source["ソース層<br/>pages/ ディレクトリ<br/>deploy-pages.yml"]
    Setting["設定層<br/>Pages 設定オブジェクト<br/>(REST /pages で見える)"]
  end

  PR["ファイル削除 PR"] -.消す.-> Source
  DELETE["DELETE /repos/.../pages"] -.消す.-> Setting

  Source -.ビルド.-> Setting
  Setting -.配信.-> Public["&lt;org&gt;.github.io/&lt;repo&gt;"]

ソース層だけ消した状態で GET /pages を叩くと、設定層が生き残っているので 200 が返ってきます。「ビルドの源を消したのだから配信も止まっているだろう」という直感とは反対の挙動で、しばらく原因を取り違えました。

Unpublish と Delete を別物として扱う

Settings 画面を開くと、今の GitHub には「Disable」ボタンが見当たりません。代わりに Pages セクションの ... メニューの中に Unpublish site があります。これがクセモノでした。

公式ドキュメントにはこう書かれています。

When you unpublish your site, your current deployment is removed and the site will no longer be available.

加えて次の一文があります。

Unpublishing a site does not permanently delete the site.

つまり Unpublish は現在のデプロイを撤去して配信を止めるだけで、リポジトリの Pages 設定オブジェクトは残ります。REST API でこの設定を確認すると、Unpublish 後も GET /repos/{owner}/{repo}/pages200 を返し続けます。

# UI で Unpublish 済みのリポジトリ
$ gh api -i "repos/$ORG/$REPO/pages" | head -1
HTTP/2.0 200 OK
$ gh api "repos/$ORG/$REPO/pages" --jq '.status'
null

Unpublish 前後の状態遷移を整理するとこうなります。

stateDiagram-v2
    [*] --> Published: Pages 有効化
    Published --> Unpublished: UI で Unpublish
    Unpublished --> Published: 再デプロイ
    Unpublished --> Deleted: DELETE /pages
    Published --> Deleted: DELETE /pages
    Deleted --> [*]

    Published: Published\nGET /pages = 200\nstatus = built
    Unpublished: Unpublished\nGET /pages = 200\nstatus = null
    Deleted: Deleted\nGET /pages = 404\n設定オブジェクトなし

表で並べると、どの経路で何が消えるかが見通しやすくなります。

操作経路効果GET /pagesstatus
UnpublishSettings の ... メニューデプロイを撤去し配信停止。設定は残る200null
DeleteDELETE /repos/{owner}/{repo}/pagesPages 設定そのものを削除404(取得不可)

REST API リファレンス によれば、GET /pagesstatusbuilt / building / errored / null の 4 値を取ります。built は配信中、building はビルド進行中、errored はビルド失敗、null は「配信は止まっているが設定は残っている」状態です。Unpublish で止めた直後はちょうど null になります。「status が null だから消えている」と読みたくなりますが、GET が 200 を返している以上、設定オブジェクトはまだ生きています。

棚卸しの判定軸を 200 と 404 の二値に倒す

配信を止めたいだけなら Unpublish で十分です。一般公開という実害はそれで消えます。ただし移行の棚卸しとして「このリポジトリの Pages はもう存在しない」と言い切りたい場合、status: null のまま 200 が返る状態はノイズになります。GET /pages を叩いて回ったとき、止めたつもりのサイトが軒並み 200 を返してくると、本当に生きているもの(built)との区別がつきにくくなります。

棚卸しスクリプトの判定ロジックも、200 と 404 の二値に倒した方が単純です。「200 かつ status != null なら生きている」「200 かつ status == null なら止まっているが設定は残っている」「404 なら完全に消えている」と 3 分岐するより、最終状態を 404 に揃えてしまえば 1 行で書けます。

設定オブジェクトごと消して 404 にするには、UI に経路がなく、REST の DELETE を叩くしかありません。移行対象のリポジトリを配列で持って一気に流します。

# 移行済みリポジトリを順に DELETE
ORG="example-org"
repos=("repo-a" "repo-b" "repo-c")

for repo in "${repos[@]}"; do
  gh api -X DELETE "repos/$ORG/$repo/pages"   # → 204 No Content
done

# 検証
for repo in "${repos[@]}"; do
  code=$(gh api -i "repos/$ORG/$repo/pages" 2>&1 | awk 'NR==1{print $2}')
  echo "$repo: $code"   # → 全て 404
done

DELETE204 No Content を返し、以降 GET /pages は 404 になります。これで「200 なら設定が残っている、404 なら完全に消えた」という単純な軸で棚卸しができます。DELETE で消えるのは公開設定だけで、リポジトリやコンテンツには影響しません。再公開が必要になれば Pages 設定を作り直せば戻せます。

DELETE を叩く権限としては、対象リポジトリの admin / maintain か、「manage GitHub Pages settings」権限が必要です。Org 全体に対して gh auth status で現在のスコープを確認しておくと、途中で 403 を踏んで止まることが減ります。

どこまで踏むかを 2 軸で切り分ける

実運用での判断軸は 2 つに分けて持っています。

第一に、配信を止めるだけが目的なら Unpublish で足ります。一般公開という実害はそこで消え、URL を知っている人にだけ届けばよい社内共有のような運用なら、設定オブジェクトを残したままでも実用上は問題ありません。リポジトリ自体を残して、Pages 再有効化の余地を温存しておきたい場合にも適した選択です。

第二に、設定オブジェクトごと消して 404 にするのは、移行の棚卸しで「このリポジトリの Pages はもう存在しない」と言い切りたいときです。DELETE で消えるのは公開設定だけなので、リポジトリやコンテンツには触りませんし、再公開が必要になれば設定を作り直せば戻せます。

引っかかりやすいのは、消すべき残骸が pages/deploy-pages.yml(ソース側)だけでなく、Pages 設定オブジェクト(設定側)の 2 層に分かれている点です。ソースを消しても設定側は残り、GET /pages は 200(status: null)を返し続けます。Unpublish と DELETE はそもそも別レイヤーの操作だと分かっていれば、棚卸しのノイズに振り回されずに済みます。

棚卸しスクリプトを冪等に組む

DELETE を流すスクリプトでは、対象リポジトリの絞り込みを誤らないようにしておきます。gh repo list <org> --no-archived --limit 200 --json name,visibility でアクティブなリポジトリを取得し、その中から「移行対象」だけを明示的に配列で持つ運用が安全です。gh api repos/$ORG/$repo/pages を全リポジトリで叩いて 200 が返ったものを機械的に DELETE する書き方は、まだ運用中の Pages を巻き込む事故につながります。

レート制限は DELETE を数十件叩く程度ではまず当たりませんが、検証側の GET ループを大規模に回す場合は gh api rate_limit で残量を見ながら sleep を挟むのが無難です。gh api は認証済みリクエストなので未認証より上限は緩いものの、確認のために全リポジトリへ繰り返しアクセスする運用では消費が積み上がります。

エラーハンドリングとしては、DELETE404 を返したら「すでに消えている」として成功扱いでよい場面が多いです。set -e でフェイルファストにしてしまうと、再実行時に「前回 DELETE 済みのリポジトリ」で止まります。gh api -i でステータスコードを取り、204404 を成功側に倒しておくと、再実行が冪等になります。

for repo in "${repos[@]}"; do
  code=$(gh api -i -X DELETE "repos/$ORG/$repo/pages" 2>&1 \
    | awk 'NR==1{print $2}')
  case "$code" in
    204|404) echo "$repo: ok ($code)" ;;
    *)       echo "$repo: FAIL ($code)" ;;
  esac
done

gh search code を含めた検索ベース API と同じで、「ここに何が残っているか」を最終判定する場面では、実体に降りる API を直接叩くのが結局いちばん速く確実でした。

ブログgh search code のインデックスは遅延するRenovateプリセットの旧ファイル削除前に `gh search code` で参照残存を確認したら、マージ済みの新参照がまだヒットしてくれませんでした。コード検索インデックスの遅延に気づいた経緯と、Contents API でファイル本文を直接取得して実体検証する手順、検索ベース API への向き合い方を扱います。