結論
個人リポのCIが、5ヶ月ずっとエラーになることがなかった。
優秀だったからじゃなくて、どう転んでも失敗しようがない書き方をしていたから。
そのCIは僕が書いたものじゃなくて、AIに作ってもらった。
で、正直に書くと、レビューして通したというより、出てきたものを眺めて「ちゃんとしてそう」で受け取っただけ。
しかもその間、GitHub Actions の分数は消費され続けていた。
30日で約266分、なにも検知しないまま。
普段は仕事で、人が上げてきたものをレビューして承認する側にいるんだけど、その僕が、自分がAIから受け取るときには承認と呼べる工程を踏んでいなかった。
だからこれはCIのバグの話じゃなくて、承認の事故の話。
何が動いていたか
gtd/ と zettelkasten/ 配下の Markdown に YAML frontmatter があるかを、push と PR のたびに全件チェックする step。
実物がこれ。
- name: YAML frontmatter の存在チェック
run: |
echo "=== frontmatter チェック ==="
errors=0
while IFS= read -r file; do
head -1 "$file" | grep -q '^---' || {
echo "⚠ frontmatter が見つかりません: $file"
errors=$((errors + 1))
}
done < <(find gtd/ zettelkasten/ -name '*.md' -not -name '.gitkeep' 2>/dev/null)
if [ "$errors" -eq 0 ]; then
echo "✓ 全ファイルに frontmatter があります"
else
echo "⚠ ${errors} ファイルに frontmatter がありません"
fi
先に言っておくと、僕はこれを自分で読んで気づいたわけじゃない
→僕はコードを書かない側の人間で、正直、このソースを渡されて眺めても見つけられなかったと思う
気づいたのは5ヶ月経ってから、CI をまるごと Claude と見直していて、Claude に見つけてもらった。
この step には exit 1 がない。
errors を数えて、違反ファイル名を出して、件数も出して、成功と失敗で分岐までしている。
そのうえで何も返してないから、この step は絶対に成功する。
さらに同じ workflow のリンクチェック step には continue-on-error: true が付いていた。
つまりこの workflow は、どんな違反があっても失敗にならなくて、検知でエラーになったことは5ヶ月間で一度もない。
で、実際に frontmatter が欠けたファイルが1件、2ヶ月半見逃されていた。
CIは毎回それを検出して、ログに ⚠ を出して、成功で終わっていた。
なぜ僕は疑わなかったのか
ここが本題で、3つあると思っている。
1. 報告の形が整っていたから。
このCIにはエラーカウンタがあるし、違反したファイル名も出すし、成功と失敗で分岐もしてるし、警告の絵文字まで付いている
→って、ソースを読んで確かめたわけじゃない
→作ってもらったときに AI がそう報告してきて、実際ログにもそのとおり出ていた
で、報告は嘘じゃなくて、カウンタも分岐も警告も本当に全部ある。
なかったのは exit 1 だけ。
やってることの列挙が全部本当でも、効いてるかどうかは別だったんだよね。
報告の形が整ってると、人間は「ちゃんとしてそう」で受け取っちゃうんだよね、それも5秒で。
効いてるかどうかは報告からは分からなくて、失敗するかどうかでしか分からない。
AIが作ったものはここが厄介で、形と報告だけは絶対に崩さない。
だから形で受け取るのが一番危ない
→人間が手を抜いた仕事は、コードも報告もどこか崩れるから一応引っかかる
→AIは崩れない
2. ずっと成功していたから。
当たり前だけど、書いておく価値があって、成功が続いてるから問題ないんだと5ヶ月思ってた。
でもCIの「成功」って、落ちる条件をひとつも書いてなくても出るから、検査して問題なしなのか、そもそも検査してないのか、結果だけじゃ区別がつかない。
3. 「ログには警告が出てます」で満足したから。
これがいちばん厄介で、このCI、違反を検出して、ファイル名を出して、警告もして、仕事はしてたんだよね。
でも、成功のログなんて普通見ないよね。笑
あ、だから「見逃した」っていうのも正確じゃないな、出したって見てないんだから、見逃すも何もない。
「ログに出す」がゴールになってる設計を受け取った時点で、こうなるのは決まってた。
ログって、落ちて通知が来て初めて開くものだし。
受け取るときに聞くべき質問は、ひとつだけだった。
これ、本当に落ちるのか?
聞いてなかったわ・・・
さらに悪いこと。途中で一度、手を入れている
約4ヶ月目に、この workflow を触っている。
理由は「Actions の分数を食いすぎている」。
Claude に頼んで、リンクチェックを週次化して、concurrency で二重実行を止めた。
分数は減ったし、効果はあった。
でも触ったのはリンクチェックのほうだけで、push ごとに走る frontmatter チェック(なにも検知してない側)は、そのまま残した。
「消費が多い」ほうは治って、「なにも検知してない」ほうはそのまま残った。
しかも「CIは見直した」という記憶だけが残るので、次に疑う機会まで遠のく。
これが一番の教訓だと思ってて、コストが気になって触ってるときは、コストしか見てない。
触るついでに「これは何を検出してるのか」「検出したら何が起きるのか」を1回声に出せばよかったんだけど。
どう直したか
これも僕が手を動かしたわけじゃなくて、Claude と壁打ちして決めた。
CI を直すんじゃなくて3層に割り直すことにして、コードは Claude が書いた。
ちなみに hook の存在自体は前から知ってて、ちゃんと理解してなくて使えてなかっただけ。
使えるようになった起点は会社のAI勉強会
→若手と話していて「仕様駆動開発」「コンテキストエンジニアリング」「ループエンジニアリング」という3つの言葉が出てきて、自分がやってることとの対比を考えた方がいいなと思った
その日のうちに Claude と調べて自分のリポに当てはめて、「文書のお願いを hook の保証に変える」で6本まとめて入れて、調べた結果はペライチにしてメンバーにも配った。
今回の frontmatter チェックは、その延長の12本目
→若手と同じ目線で勉強会に混ざって、拾った言葉をスキマ時間で調べて自分の頭に落として、まとめて共有する
→こういうことをするのが大事なんだと思う、僕らの層として
だから今回のは、勉強会の流れで学んだ仕組みを、この事故にも当てただけ。
1. 検証は手元の hook に移す
frontmatter チェックは Node のスクリプトにして、Claude Code の PostToolUse hook から呼ぶようにした。
ファイルを書いた直後に判定して、違反なら decision: "block" を返す。
要点だけ抜粋するとこう。
// 判定は開始 --- と閉じ --- の有無だけ。小さく単純に
function violationOf(absPath) {
const lines = fs.readFileSync(absPath, 'utf8').split(/\r?\n/);
if (lines[0] !== '---') return 'frontmatter が無い(1行目が --- でない)';
for (let i = 1; i < lines.length; i++) {
if (lines[i] === '---') return null; // 閉じがあれば OK
}
return 'frontmatter が閉じていない(閉じ --- が無い)';
}
// 違反なら echo じゃなく block を返す。書いた本人(AI)の手が止まる
process.stdout.write(JSON.stringify({
decision: 'block',
reason: `[frontmatter] ${relPosix}: ${why} — 先頭に --- で囲んだ frontmatter を追加すること`,
}) + '\n');
CIと比べた利点は3つ。
-
速い
→push を待たない
-
その場で直せる
→AIが自分で直す
→書き込み自体は成功してるので実害もない -
exitの書き忘れが起きにくい→hook はブロックが仕事なので、返り値を書き忘れたら動かないことにすぐ気づく
2. 網は週次スイープに残す
hook はローカルのセッションしか通らない
→Web版やスマホ経由の書き込みは素通りする
だから同じスクリプトを --all で全件走らせる週次 workflow を別に用意して、こっちは違反があれば exit 1 する(今度こそ)。
役割分担としては、hook が「速いけど漏れる」側で、週次スイープが「遅いけど漏れない」側。
3. CIには「落ちて初めて気づけるもの」だけ残す
CIは外部リンク切れチェック専用にして、continue-on-error を外した。
workflow のコメントにはこう書いた。
# continue-on-error は付けない: 落ちて初めて気づける(Actions の失敗通知が唯一の導線)。
通知が来ないチェックは、無いのと同じだった。
成功のログは誰も読まない(僕は5ヶ月読まなかった)から、エラーで落ちる以外に伝わる道がない。
承認する側として、次から何を見るか
自分のリポの話として書いてきたけど、たぶんこれから増えるのは、部下が「AIに書かせました」と持ってきたものを通すかどうかの場面だと思っている。
僕が家でやった素通しを、今度は仕事でやることになる。
同じ罠にかかる、形は整ってるから。
だから、コードの中身を読むのはやめて、聞くことを3つに決めた。
-
これは何を検出するのか
→一言で言えないなら、たぶんまだ設計されてない
-
検出したら何が起きるのか
→「ログに出ます」は答えになってない
→誰の目に、どういう形で入るのか -
わざと違反させたのを見たか
→見てなければ、そのチェックは未検証
3つとも、成果物を読まずに聞ける。
僕らの層は時間的にもブランク的にも、ソースを読み切れるかが微妙なラインだし、読んだら読んだで形に引っかかるから、読まずに聞く。
差し戻すときも「コードが悪い」じゃなくて「わざと落として見せて」で済むし、AIが書いたかどうかに関係なく効くから、言い方として揉めない。
というかこれ、書いてて気づいたけど、新しい話じゃなかった。
決済系のシステムを作るとき、パトライトのマニュアル(一般的にはエラー対応マニュアルかな)を書くのとずっと同じだ
→何を検知したいのか
→本当にエラーとして検知する必要があるのか
→リトライじゃだめなのか、再操作じゃだめなのか
→検知して何をしたいのか、してほしいのか
→誰がやるのか、できるのか
→システムの特性・規模・仕組みに合わせて考えて、必ず試験で「それを検知して対応する」ところまで試験する
で、これを必ず開発してるみんなと会話して決める。
お互いの意図を理解し合って、同じ目線で話し合って、最後は僕が覚悟する。
レビューというより認識合わせ。
僕がコードを読めなくても仕事になってたのは、たぶんこれをやってたからだ。
AIが相手になって、この会話を省略した、それが今回の事故なんだと思う。
教訓(自分用のメモ)
-
echoするだけのチェックは、チェックではない→exit code を返さない検証は、検証してるように見えるだけ
-
AIが作ったものは、形と報告が整っている。だから受け取ってしまう
→カウンタも分岐も警告文も、本当にある
→形と効力は別物 -
CIを入れたら、一度わざと違反を作って、ちゃんとエラーになるのを見る
→僕はこれをやってなくて、1分で済むのに5ヶ月失った
-
continue-on-errorはチェックを飾りに変える→付けるなら「落ちても構わない理由」をコメントに残す
今日できること(1分)
自分のCIで、わざと違反を1件作って push してみる。
ちゃんとエラーになるか。
→yml が読めなくても、AI に「このチェックにわざと引っかかる違反を作って」と頼めばできる
エラーにならなかったら、そのチェックは動いてないから、exit 1 を1行足そう。
僕はそれをやらないまま5ヶ月払い続けて、266分、何の役にも立ってなかった。笑