生成AIでFAQ、記事、メール文面の下書きを増やすと、公開前の確認がチャットや口頭に散らばりやすくなります。自動チェックを入れても、それだけで内容の正しさや公開可否を判断することはできません。
この記事では、GitHubのPull Request(PR)とGitHub Actionsを使い、形式上の不備は自動で止め、事実・権利・顧客影響は人間が承認する最小構成を実装します。Node.jsだけで動く検査スクリプト、PRテンプレート、Actions設定まで用意します。
この記事で作るもの
公開コンテンツを次の流れで確認します。
- AIまたは担当者がMarkdownを変更する
- PRを作成する
- GitHub Actionsがfrontmatter、禁止プレースホルダー、危険な文字列候補を検査する
- CODEOWNERSに指定した人が差分と根拠を確認する
- 承認後にマージし、公開URLを確認する
図は問い合わせ返信の例ですが、重要な境界は同じです。AIと自動処理は候補作成と機械検査まで、外部公開は人間確認後に限定します。
今回の構成は記事の自動公開、AIによる自動承認、秘密情報の完全検出を行いません。
ディレクトリを用意する
.
├── .github
│ ├── CODEOWNERS
│ ├── pull_request_template.md
│ └── workflows/content-check.yml
├── content
│ └── example.md
├── scripts
│ └── check-content.mjs
└── package.json
content/ は公開候補だけを置く場所にします。顧客の問い合わせ原文、契約書、Cookie、APIキーなどを下書き用リポジトリへコピーしないでください。
検査ルールを設定する
content-rules.json を作ります。
{
"requiredFrontmatter": ["title", "reviewer", "source_checked", "publish_ready"],
"blockedPlaceholders": ["TODO", "TBD", "要確認", "あとで直す"],
"suspiciousPatterns": [
"BEGIN PRIVATE KEY",
"ghp_[A-Za-z0-9]{20,}",
"sk-[A-Za-z0-9]{20,}",
"\\{PAID_URL\\}"
]
}
suspiciousPatterns は漏えい防止の最後の網ではなく、誤投入の候補を止める補助です。実在する秘密値をテストデータへ書かず、ダミー文字列を使います。
公開候補のfrontmatterは次のようにします。
---
title: 問い合わせFAQを更新する
reviewer: cs-owner
source_checked: true
publish_ready: false
---
source_checked は参照元を人間が確認したか、publish_ready は公開可能と判断したかを表します。AIにこの2項目を自動でtrueへ変更させない運用にします。
Node.jsでMarkdownを検査する
scripts/check-content.mjs を作ります。外部パッケージを使わない最小例です。
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const rules = JSON.parse(
fs.readFileSync(path.join(root, 'content-rules.json'), 'utf8')
);
function listMarkdown(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return listMarkdown(target);
return entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
});
}
function parseFrontmatter(text) {
if (!text.startsWith('---\n')) return null;
const end = text.indexOf('\n---\n', 4);
if (end < 0) return null;
const values = {};
for (const line of text.slice(4, end).split('\n')) {
const separator = line.indexOf(':');
if (separator < 0) continue;
values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
}
return values;
}
const errors = [];
const contentDir = path.join(root, 'content');
for (const file of listMarkdown(contentDir)) {
const relative = path.relative(root, file);
const text = fs.readFileSync(file, 'utf8');
const frontmatter = parseFrontmatter(text);
if (!frontmatter) {
errors.push(`${relative}: frontmatterがありません`);
continue;
}
for (const key of rules.requiredFrontmatter) {
if (!(key in frontmatter) || frontmatter[key] === '') {
errors.push(`${relative}: ${key}がありません`);
}
}
for (const marker of rules.blockedPlaceholders) {
if (text.includes(marker)) {
errors.push(`${relative}: 未解決の表記「${marker}」があります`);
}
}
for (const source of rules.suspiciousPatterns) {
if (new RegExp(source).test(text)) {
errors.push(`${relative}: 秘密情報または未置換値の候補があります`);
}
}
}
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
console.log('content check passed');
package.json に実行コマンドを追加します。
{
"scripts": {
"check": "node scripts/check-content.mjs"
}
}
ローカルでは次を実行します。
npm run check
検出結果をそのまま外部通知へ流すと、秘密値の一部をログへ再掲する危険があります。この例は一致した値を表示せず、ファイル名と検出種別だけを表示します。
GitHub ActionsでPRごとに実行する
.github/workflows/content-check.yml を作ります。
name: content-check
on:
pull_request:
paths:
- "content/**"
- "scripts/check-content.mjs"
- "content-rules.json"
- "package.json"
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm run check
PR検査には書き込み権限や公開用シークレットを渡しません。公開処理が必要な場合も、承認済みのmainへマージされた後の別ワークフローに分けます。
CODEOWNERSとPRテンプレートを追加する
.github/CODEOWNERS で確認者を固定します。
/content/ @your-org/content-reviewers
/.github/workflows/ @your-org/ops-reviewers
.github/pull_request_template.md には、人間が判断する項目を残します。
## 変更の目的
- 対象読者:
- AIを使った範囲:
- 参照元:
## 公開前確認
- [ ] 事実、数値、リンクを参照元で確認した
- [ ] 個人情報、顧客情報、秘密情報を含めていない
- [ ] 引用、画像、商標などの利用条件を確認した
- [ ] 誤解を招く成果保証や未確認の実績がない
- [ ] 公開後の確認担当と取り下げ方法を決めた
## 最終判断
- 確認者:
- 判断: 承認 / 修正依頼 / 公開中止
- 理由:
リポジトリのrulesetでは、PR必須、必要な承認数1以上、CODEOWNERS承認、content-check成功を必須にします。新しいコミットで古い承認を無効にする設定も有効です。
テストする
少なくとも次の3ケースをPRで確認します。
- 通常例: 必須項目が揃い、チェックが成功する
- 境界例:
publish_ready: falseでも下書きとして検査は成功する - 停止例:
TODOやダミーの秘密情報パターンがあると失敗する
自動チェック成功後、人間は本文、差分、根拠、権利、公開範囲を読みます。CI成功は機械的条件を満たした証拠であり、公開承認ではありません。
公開後の完了条件
マージだけで公開済みにせず、次を確認します。
- 公開URLがHTTP 200を返す
- タイトル、本文、画像、コードが表示される
- 下書き注記や未置換値が残っていない
- 外部リンクが意図したページを指す
- 問題時の修正または取り下げ手順が使える
外部サービスの反映待ちは「マージ済み・公開未確認」と記録します。この区別があると、次の担当者が公開確認の残作業を判断できます。
まとめ
AI生成コンテンツの公開前チェックは、すべてをAIに判定させる仕組みではありません。形式、未置換値、秘密情報らしい文字列はCIで早く止め、事実、権利、顧客影響、公開可否は責任を持つ人が判断します。
最初は公開ディレクトリを1つ選び、npm run check、CODEOWNER 1人、PRテンプレート5項目から始めると運用しやすくなります。差し戻し理由が発生したら、自動検査へ加えるものと人間確認へ残すものを分けて更新してください。
