---
title: "GitHub Pages は Unpublish しても設定が残るので棚卸しでは DELETE API まで叩く"
description: "移行元リポジトリの GitHub Pages を片付けたとき、Settings から Unpublish しただけでは Pages 設定オブジェクトが残り、`GET /repos/{owner}/{repo}/pages` が 200 を返し続けることに気づきました。Unpublish と Delete の違い、`status: null` の読み方、棚卸しで漏れを出さないために `gh api -X DELETE` まで踏み込む判断をまとめます。"
category: "DevOps"
tags: ["GitHub","GitHub Pages","gh CLI"]
publishedAt: "2026-06-30"
lastmod: "2026-08-03"
---

GitHub Pages を移行元リポジトリで片付けるとき、Settings から Unpublish しただけだと Pages 設定オブジェクトが残り続け、`GET /repos/{owner}/{repo}/pages` は 200 を返し続けます。設定ごと消して 404 にしたい場合は [REST API](https://docs.github.com/en/rest/pages/pages) の `DELETE` まで踏み込みます。

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

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

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

::card[https://labee.jp/posts/product-sites-monorepo]

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

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

::card[https://labee.jp/posts/github-api-bulk-pr-migration]

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

```bash
$ 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 層に分かれている点でした。

![移行元リポジトリのGitHub Pagesは、pagesディレクトリとdeploy-pages.ymlからなるソース層と、RESTの/pagesで見えるPages設定オブジェクトからなる設定層の2層に分かれている。ソース層はファイル削除PRで消せるが、設定層はそれでは消えず、UIにも経路がないためDELETE /repos/.../pagesを叩くしかない。ソース層だけを消した状態では設定層が生き残るので、GET /pagesは200を返し続け、statusだけがnullになる](/images/posts/github-pages-unpublish-vs-delete/two-layers.svg)

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

## Unpublish と Delete を別物として扱う

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

[公式ドキュメント](https://docs.github.com/en/pages/getting-started-with-github-pages/unpublishing-a-github-pages-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}/pages` は **200** を返し続けます。

```bash
# 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 前後の状態遷移を整理するとこうなります。

![Pagesを有効化するとPublishedになりGET /pagesは200でstatusはbuilt。UIでUnpublishするとUnpublishedへ移り、200のままstatusだけがnullになる。ここは再デプロイでPublishedへ戻れるので往復する。DELETE /pagesだけが片道の遷移で、PublishedからもUnpublishedからも入れて、GET /pagesが404になり設定オブジェクトそのものが消える](/images/posts/github-pages-unpublish-vs-delete/state-transitions.svg)

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

| 操作 | 経路 | 効果 | `GET /pages` | `status` |
| --- | --- | --- | --- | --- |
| Unpublish | Settings の `...` メニュー | デプロイを撤去し配信停止。設定は残る | 200 | `null` |
| Delete | `DELETE /repos/{owner}/{repo}/pages` | Pages 設定そのものを削除 | 404 | （取得不可） |

[REST API リファレンス](https://docs.github.com/en/rest/pages/pages) によれば、`GET /pages` の `status` は `built` / `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` を叩くしかありません。移行対象のリポジトリを配列で持って一気に流します。

```bash
# 移行済みリポジトリを順に 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
```

`DELETE` は `204 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` は認証済みリクエストなので未認証より上限は緩いものの、確認のために全リポジトリへ繰り返しアクセスする運用では消費が積み上がります。

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

```bash
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 を直接叩くのが結局いちばん速く確実でした。

::card[https://labee.jp/posts/gh-search-code-indexing-delay]