重複確認した既存記事:
- allow-listを絞りすぎたパイプラインは「在庫があるのに0件」と報告して静かに空振りする
- GASで職員会議の議題フォームを会議アジェンダに自動整形する
- Playwrightで「固定秒数待ち」がタグ入力を黙って飛ばした話
- 自動化パイプラインで「カードの日付」と「実行日の日付」を混ぜると依存解決が静かに失敗する
- allowed_context_filesをbasenameだけでステージングして27件落とした話
何が起きたか
自動投稿の前段で、Markdown本文を作るワーカーと、本文をそのまま投稿するワーカーを分けて運用している。
この形だと、本文を作る側が「最終確認してください」「必要なら差し替えてください」のような運用メモを本文中に入れてしまうと、投稿側はそれも本文として扱ってしまう。
実際の運用ログにも、投稿側は「本文そのまま」「本文は無改変」で公開する前提の記録が残っている。つまり、本文生成側で混入を止めないと、投稿側で自然には消えない。
今回の穴は、allow-listや日付解決ではなく、人間向けの申し送りが、公開本文の一部として流れてしまうという境界の問題だった。
再現用の小さい構成
まず、投稿本文を1つ作る。
POST_QIITA_01
TITLE=投稿原稿に運用メモを混ぜない
TAGS=自動化,Node.js,Markdown,CI,運用
# 投稿原稿に運用メモを混ぜない
本文はここから始まります。
公開前に人が確認してください。
この最後の1行は、作業メモとしては自然に見える。でも、投稿側がMarkdownをそのまま使う設計なら、これは公開本文になる。
次に、本文を受け取る側を極端に単純化する。
import fs from "node:fs";
const markdown = fs.readFileSync("post.md", "utf8");
const title = markdown.match(/^TITLE=(.+)$/m)?.[1];
const tags = markdown.match(/^TAGS=(.+)$/m)?.[1].split(",");
const body = markdown
.replace(/^POST_QIITA_01\s*/m, "")
.replace(/^TITLE=.+\r?\n/m, "")
.replace(/^TAGS=.+\r?\n/m, "")
.trim();
console.log({ title, tags, body });
実行すると、body の末尾に「公開前に人が確認してください。」が残る。これはパーサのバグではない。入力が公開本文として成立していないのに、機械的には正常なMarkdownだから通ってしまう。
直し方
投稿処理の直前ではなく、本文生成の成果物を受け取った時点で検査する。
今回ほしい検査は大きく3つ。
- 投稿ラベル、タイトル、タグがある
- 公開本文に残してはいけない運用メモがない
- 認証情報らしい文字列やローカルの絶対パスがない
次のような小さい検査スクリプトで十分だった。
import fs from "node:fs";
const file = process.argv[2] ?? "post.md";
const text = fs.readFileSync(file, "utf8");
const required = [
[/^POST_QIITA_01$/m, "POST_QIITA_01 label is missing"],
[/^TITLE=.+$/m, "TITLE is missing"],
[/^TAGS=([^,\n]+,){4}[^,\n]+$/m, "TAGS must contain 5 tags"],
[/```[\s\S]*?```/m, "at least one code block is missing"],
];
const forbidden = [
[/下書き|要確認|確認してください|差し替えてください|必要なら|人が確認/u, "editorial memo remains in body"],
[/[A-Z]:\\[^ \n\r\t`]+/u, "local absolute path remains"],
[/[A-Za-z0-9_-]{32,}/u, "long credential-like string remains"],
];
const errors = [];
for (const [pattern, message] of required) {
if (!pattern.test(text)) errors.push(message);
}
for (const [pattern, message] of forbidden) {
if (pattern.test(text)) errors.push(message);
}
if (errors.length > 0) {
console.error(errors.map((e) => `- ${e}`).join("\n"));
process.exit(1);
}
console.log("ok");
この検査は、内容の良し悪しを判定しない。見るのは「公開本文として流してよい最低条件を満たしているか」だけに絞る。
ここを欲張って、文章品質やSEOまで1本の検査に入れると、失敗時に何を直せばよいかが曖昧になる。事故防止の検査は、落とす条件を狭く、説明できる形にした方が扱いやすい。
再現手順
作業用ディレクトリに post.md と check-post.mjs を置く。
node .\check-post.mjs .\post.md
最初の例では、次のように落ちる。
- editorial memo remains in body
そこで、本文末尾の運用メモを消す。
POST_QIITA_01
TITLE=投稿原稿に運用メモを混ぜない
TAGS=自動化,Node.js,Markdown,CI,運用
# 投稿原稿に運用メモを混ぜない
本文はここから始まります。
もう一度実行する。
node .\check-post.mjs .\post.md
今度は通る。
ok
実装で気をつけた点
1. 「投稿側で消す」ではなく「成果物側で落とす」
投稿側で禁止語を削除する実装にすると、本文が勝手に変わる。
今回のように、投稿側が本文をそのまま扱う前提なら、投稿直前の自動修正より、成果物を作った時点で失敗させる方が追いやすい。
2. 禁止語は増やしすぎない
禁止語を増やしすぎると、普通の技術説明まで落ちる。
たとえば「レビュー」「確認」だけで落とすと、コードレビューや確認用ログの説明まで巻き込む。今回は、公開本文に残ると明らかにおかしい表現に寄せた。
const forbiddenEditorialMemo =
/下書き|要確認|確認してください|差し替えてください|必要なら|人が確認/u;
3. ローカルの絶対パスは本文から消す
公開記事に作業端末の絶対パスが入ると、読者には不要な情報になる。さらに、環境名やユーザー名が見える場合もある。
検査では、Windowsの絶対パスらしいものを落とすだけでも効果があった。
const windowsAbsolutePath = /[A-Z]:\\[^ \n\r\t`]+/u;
必要なら、記事内では ./post.md や ./check-post.mjs のような相対パスに置き換える。
まとめ
自動化で怖いのは、派手に失敗する処理だけではない。
本文としてはMarkdownの形をしている。でも、公開してはいけない作業メモが混ざっている。こういう入力は、パーサや投稿処理から見ると正常に見える。
今回の対策は、投稿処理を賢くすることではなく、成果物の境界で静的検査を1つ足すことだった。
本文をそのまま流すパイプラインでは、「そのまま流してよい本文か」を、本文ができた直後に見る。これだけで、運用メモ混入の事故はかなり減らせる。