Blogへ戻る

Language: 日本語

「main にマージしたら即公開?」 外部配信の事故を防ぐ公開ゲートと永続カナリアの CI 設計

Markdown や SSG(静的サイトジェネレーター)で技術ブログを運用していると、**「`main` ブランチにマージした瞬間に CI が走り、Qiita や Zenn などの外部プラットフォームへ記事が自動配信される」** というパイプラインを組むことがよくあります。

Markdown や SSG(静的サイトジェネレーター)で技術ブログを運用していると、main ブランチにマージした瞬間に CI が走り、Qiita や Zenn などの外部プラットフォームへ記事が自動配信される」 というパイプラインを組むことがよくあります。

自動配信自体は非常に便利ですが、運用が進むにつれて危険な課題が浮き彫りになります。

  • まだ執筆途中の下書き記事(draft: true)が、うっかりマージで意図せず全世界に公開されてしまう恐怖
  • 「今回のマージでどの記事が新しく公開され、どれが下書きのままなのか」を PR 画面で一目で確認できるセーフティネットがない
  • ビルド成果物(dist)の中に未公開記事の HTML やタイトルが漏洩していないかを機械的に保証できない

ビルド時フラグによる成果物の封じ込め(『「if (flag) で隠した未公開機能、JS バンドルに漏れていませんか?」 ビルド時フラグと Tree-shaking で成果物を削ぎ落とすフロントエンド設計』)の知見を活かし、ポートフォリオ兼ブログ基盤(Astro 製)に 「公開ゲート」と「永続カナリア」による二重の安全機構 を構築しました。

この記事では、長命ブランチと 1 本の PR による公開管理、ステータスコメントの自動生成、そして 「カナリア自身が無効化されたことも検知する」漏洩防止テスト の設計を解説します。

得られること

  • main マージによる外部配信事故を防ぐ 長命 blog ブランチと 1 本集約 PR の運用設計がわかる
  • Base と Head の frontmatter 差分から「新たに公開される記事」を明確に可視化するステータスコメントの仕組みが学べる
  • 未公開記事を隠すのではなく入力境界で切り落とす構造的防御の手法が身につく
  • 成果物(dist)を Unicode 復号まで含めて網羅走査する 自己検証付き永続カナリアテスト の実装がわかる!

1. 外部配信パイプラインとマージ前の「確認の場」

ブログサイトは Astro で構築されており、記事は src/content/blog/ja/ 配下の Markdown で管理されています。

main ブランチに push されると、配信ワークフロー(blog-distribution.yml)が自動で起動し、更新のあった記事を Qiita API で投稿し、Zenn 用の articles/ に同期してコミットします。

【 配信ワークフローの発火とリスク 】
[ main へマージ ] ──→ 💥 [ blog-distribution.yml が即時発火 ]
                            ├─→ Qiita API へ自動投稿
                            └─→ Zenn リポジトリへ自動コミット

ブランチで記事を書いている間は安全ですが、main にマージする直前の段階で 「本当にこの記事を今すぐ全世界に配信してよいのか?」 を落ち着いて確認できるレビューゲートが必要でした。

2. 1 本に集約した長命 blog ブランチと自動 PR 運用

記事ごとに個別 PR を乱立させると、「どの PR をマージすると何が公開されるのか」の判断が分散して見落としの原因になります。

そこで、長命な blog ブランチ を 1 本常設し、main に向けた PR を常に 1 本だけ維持する運用を採用しました。

# blog ブランチへの push 時に open な PR があるか確認
open_count=$(gh pr list --repo "$GITHUB_REPOSITORY" --head blog --base main --state open --json number --jq "length")

if [ "$open_count" -ge 1 ]; then
  echo "✅ 既に blog -> main のオープンな PR が存在します。"
  exit 0
fi

# PR がなければ差分コミットを確認して自動作成
diff_count=$(git rev-list --count main..blog)
if [ "$diff_count" -ge 1 ]; then
  gh pr create --base main --head blog --title "chore(blog): release content updates" --body "..."
fi

記事の追加や推敲のコミットはすべてこの blog ブランチに積み上がっていき、1 つの PR の上で公開準備が進みます。

3. 「このマージで新たに公開される記事」を浮き彫りにするステータスコメント

blog ブランチにコミットが push されるたびに、CI が Base(main)と Head(blog)の Markdown frontmatter(draft フラグ)の差分を解析し、PR の最下部にステータスコメントを自動投稿します。

## Blog status

### このマージで新たに公開される 🚀
| | 記事 |
|---|---|
| ● | 23-production-log-hygiene.md |

