要点
対象:AIに記事を書かせる媒体のエンジニア
- 壊れた記事はCIで止め、本番は1本だけ外す
- 機械が書き足すデータはデータベースに置く
- 監視は最後のデプロイとmainの一致で足りる
AIに記事を書かせるなら、記事の正本を Git のファイルに置き、プルリクエスト(PR)のマージを公開の操作にすると、承認の手続きが PR に一本化されます。 履歴・レビュー・差し戻しが、コードと同じ仕組みで済みます。このサイトはそう作ってあります。その代わり、公開の速さと、エンジニアでない人の編集のしやすさを手放しました。
この記事の目次(10節)
記事をコードとして扱うとは
記事をコードとして扱う運用では、管理画面で本文を保存し、データベースに保存する経路を作りません。記事は content/articles/<slug>.md という普通のテキストファイルです。先頭の frontmatter(YAML で書くメタデータ)に、タイトル・分類・出典を書きます。編集は差分、レビューは PR のレビュー、公開はマージ、取り消しは revert(変更を打ち消すコミット)になります。
CMS を導入しなかった理由
もともとの設計では、CMS(記事を管理画面で書いて保存する仕組み)に近いものを自前で作るつもりでした。本文をオブジェクトストレージに保存し、バージョン管理と承認のテーブルを持つ形です。やめた理由は3つあります。
承認の仕組みがすでにあった
この記事を書いた当時、このシステムは開発そのものを AI に任せていました。実装・レビュー・取り込みを別々のAIが PR を通して行う仕組みが先にあり、人が関わらなくても最後まで流れることも確かめていました。記事をそこに乗せれば、新しい仕組みは要りません。
例えば、社内の技術ブログを AI に下書きさせ、エンジニアが PR でレビューして公開するチームなら、承認の仕組みを新しく作らずに済みます。
履歴は Git に最初から備わっている
すべての版と差分、誰がいつ何をなぜ変えたか、好きな時点に戻す機能も、Git に最初からあります。バージョン管理のテーブルを自前で作るのは、Git の劣化版を作るようなものです。
公開の前提だった作業が2つ要らなくなった
オブジェクトストレージの有効化と、API キーの発行です。記事の本文は数十KBのテキストで、置き場を別に用意するほどのものではありません。
ビルド時に記事を埋め込む
公開サイトは Cloudflare Workers の上で動いています。記事が読者に届くまでの流れは次のとおりです。
content/articles/*.mdが正本- ビルドのとき、スクリプトが全ファイルを読み、ファイル名と中身の文字列の組にする
- 公開サイトの Worker が、その文字列を実行時に解釈して配信する
- マージすると Workers Builds が再ビルドし、本番環境に反映される(執筆時点では、ここだけ未接続だった)
Cloudflare の Workers Builds は、Worker を GitHub か GitLab のリポジトリにつなぎ、push のたびに自動でビルドとデプロイをする仕組みです。1 Cloudflare を使うなら、CI を自分で書かなくても、マージするだけでデプロイされる構成にできます。
マージしても本番環境に反映されていなかった
執筆時点では1〜3が動いていましたが、それまでのデプロイは、手元から手動で実行した1回だけでした。この記事が説明する公開=マージは、当時この記事にはまだ当てはまっていませんでした。
問題は、この状態に約2時間気づかなかったことです。その間にマージされた記事1本とサイトの修正1本が、本番環境に反映されていませんでした。この時間は運用記録によるもので、開発リポジトリは非公開のため、読者が確かめられる出典はありません。
記事はマージされ、CI も通り、リポジトリの上では公開が済んだように見えます。マージとデプロイを一致させる構成は、一致していることを別の経路で確かめて初めて完成します。監視するのは、最後にデプロイしたコミットが main の最新コミットと一致しているかどうかの1点で足ります。
ビルドスクリプトは文字列にするだけ
あえて決めたことが1つあります。ビルドスクリプトは、ファイルを読んで文字列にする以上のことをしません。frontmatter の解釈と検査は公開サイトの側の1つのモジュールに集め、CI でも、本番と同じコードで検査します。ビルドのとき(Node)と実行のとき(Workers)で実装が分かれると、CI を通った記事でも、本番では別の解釈になるおそれがあります。
公開サイトの Worker には、データベースをつないでいません。記事が Git にある以上、公開の側からデータベースへ届く経路は、最初から作らずに済みます。
検査は CI で行い、スキーマは厳格に定義する
frontmatter を埋めるのは AI なので、人がレビューする前に機械で落とすほうが速く済みます。実際に効いている検査は次のとおりです。
| 検査 | 落とす対象 | なぜ |
|---|---|---|
| 未定義のキーを拒否 | medical_risks: のような綴り違い | 任意の項目として黙って通ると、分類が消える |
ファイル名 = slug | icu-origin.md に slug: icu | URL とファイルの対応が崩れる |
sources が1件以上 | 出典のない記事 | 出典のない断定を公開しないため |
medical_risk の値を固定 | low/medium/high 以外 | 公開の承認の流れが分類で分かれる |
| 出典の URL は http/https だけ | javascript: や data: | 出典のリンクからスクリプトが動く |
最後の行は、記事のスキーマがセキュリティ境界を兼ねている例です。一般的な URL バリデーターには、javascript: を通してしまうものがあります。その AI は外部のページを読みますし、記事と同一オリジンには問い合わせフォームもあります。そのため目視ではなく CI で落とし、表示する側でも二重に絞っています。
記事の検査は、content/articles/ の実際のファイルを1本ずつ読みます。壊れた記事はマージできません。
壊れた記事を、CI では止め、本番では外す
同じ壊れた記事に対して、CI と本番は逆の動きをします。
- CI: 1本でも壊れていたら落とす(マージさせない)
- 本番: 壊れた1本だけを配信から外し、理由をログに出して、ほかは配信する
マージの前なら、止めても損は小さく済みます。本番では、1本の不備のために全ページがエラー(500)になるほうが、はるかに高くつきます。止めるほうが安全か、動かし続けるほうが安全かは、場所によって変わります。
手放したもの
公開までの時間
当時はレビューと取り込みが定時のループで回っていたので、書き上がってから公開まで最大1時間強かかる見込みでした。ループの起動の間隔から見積もった値で、自動デプロイがつながっていなかったので、実測はできませんでした。1日に数本なら問題になりませんが、速報を出す媒体には向きません。
エンジニアでない人の編集
管理画面から本文を直せません。書き手が AI で、レビューが PR だから成り立っています。人が毎日本文を直す運用なら、手間になります。
本文の A/B テスト
本文はビルド時に埋め込まれるので、本文の A/B テストはできません。タイトル・CTA・導線の出し分けは、配信のときの上書きで行います。本文は、版を差し替えてマージし、その前後で比べる設計にしました。
運用でつまずいた点
ビルドコマンドは、設定ファイルの場所ではなく、プロセスのカレントディレクトリを基準に実行されていました。手元の型チェックもテストも素通りし、デプロイのビルドで初めて落ちる種類の誤りです。設定を変えたら、デプロイの前に dry-run(実際には反映しない試しの実行)で確かめてください。
データベースは運用データに使う
記事の正本は Git ですが、計測・見込み客・実験・監査ログのような運用データは、データベースに置いています。使っているのは Cloudflare の D1 で、SQLite の SQL の書き方で扱える、サーバーレスのマネージドデータベースです。2
境目はこう引きました。人が読むために書かれ、履歴とレビューが要るもの(記事・仕様・意思決定)は Git に置きます。機械が書き、時間とともに増えるもの(計測・監査)はデータベースに置きます。
よくある質問
Q. 公開した記事を取り消すには、どうしますか。
該当するコミットを revert してマージします。復元も差し戻しも Git の機能で済み、専用の仕組みを作る必要はありません。
Q. 記事が1本壊れていたら、サイト全体が止まりませんか。
止まりません。本番では壊れた1本だけを配信から外し、理由をログに出して、ほかの記事は配信を続けます。
自分の媒体で真似するなら
- 書き手が AI で、公開の前に人が PR でレビューする運用なら、この形が合います
- 人が毎日本文を直す媒体や、速報を出す媒体には勧めません
- 取り入れるなら、生成より先に、検査と差し戻しの経路を用意してください
出典
- Cloudflare Docs(公式ドキュメントのソース)— Workers Builds(参照 2026-08-09)
- Cloudflare Docs(公式ドキュメントのソース)— D1 overview(参照 2026-08-09)
