この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 19 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方(この記事)
TL;DR
対象読者は、ここまでの 18 回を読んで「自分のプロジェクトで何から始めるか」を考えているエンジニアです。最終回です。
- このリポジトリは 品質チェックとデプロイに GitHub Actions を使いません。ゲートはシェルスクリプトが持っています
- そのスクリプトは GitHub Actions の YAML 構文検査まで走らせます。道具を捨てたのではなく、道具も検査対象にしています
- 「未実行」と「合格」を終了コードで区別できないと信用できない、という設計思想が全体を貫いています
- fail-closed で止めたあと、閉じたゲートを開け直す経路 が無くて 3 スプリント分が滞留した話も出てきます
- 最後に、この連載で読んだものを 自分のプロジェクトへ持ち帰る順序 をまとめます
ゲートの集合を、どこで動かすかと切り離す
このリポジトリの品質チェックは、GitHub Actions ではなく tools/run_checks.sh というシェルスクリプトが持っています。README にこう書かれています。
本リポジトリでは品質チェック・デプロイに GitHub Actions を使わない(
D-23)ため、上記を走らせる CI は動いていない。
きっかけは制約でした。プラットフォーム側の制限で Actions を起動できなくなり(Issue #72)、ワークフローを撤去して、品質チェックを自前のスクリプトへ、デプロイを Cloudflare Workers Builds へ移した、という経緯が記録されています。
ただし現在は「使えない」のではありません。用途を限定して使い分けています。.github/workflows/ にはワークフローが 1 本あります。
GitHub Actions は Gem 候補プールの 日次実行 + 週次反映(
.github/workflows/gem-pool-refresh.yml)にのみ使う。
つまり「Actions が無い」のではなく、品質チェックとデプロイに Actions を使わないと決めている のが実態です。ここから引き出せる原則があります。
CI は道具ではなく、ゲートの集合である。
道具(Actions・CircleCI・自前スクリプト)は交換できますが、ゲート(何を通したいか)は残ります。実際、Actions を撤去してもゲートは 1 つも失われませんでした。
そしてこの分離が効いていることは、run_checks.sh 自身が示しています。このスクリプトは GitHub Actions のワークフロー YAML の構文検査まで走らせます。週次で回るワークフローの YAML が壊れていても、次に走るまで気づけないからです。
Actions を「使う / 使わない」ではなく、Actions も検査対象の 1 つとして扱っている。ゲートを道具から切り離して持つと、こういう向きの関係が作れます。
npm run check の中身
npm run check # = bash tools/run_checks.sh
中身を全部並べても覚えられないので、「何が検出できないから、その検査が存在するのか」 で 5 つに分けます。この表は覚えるためのものではありません。自分のリポジトリを当てはめて、行が空いているところを探す ために使ってください。
| 分類 | 何が検出できないから存在するのか | 代表例 | 回 |
|---|---|---|---|
| 言語・型・テスト | 型と実行で届くので、この列は空 | eslint / tsc --noEmit / vitest / playwright |
13〜17 |
| 表示品質 | 人間が見ないと気づかない劣化 | Lighthouse(アクセシビリティ 100 は blocking)/ 寸法・コントラスト・文言トークン | 12 |
| 規約と設計書 | 型では届かない依存の向き、書いたのに実装されていない設計 | 依存規則の静的検査 / ADR 記載 / レーン到達可能性 | 10 |
| 見えない配線 | 実行時に正常と区別できない配線漏れ | レート制限の配線検査 / 副作用 GET のプリフェッチ検査 / install スクリプト方針 | 18 |
| 検査自身の検査 | ゲートが黙って嘘をつくこと | 各ツールの --self-test / Actions の YAML 構文検査 |
19 |
上から下へ行くほど、「型でも実行でも届かない領域」 に入っていきます。最後の行がいちばん見落とされます。検査スクリプトも普通のコードなので、条件を反転しても全部 PASS のまま通ってしまうことがあるからです。
gem-hunter に実際に何本あるかは tools/run_checks.sh を上から読めば全部見えます。数えたい人は grep -c 'run_check' tools/run_checks.sh で足ります(実際に全部走らせるには第 16 回の前提が要ります)。
設計思想が 4 つ読み取れる
1. 最初の失敗で止めない
各チェックの結果を配列に貯め、全部走らせてから まとめて報告します。1 回の実行で全部の問題が分かるので、直す → 走らせる の往復が減ります。
2. 「未実行」を「合格」にしない
いちばん効いている思想がこれです。
package.jsonがあるのにnode_modulesが無いのは「依存未インストール=検査できていない」なので FAIL 扱い にする。理由は「未実行と合格が終了コードで区別できないと、CI 不在の今この結果を信用できない」。
同じ発想で、タイムアウトも FAIL として扱います(exit 124 を「チェッカー自体が完走できなかった」と解釈する)。
「エラーが出なかった」と「正常だった」は違う。 この区別が全体を貫いています。
3. スキップを必ず可視化する
SKIP_E2E=1 でスキップできますが、サマリー表には SKIP として必ず残ります。「黙って緑にしない」ためです。
さらに、@playwright/test が入っていないときは SKIP ではなく FAIL にします。「Lint や型は同じ条件で FAIL するのに、E2E だけ黙って緑にしない」という一貫性です。
4. 結果をそのまま PR に貼れる形で出す
最後に必ず Markdown の表を出力します。オプションではなく 常に 出ます。「結果を報告する」ところまでがスクリプトの責務、という設計です。
デプロイ(参考程度に)
デプロイ先は Cloudflare Workers です。ここは この連載の主題ではない ので、他の環境でも通じる部分だけ拾います。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "gem-hunter",
"main": ".open-next/worker.js",
"compatibility_date": "2026-08-04",
"compatibility_flags": ["nodejs_compat"],
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
"workers_dev": true,
"preview_urls": true,
"observability": { "enabled": true, "logs": { "invocation_logs": false } },
"limits": { "cpu_ms": 400 },
// 🔴 `period` を変えたら src/infrastructure/platform/rate-limit.ts の RATE_LIMIT_PERIOD_SECONDS も
// 同じ値へ更新する(超過時の Retry-After に使うため、ずれると案内した秒数と実際の窓が食い違う)。
"ratelimits": [
{ "name": "RATE_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
]
}
汎用的に持ち帰れるのは 3 点です。
1. ログにクライアント IP を残さない設定(invocation_logs: false)。第 18 回で HMAC 化までしたのに、ログに生 IP が残っては意味がありません。アプリの設計と運用設定を揃える、という一貫性です。
2. 設定とコードの二重管理を警告する。period(60 秒)と同じ値がコード側にもあり、ずれると案内する秒数が実際と食い違います。理想は 1 箇所ですが、設定ファイルとコードをまたぐと難しい。せめて気づけるようにコメントで警告しておく、という現実的な対処です。
3. 暫定値を実測で見直した記録が残っている。上の引用では 1 行に見える "limits": { "cpu_ms": 400 } ですが、実ファイルではこの行の直前に根拠のコメントが延々と続きます。
もともとの値は 50 でした。設計判断 D-38 は、それを暫定値と明示したうえで「実測後に 20〜30ms へ 締める」と書いていました。ところがコメントはこう続きます。
D-38は暫定 50ms を置き「wrangler tailの p95 実測後に 20〜30ms へ締める」としていたが、実測は逆方向を示した ので引き上げる。
締めるつもりで測ったら、上げる必要があると分かった。予想と逆の結果が出たことが、予想ごと残っています。 その後さらに 200 → 400 へ上がりました。Gem 一覧の初回リクエストが 237〜277ms だったためです。300 では余裕が 8% ほどしか残らない、という判断まで書かれています。
暫定値を置くときは「いつ・何を見て見直すか」まで書き、見直したら結果を上書きせず訂正として残す。 ADR と同じ作法が、たった 1 行の数値にも適用されています。
事業者非依存をどう保つか
「Cloudflare を選んだが、乗り換え不能にはしない」を、ディレクトリ境界と grep 2 本 に翻訳しています(第 10 回)。
- Cloudflare 固有の機能に触れてよいのは
src/infrastructure/platform/だけ - それ以外の場所で
getCloudflareContextやcloudflare:workersを書いたら検査が落ちる
「事業者非依存」というふわっとした目標が、機械で検査できる形に落ちています。 ここが持ち帰る価値のあるところです。
常設の開発環境を置かない
ADR 0004 の結論は「dev 環境を持たない」でした。
確認する人間も実トラフィックもいない体制では、dev を挟んでも検証内容は増えず、リードタイムと「誰がいつ昇格させるか」という未定義の判断点だけが増える。
代わりに「main マージ後にテストを走らせ、失敗したら本番デプロイに進ませない」ゲートで塞いでいます。
「一般にそうするから」ではなく「この規模・この体制ではどうか」で決める。 ADR に却下理由まで残っているので、状況が変われば再検討できます。
止めるだけでは足りない — 閉じたゲートを開ける経路
デプロイの入口スクリプトは、終了コードの意味を明示しています。
0 = ゲート通過 + デプロイ成功
1 = ゲート待機
2 = ゲート判定不能(GitHub API 到達不可等・fail-closed)
判定できないときは 2(fail-closed)。デプロイは不可逆なので、判断できないなら進めません。第 18 回のレート制限が fail-open だったのと対照的です。
ここで話を終えると気持ちよく締まるのですが、実際にはこの設計がそのまま事故になりました。Workers Builds は main への push ごとにビルドを発火し、そのビルドがゲートを実行して、通ったときだけデプロイへ進む。ここまでは設計どおりでした。しかし実測すると、こうなっていました。
Workers Builds は push のたびに正常に発火していた(直近 24 件すべて)。しかし全件
build_outcome: "fail"で、デプロイゲートが閉じていたため止まっていた。
問題はその次の一文です。
ゲートが後で開いても、その push を再ビルドする経路が存在しない ため、SP-17 / SP-18 / SP-19 の 3 スプリント分が本番未反映のまま滞留した。
止めるところまでは完璧に動いていました。動いていなかったのは、止めた後です。 ビルドの引き金は「push」というイベントだけで、そのイベントは過ぎ去ります。ゲートが開いた瞬間には、もう引き金を引くものが誰も残っていません。
対処として tools/trigger_workers_build.py が後から足されました。「ゲートが開いていたら main の最新コミットで再トリガーする」だけの CLI で、ゲート判定は自分で持たず既存のスクリプトに委ね、終了コードの意味も揃えてあります。
この回の結論を、1 段深くします。
止めるだけでは足りない。閉じたゲートが開いたときに自動で再開する経路まで設計して、初めて fail-closed が成立する。
fail-closed は「危険なものを通さない」設計です。しかし通さなかったものを 通せるようになったときにどう再開するか を設計しないと、安全に止まったまま気づかれずに滞留します。それは「安全」ではなく、ただ壊れているのと区別がつきません。
自分のプロジェクトで fail-closed なゲートを置くときは、必ずセットで問いを立ててください。「このゲートが開いたとき、誰が引き金を引き直すのか」。答えが「人間が気づいたら」なら、それはまだ設計が終わっていません。
1 本だけある Actions — コストを「反映頻度」で測る
冒頭で触れた .github/workflows/gem-pool-refresh.yml を見ます。アプリのビルドでもデプロイでもなく、Gem 候補プールという静的データを再生成するバッチ です。
素直に考えれば「毎日生成して毎日コミット」でいいはずです。Actions の実行時間には課金がかからず、データは新しいほうがいい。しかし結論は違いました。生成と QA は毎日、main への反映は前回から 7 日以上経過したときだけ。
コストの実体は実行頻度ではなく反映頻度にあるため、生成・QA(パイプラインの健全性検証)は毎日行い、実際に
mainへ反映するのは直近の反映コミットから 7 日以上経過した回だけに絞る
生成物は 3.6MB あります。コミットするたびにリポジトリの履歴へ積み上がり、クローンが年単位で数百 MB 太る。実行しても消えるものは安く、コミットするものは永久に残る。 どこにコストが乗っているかを取り違えると、最適化する場所を間違えます。
反映しない日も生成と QA は実行し、成果物は捨てます。パイプラインが壊れていないことを毎日確かめるため です。週次だと、壊れてから気づくまで最大 1 週間かかります。
そして「7 日以上経過したか」を決めるのは YAML ではありません。
# 🔴 日数計算はここに書かない。tools/gem_pool_qa.mjs --should-publish を呼ぶだけにする
# (YAML 側に判定ロジックの複製を作らない)。
YAML に条件式を書くと、手元で確かめられず、テストも書けず、そこだけが誰にも読まれないコードになります。Node の CLI に集約すれば、--should-publish を叩けば同じ答えが返ります。判断の正本を 1 箇所に置く という話です。
もう 1 つ。このファイルには、やらないことがヘッダに列挙されています。
このワークフローは以下を絶対に行わない:
mainへの直接 push / 自動マージ(PR を作るところまで。マージは Claude セッションが既存の回収経路で行う)/ Cloudflare へのデプロイ・Workers Builds の再トリガー / 生成コマンドへの--allow-partial-write(孤児シャード削除が不可逆)
権限(contents: write)は実際に与えられているので、コードで縛られた制約ではありません。それでも、後から誰かが「ついでにマージまでやらせよう」と手を入れるときに必ず目に入る場所に置いてあります。再トリガーを禁止しているのは、前節の CLI と役割が重ならないようにするためです。同じことをする経路を 2 つ作らない。
間違っていたことも残す
この連載で何度か、gem-hunter が 自分の記述を訂正した記録 を見ました。
- 第 4 回:
viewportの扱いについての当初の理解が誤りで、実機確認して訂正した記録 - 第 12 回: ライブリージョンの実装で、当初の方針を実測で覆した記録
- 第 17 回: flaky の原因推測が誤りで、真因が別にあったと判明した記録
- この回:
cpu_msを「締める」つもりで測ったら、逆に「上げる」必要があると分かった記録
いずれも 元の記述を消さず、訂正として残しています。
同じことをした人が「なぜこの形なのか」を辿れるようにするには、正解だけでなく そこへ至る間違い が要ります。これは設計文書の書き方として持ち帰る価値があります。
自分のプロジェクトへ持ち帰る順序
19 回ぶんを一度に導入するのは無理です。効果が出やすい順 に並べます。
① 依存の向きを決めて図にする
いまのコードの依存を図にするだけで、逆流している箇所 が見えます。理由は 3 つまでに絞る(第 6 回)。
② 機械検査を 1 本書く
図にしただけのルールは守られません(必ず そうなります)。禁止したい依存を 1 つだけ選んで、検査スクリプトを書きます。
grep 1 行でも構いません。CI で終了コード 1 になること が本質です。
③ 値オブジェクトを 1 つ作る
いちばん検証漏れが怖い値を 1 つ選び、ブランド型にします(第 7 回)。parse と tryParse の二段構えまで作れば、境界の設計が身につきます。
④ 外部 API の翻訳層を作る
外部サービスのフィールド名がアプリ内に散らばっているなら、変換を 1 箇所に集めます(第 9 回)。
⑤ テストの層 × 道具の表を決める
チームで 30 分話して表を作るだけ で、「これはどう書く?」の議論がなくなります(第 13 回)。
⑥ E2E を 1 本、仕様の手順に対応させる
いちばん大事な操作を 1 つ選び、test.step() を仕様書の手順の文言そのままにします(第 16 回)。
⑦ 判断を ADR に残し始める
次に技術選定をするとき、1 ファイルだけ書きます。却下した案とその理由 を必ず入れます(第 3 回)。
この連載で言いたかったこと
19 回を通して繰り返し出てきたのは、この 3 つです。
1. 規則は、機械で守れる形にして初めて守られる。 依存の向き・秘密情報の場所・TDD のコミット順・アクセシビリティ・コントラスト。文章で書いただけのものは、いつか必ず破られます。
2. 「エラーが出ない壊れ方」を知っているかどうかが差になる。 notFound() を Suspense の後ろに置く、Suspense に key を付けない、ライブリージョンを条件分岐で出す、キャッシュを関数内で new する、metadataBase を設定しない、レート制限を配線し忘れる、fail-closed で止めたあと再開する経路を作らない。どれもテストもビルドも通ります。
3. 判断は、却下した案ごと残す。 「なぜこうなっているのか」が分からないコードは、直すのが怖くなります。理由が残っていれば、状況が変わったときに堂々と変えられます。
おわりに
ここまで読んでいただき、ありがとうございました。
そして何より、gem-hunter を自分で clone して読んでみてください。この連載で扱えたのは、あのリポジトリに残っている判断のごく一部です。ADR も、設計ドキュメントも、コード中のコメントも、まだたくさんの「なぜ」が残っています。
シリーズの前後の記事
- ⬅️ 前の記事: 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
- ➡️ 次の記事: なし(全 19 回・この記事で完結です)
公開済みの回(17 本・どの回からでも読めます)
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。