### 公開 (`draft: false`)
| | 記事 |
|---|---|
| ✚ | 新規追加された公開記事 |
| ● | 既存の公開記事の更新 |

### 非公開 (`draft: true`)
| | 記事 |
|---|---|
| ✚ | 執筆中の下書き記事 |
| ● | 下書き記事の更新 |
| ✖ | 削除された下書き |

✚ 追加 / ● 変更 / ✖ 削除

コメント設計の工夫

  • 「新たに公開される」を先頭に配置: Base で draft: true、Head で draft: false に切り替わった記事だけを最上段に独立表示。マージで実際に外部配信される対象が 1 秒で判別できます。
  • 最新状態を常にタイムライン末尾に届ける: コメントを単に edit すると過去のタイムラインに埋もれるため、「識別用 HTML コメントマーカー付きの旧コメントを全削除 ➔ 新しいコメントを最下部に新規投稿」する方式を採用。
  • 差分行数の排除: 行数は GitHub の Files changed タブを見れば十分なため、ノイズを排除して記事タイトルと公開状態の変化だけに集中。

4. 未公開記事を隠すのではなく「入力境界で落とす」

未公開記事をサイト上で非表示にする際、最初はビルド時の Feature Flag で隠す方法も検討しました。

しかし、公開記事と未公開記事を同じビルドツリーに同居させると、「フラグの評価漏れで未公開記事の HTML が生成されてしまう」リスクが常に付きまといます。

そこで、「全記事を読み込むエントリーポイントで即座に draft: true を filter して切り落とす」 設計に倒しました。

// src/content/config.ts 等の入力エントリーポイント
export async function getPublishedBlogEntries() {
  const entries = await getCollection('blog');
  
  // 入力の最前線で未公開記事を完全に遮断
  return entries.filter((entry) => !entry.data.draft);
}

未公開記事は Astro のページ生成パイプラインの入力にそもそも届かないため、「HTML や JSON 成果物(dist)に未公開記事が漏洩する経路が構造上ゼロ」 になります。

5. 自己検証付き「永続カナリア」による漏洩検知

構造的な防御に加えて、万が一のフレームワーク側の挙動変更やバグに備え、「永続カナリア(Permanent Canary)」 を配置しました。

記事ディレクトリに、常に draft: true に設定されたカナリア記事を 1 本常設しています。

---
title: "CANARY_TITLE_NEVER_PUBLISH"
draft: true
---
CANARY-MARKER: secret-canary-payload-token

本番ビルド完了後、CI ワークフローが dist/ ディレクトリ配下の全ファイル(HTML、JS、JSON、XML サイトマップ)を走査し、カナリアのタイトルやマーカー文字列が含まれていないかを徹底検証します(Unicode エスケープ \uXXXX された文字列も復号して検証)。

カナリアが無効化されたことも検知する「自己検証ガード」

カナリアテストにおける最大の盲点は、「開発者が誤ってカナリア記事の draftfalse に変えてしまったり、マーカーを消してしまった場合、カナリアが沈黙してテストが素通りしてしまう」 ことです。

そこで、カナリアテストの実行スクリプトの入口に、カナリア自身の健全性を検証するアサーションを組み込みました。

// canary-leak-test.mjs の入口
const canaryData = parseFrontmatter(CANARY_FILE_PATH);

// カナリア自身が draft: true でない場合はテスト自体を即座に落とす
if (canaryData.draft !== true) {
  throw new Error(
    `❌ 重大エラー: カナリア記事(${CANARY_FILE_PATH})の "draft: true" が解除されています! 漏洩検知を維持するため draft: true に戻してください。`
  );
}

if (!canaryContent.includes('CANARY-MARKER:')) {
  throw new Error(`❌ 重大エラー: カナリアマーカーが見つかりません。`);
}

「検知の仕組みは、自分が無効化されたことも検知できなければならない」。この自己検証ガードにより、カナリアが誤って骨抜きにされるリスクを完全に封じ込めています。

まとめ: 配信の自動化には、安全なゲートと監視が不可欠

CI/CD による外部プラットフォームへの自動配信を成功させるための要点は、次の 3 つです。

  1. 判断を 1 箇所に集約する: 長命ブランチと 1 本の PR で公開状態を集中管理する
  2. 公開差分を可視化する: Base と Head の frontmatter 差分から「新たに公開される記事」をコメントで明示する
  3. 入力で切り落とし、カナリアで見張る: 未公開記事をビルドツリーから構造的に排除し、自己検証付きカナリアテストで成果物のクリーンさを常に保証する

この公開ゲートがあることで、日々の執筆では安心して下書きをコミットし、マージの瞬間には確信を持って「公開」ボタンを押すことができます。

参考リンク