公開ガードのCIをわざと破ったら、本当に穴が見つかった

· dev-log ci github-actions ssg

本記事にはアフィリエイト広告(PR)が含まれます。

このサイトの記事は、AI が下書きを作り、人間がプレビューで全文確認して承認しないと公開されない仕組みの上に載っています。その「承認なしでは公開できない」を守る CI ガードを実装した当日、受け入れ確認としてわざと破ってみたら、本当に穴が見つかりました。その一部始終の記録です。

前提: 記事の状態と公開ゲート

このサイトは自作の静的サイトジェネレータで動いていて、記事は Markdown の frontmatter に status(draft / review / published)を持ちます。本番ビルドは published の記事だけを出力します。公開までの通り道はこうです。

  1. 執筆ブランチで status: review にして Pull Request を作る
  2. CI がスキーマ・出典の有無・状態遷移を検査し、プレビュー環境にデプロイされる
  3. 人間がプレビューで全文を確認してマージする(これが承認)
  4. 公開は「status を published に書き換えるだけの PR」を人間がマージした瞬間

このうち「記事 PR の中で published へ直行してはいけない」という遷移ルールを、CI 上の検査スクリプトが守っています。main ブランチは保護されていて、この検査を通らない PR はマージできません。

「効くはず」を破ってみる

ガードは作っただけでは信用できないので、受け入れ確認として、わざと status: published を直書きした記事を含む PR を作りました。スキーマ的には完全に正しい記事にして、遷移ルールだけに違反させます。期待は CI が赤くなること。

結果は緑でした。素通りです。守るべき不変条件の中心が、初日から破られました。

原因: git のパススペックは cwd 相対

CI のログを見ると、検査スクリプトは「記事の変更なし」と報告していました。差分検出にはこういうコマンドを使っています。

git diff --name-status origin/main...HEAD -- site/content/articles

問題はコマンドではなく実行場所でした。GitHub Actions のジョブは defaults.run.working-directorysite に設定しています。git のパススペックはカレントディレクトリからの相対で解決されるため、site の中から実行すると site/site/content/articles を探しに行き、何にもマッチしません。差分ゼロ、検査対象なし、緑。ローカルで同じディレクトリから実行したら、まったく同じ空振りが再現しました。

修正と回帰テスト

修正そのものは小さく、git 呼び出しをすべてリポジトリルート起点にするだけです。

const repoRoot = sh("git rev-parse --show-toplevel");
const git = (args: string) => sh(`git -C "${repoRoot}" ${args}`);

ただし同じ穴を二度掘らないために、先に回帰テストを書きました。テスト内で一時的な git リポジトリを作り、CI と同じく cwd を site にして検査スクリプトを実行し、「published 直行の PR が拒否されること」と「正規の公開 PR は通ること」を確認します。現行コードでこのテストが落ちるのを見てから修正し、緑になるのを確認しました。変更は2ファイル、約110行です。

修正を取り込んで例の違反 PR を再実行すると、今度は content-pr-no-publish(記事PRでは published へ遷移できない)で赤くなりました。これでようやく「ガードがある」と言えます。

この日の教訓

  1. ガードは「効くはず」のまま運用に入れない。 守りたい不変条件があるなら、それを破る操作を最初の受け入れテストにする。破ってみて赤くなるまでは、ガードは存在しないのと同じ
  2. テストに設定の実値をハードコードしない。 実は同じ日にもう1件、サイトURLをテストに直書きしていたせいで、URL設定を本番値に変えた PR がテストを壊す事故も起きた。テストは設定オブジェクトから値を導出する形に直した
  3. 検証コマンドをパイプで繋ぐと、終了コードは最後のコマンドのものになる。 npm test | tail で確認した気になり、テスト失敗を見逃した。成否は PIPESTATUS で元コマンドの終了コードを見る

穴が見つかったのが、本物の記事を公開する前の受け入れ確認だったので、被害はゼロでした。それは運が良かったのではなく、運用に入る前に自分で破ってみたからです。

出典