仕上げは AI との協働でやっています。事実と表現は著者本人が確認しています。
実行履歴が緑だという理由で「動いている」と判断していたなら、その判断には根拠がない。GitHub Actions は、途中のステップが if 条件で skip されても、job が exit 0 で終わればその run の conclusion を success として記録する。何を実行し、何を実行しなかったかは conclusion に出ない。実際に配信まで終えた run も、ステップが軒並み skip されて何もしなかった run も、履歴の上ではまったく同じ緑色をしている。何もしなくても、CI は緑になる。
この workflow は AI に書かせたものだった。方針を出し、出てきたコードを読んでレビューしたのは自分だ。動くことは確認した。だが no-op のゲートが何を守っているのかは、確かめていなかった。今週 AI に書かせて通した実装があるなら、その中の「何もしない」分岐が何を守っているか、言えるだろうか。
実害は「その回に何かひとつが動かなかったこと」ではない。動いていると思い込んだまま、その前提の上に次の作業を進めてしまう期間ができることのほうだ。壊れていたと分かった後には、いつから壊れていたかを調べ、その期間に出した分の再実行・再送・経緯の説明といった作業が発生する。
同じ形の実害は、記事の配信に限らず起きる。ステージングへの自動デプロイが黙って動かなくなり、レビューを頼まれた相手が気づかず古い画面をレビューする。定期バッチの集計が止まったまま、古い数字が会議資料に載る。
自分の scheduled workflow でも、これは今すぐ確認できる。
$ gh run list --workflow publish.yml --json conclusion,createdAt,headSha
(publish.yml は自分の workflow ファイル名に置き換える) conclusion が success で並んでいても、それは「エラーが出なかった」ことしか示さない。同じ期間に、その workflow が本来更新するはずの成果物 — デプロイ先のファイル、送信したレポート、公開したページなど — が実際に更新されているかを、別途確認する必要がある。両者が食い違っていれば、これから書く状態と同じものが起きている。
自分の環境でも、記事を配信する scheduled workflow でこれが起きていた。上のコマンドで見る限り実行履歴は success が並んでいたが、本番サイトの記事は出ていなかった (404)。気づいたのは監視の通知ではなく、本番サイトを直接見たことだった。原因は本番の repository variables に必須の値を設定し忘れていたことで、対処は変数が欠けている場合に skip ではなく fail-loud で落とすよう変更したことだ。
cron が動かないのに実行履歴は success になる
記事を予約投稿すると、平日の朝6時に cron が走って本番サイトに反映される。そういう仕組みを自分で組んでいた。
on:
schedule:
- cron: "0 21 * * 0-4" # UTC 21:00 = JST 翌 06:00 (月〜金)
記事が出ていなかった。実行履歴は緑。ログを開くと、こう書いてあった。
##[notice]WEBSITE_OWNER/WEBSITE_REPO/WEBSITE_RECEIVE_WORKFLOW が未設定。 publish を skip
該当のステップは、こうなっていた。
- name: Validate required vars (no-op if missing)
id: gate
run: |
if [ -z "${WEBSITE_OWNER}" ] || [ -z "${WEBSITE_REPO}" ] || [ -z "${WEBSITE_RECEIVE_WORKFLOW}" ]; then
echo "::notice::WEBSITE_OWNER/WEBSITE_REPO/WEBSITE_RECEIVE_WORKFLOW が未設定。 publish を skip"
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
env:
WEBSITE_OWNER: ${{ vars.WEBSITE_OWNER }}
WEBSITE_REPO: ${{ vars.WEBSITE_REPO }}
WEBSITE_RECEIVE_WORKFLOW: ${{ vars.WEBSITE_RECEIVE_WORKFLOW }}
後続のステップには全部 if: steps.gate.outputs.enabled == 'true' が付いている。空文字を1つでも検出すると、gate は enabled=false を出力し、後続ステップはすべて実行されず、トークンも作らず、配信先も叩かず、記事に一度も触れないまま、job は exit 0 で終わる。
補足しておくと、未定義の vars は式評価で空文字になる。だから -z で拾えてしまう。WEBSITE_* は機密ではないので secrets ではなく variables に置いていた。
過去の緑と並べると、こうだった。
$ gh run list --workflow publish.yml --limit 20 \
--json databaseId,conclusion,createdAt,event,headSha
| 日付 (JST) | conclusion | 受信側 workflow の発火 | 実際に配信したか |
|---|---|---|---|
| 7/06 〜 7/13の6回 | success | あり | した |
| 7/14 06:49 | success | なし | していない |
過去の緑が本当に配信していたことは、その回の success からは分からなかった。分かったのは、受信側の workflow が直後に発火した記録が残っていたからだ。緑が信用できないと分かった以上、緑の中身は緑以外のもので確かめるしかない。
skip された job の conclusion は success になる
success は「やるべき仕事をやった」ではない。「エラーが出なかった」だ。
skip されたステップは失敗ではない。失敗でないものは job を落とさない。落ちない job は緑になる。緑の実行履歴をわざわざ開き直す理由は、誰にもない。通知も飛ばない。異常ではないのだから。
緑は「動いた」の証拠ではなく、「落ちなかった」の証拠でしかない。
後で分かったが、::notice:: は run のサマリにアノテーションとしても表示される。つまり隠れていたわけではない。一覧が緑である限り、その run を開く理由が誰にも無いからだ。自分の workflow でも、一覧の色だけでなく、個別の run を開いてサマリのアノテーションを見る習慣があるかどうかで、この種の skip への気づきやすさが変わる。
今回止まったのは、本番サイトを見て記事が無いことに気づいたからだ。仕組みが検知したわけではない。誰も見にいかなければ、この緑は何日でも続いた。1回で済んだことは、設計が安全だった証拠にはならない。
workflow の変更が記事公開PRに同居していると、レビューで設定漏れは拾えない
壊した変更を特定した。前日にマージされた PR で、件名は「公開確定8本 + 名義設計の適用」。記事8本の公開を確定し、著者名義の整理を反映する、記事まわりの作業の PR だ。diff は96ファイルに及び、大半は記事本文と原稿まわりのドキュメント。その中に、配信 workflow の変更が同居していた。
変数化と gate の追加は、記事8本の確定・名義設計の適用と同一 commit として、2026-07-09 17:18 JST に dev 側で書かれていた。後から紛れ込んだのではなく、最初から一緒だった。それが 2026-07-13 16:41 JST の一括同期 PR で本番に入った。PR 本文には記事確定・名義設計のことだけが書かれ、workflow 変更への言及は無い。レビュー依頼はゼロ、レビューもコメントもゼロで、作成者本人がマージしている。
変更は2つあった。1つは、配信先 (記事を実際に公開するサイト側のリポジトリ) のハードコードを変数に外出しするリファクタリング。
- owner: <組織名>
- repositories: <リポジトリ名>
+ owner: ${{ vars.WEBSITE_OWNER }}
+ repositories: ${{ vars.WEBSITE_REPO }}
もう1つが、さきほどの gate の追加だ。workflow のコメントには、意図がはっきり書いてあった。
# WEBSITE_OWNER / WEBSITE_REPO が未設定なら no-op で終わる (テスト環境で publish を抑止する用途)。
筋は通っている。テスト用のリポジトリでこの workflow が動いても、本番の配信先に誤って記事を送らないように、変数が無ければ publish 系のステップを skip して success のまま終わる。安全側に倒したつもりの設計だ。
ただし、本番の repository variables に、その3つを入れるのを忘れた。AI に書かせたのも、レビューで通したのも、実在しない環境を守る分岐に気づかなかったのも自分だ。
結果として、本番は次の cron 実行から、記事を配信しないまま conclusion=success を返し続けるようになった。
本番の repository variables が実際に作成されたのは、PR マージから約17時間後だった (PR マージ: 07-13 16:41 JST、変数作成: 07-14 09:47 JST。いずれも API のタイムスタンプ実測)。その間に走った scheduled run は1件のみ (07-14 06:49 JST)。この run で Validate required vars は success を返し、後続の3ステップが skipped になった。影響は1日分の同期欠落で、翌日の実行からは正常に戻っている。
そしてこの変更は「記事の公開を確定する PR」の中にあった。自分が同じ PR をレビューしても、workflow の diff は読み飛ばしたと思う。タイトルが「記事の PR」だと示している以上、そこしか見ない。件名のどこにも、workflow に触れた気配は無い。
ただし、仮に workflow の diff を注視していても、防げたかは怪しい。diff から読み取れるのは「変数を参照するようになった」ことまでで、「その変数が本番に入っているか」はコードの外にある。レビューで捕まえられるのはコードで、設定は捕まらない。最終的に効いたのは、レビューの改善ではなく、設定が欠けていたら実行時に落ちる仕組みのほうだった。
直し方: 変数が欠けていたら skip せず fail-loud で落とす
「宣言された変数が本番に実際に投入されているか」を検証する CI チェックは、この時点で存在しなかった。既存の PR 時チェックは、その検証を対象外と明記していた (実 deploy 先に投入済みかは検証しない、という一文がスクリプトのコメントに書かれていた)。宣言 (コードが変数を参照するようになったこと) と実投入 (その変数が本番に実際に入っていること) は別物で、宣言側だけを見るチェックは、実投入が抜けていても通ってしまう。この穴を埋めるチェックを、事故発覚当日に新規で作った。
差分は10行もない。exit 0 の skip を、必須変数を検証して落ちるスクリプトに置き換えた。
- name: "Validate required vars (Gate1: fail loud if missing)"
id: gate
run: |
python3 scripts/ci/check-secrets-provisioned.py --used-in publish.yml
echo "enabled=true" >> "$GITHUB_OUTPUT"
env:
VAL_<必須のsecret名>: ${{ secrets.<必須のsecret名> }}
# ... 以下、必須の secrets / vars を並べる
name に付けた Gate1 はこの workflow 内だけの呼び名で、GitHub Actions の一般的な用語ではない。ここでは「公開に必要な設定がそろっているかを検証するステップ」を Gate1、後述する「公開対象の記事が今日あるかを判定するステップ」を Gate2 と呼ぶ。
両方のケースを手元で走らせた。
$ VAL_WEBSITE_OWNER=OWNER VAL_WEBSITE_REPO=REPO ... \
python3 scripts/ci/check-secrets-provisioned.py --used-in publish.yml
=== 必須 secrets/vars の実投入チェック (5 件検査) ===
✅ 必須 secrets/vars はすべて投入済みです (5 件)
$ echo $?
0
$ VAL_WEBSITE_OWNER= VAL_WEBSITE_REPO=REPO ... \
python3 scripts/ci/check-secrets-provisioned.py --used-in publish.yml
=== 必須 secrets/vars の実投入チェック (5 件検査) ===
❌ 1 件が未設定です:
- vars.WEBSITE_OWNER (used_in: publish.yml)
$ echo $?
1
この検証は、cron の先頭に入っている。設定が欠けた瞬間、次の実行が赤くなる。
整理すると、変わったのは1点だ。
| 状態 | 変更前 | 変更後 |
|---|---|---|
| 変数が揃っている | success (配信する) | success (配信する) |
| 変数が欠けている | success (何もしない) | failure (赤く落ちる) |
正常系は何も変えていない。変えたのは、異常系でも success のまま終わっていた挙動を、失敗として扱うようにしたことだ。
設定ドリフトを監視する専用の workflow を別に作る案もあったが、やめた。すでに毎日走っている処理があるなら、それをうるさくすれば点検装置になる。増やした監視は、それ自体が止まっていても気づかれない。
「変数が無い」はテスト環境の証明にならない — Gate1 と Gate2 に分ける
ここで宿題が残る。no-op は「テスト環境で publish を抑止する」ために置かれていた。fail-loud にしたら、テスト用リポジトリでは cron のたびに赤く落ちることになる。
no-op そのものが悪いわけではない。fork からの PR には secrets が渡らないので skip する、といった設計は正しい。悪かったのは、no-op に入る条件が「変数が無いこと」という暗黙のデフォルトだったことだ。「変数が無い」は、テスト環境であることの証明にならない。本番で設定を忘れても、まったく同じ形をしている。gate は、前者を後者として扱っていた。
no-op を選んだ理由として渡された説明は、コードのコメント1行 (「未設定なら no-op で終わる (テスト環境で publish を抑止する用途)」) のみだった。この gate も AI 協業で書いたもので、このコメントは AI が書いた意図説明だ。もっともらしい理屈だったので、読んで納得した気になり、そのまま通した。だが commit 本文にも PR 本文にも Issue にも、なぜ fail ではなく no-op にしたかの説明は無い。そして自分は、そのコメントが指す「テスト環境」が実在するかを確かめていなかった。この workflow を実際に動かしていた唯一の fork は、gate を書いた時点 (2026-05-22) で既に自前の変数を設定済みだった。つまり「変数が無いテスト環境」に該当する環境は、gate を書いた時点で存在しなかった。変数欠落が実際に起きたのは、本番の設定漏れのときだけだ。テスト環境を守るための分岐が、実在しない環境を守り、実在した障害を隠していたことになる。
自分の no-op 分岐にも、同じ問いが立つ。その分岐は、実際に存在する環境を守っているか。もっともらしい説明が付いているかどうかは、その答えにならない。
だから直す方向は「環境を推測させない」になる。実際、置き換えた後の workflow はゲートを2つに分けた。
| ゲート | 意図した条件 | 意図した結果 |
|---|---|---|
| Gate1 | 必須の変数が欠けている | failure (設定漏れ。意図ではありえない) |
| Gate2 | 今日は公開対象の記事が無い | success のまま skip (正常系) |
「何もしない」を全部消したわけではない。意図された「何もしない」(Gate2) は残し、意図されていない「何もしない」(Gate1) を落とす。設定を忘れたのか、意図的に止めたのか。コードがその2つを区別できていなかったことが、今回の本体だった。
自分の workflow に同じような no-op 分岐があるなら、その条件を上の2行のどちらに当てはめられるかを確認するとよい。設定不足も対象なしも同じ「何もしない」として扱っている分岐があれば、それが同じ欠陥を持っている。
公開先を確認しない gate は、fail-loud にしても空振りを検知できない
この記事を書きながら、Gate2 が本当に「今日は公開対象が無い」を判定しているのか確かめた。実装はこうだ (posts/ は記事の原稿を置いているこのリポジトリ内のディレクトリで、配信先である ../website 側とは別)。
TODAY=$(TZ='Asia/Tokyo' date +%Y-%m-%d)
for f in posts/*.md; do
base=$(basename "$f")
fdate="${base:0:10}" # ファイル名の先頭10文字
case "$fdate" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) continue ;; esac
if [[ "$fdate" > "$TODAY" ]]; then continue; fi # 未来日付は対象外
if ! grep -q '^draft: true' "$f"; then TARGET="$f"; break; fi # 最初の1本で確定
done
[ -n "$TARGET" ] && echo "has_target=true" >> "$GITHUB_OUTPUT" \
|| echo "has_target=false" >> "$GITHUB_OUTPUT"
見ているのは、記事ファイルの名前に入った日付と draft フラグだ。公開先の状態を一度も見ていない。つまりこの gate が答えているのは「今日公開すべき記事があるか」ではない。「posts/ に、draft でない過去日付のファイルが1本でも残っているか」だ。
公開済みの記事も posts/ には残り続ける。だから has_target=false になるのは、全ファイルが未来日付か、過去日付のものが全部 draft のときだけだ。posts/ は公開のたびに空へ近づくディレクトリではなく、公開済みも未公開も蓄積されていく一方のディレクトリなので、過去日付の draft でないファイルが1本でも残っている限り、実質的にいつでも has_target=true を返す。
この gate の欠陥は件数の多寡に依存しない。差が1件だろうと0件だろうと、この gate は未公開の記事を数えていない。数えているのは「posts/ が空でないこと」だ。記事を出し切ってキューが0件になった日も、posts/ に4月の古い記事が1本残っている限り、同じ has_target=true を返し続ける。判定ロジックを切り出し、実データ全件に対して日付を1日ずつ変えて回すと、どの日も has_target=true になった。
そして表に書いた「正常系の skip」——キューが空のときに緑で止まる、という、私が「意図された何もしない」として残したつもりの挙動は、この gate では起きない。上流の同期が壊れてキューが空になっても、健全なときとまったく同じ緑が出る。
「何もしない」を実際に決めているのは、この gate ではなく下流だった。dispatch を受ける側のワークフローに、こう書いてある。
- name: Check for changes
id: changes
run: |
git add src/content/blog/
if git diff --staged --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "ℹ️ 差分なし。スキップします"
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
差分が無ければ緑で止まる。同期すべきものが無かったのか、checkout が空だったのか、同期スクリプトが何も拾えなかったのか。この緑からは区別できない。
私はガードを2つに分け、片方に「意図された skip」というラベルを貼ったが、その false 側が実際に到達可能かどうかは確認していなかった。
fail-loud にすべき場面は、変数の欠落以外にも広がる。「何もしなかった」という結果になる分岐はすべて、なぜ何もしなくてよかったのかを証明できなければならない。Gate2 なら「公開先を読んだ上で、未反映が0件だった」と言えなければならない。この Gate2 は公開先を読んでいないので、それを言えない。何もしなかったことの証明は、内部の状態を見てもできない。見るべきは、外にある成果物のほうだ。
schedule は60日無活動で自動停止する — 赤も緑も出ない空振り
ただし、これは「実行はされたが失敗が表に出ない」失敗にしか効かない。cron そのものが発火しなくなれば、赤も緑も出ない。silent skip を潰した先には silent no-run が残る。schedule は高負荷時に遅延・drop されうるし、public repo なら60日の無活動で自動的に無効化される。
「実行されたら鳴る」監視は、「実行されなかったこと」を鳴らせない。そこを塞ぐには、実行があったこと自体を外から数える仕組みが別に要る。今回はそこまでやっていない。
BUILD_DATE で明日の日付を注入すれば、翌日を待たずにローカルで確認できる
直したあと、問題が残った。本当に次の実行でちゃんと動くのか。次の cron 実行まで待って本番 URL を見れば分かるが、壊れていたらまた記事が1本出ない。
同期スクリプト側で、BUILD_DATE という環境変数を渡すと「JST の今日」を上書きできる設計にしてあったので、明日の日付でローカル実行した。本番を一度も発火させずに、「その日にこの処理が走ったら、どの記事を拾い、何を消すか」を手元で再現できる。
$ BUILD_DATE=2026-07-15 python3 sync-blog.py ./posts ./src/content/blog
sync cutoff date (JST): 2026-07-15
SKIP (draft:true): ...
SKIP (future:2026-07-16 > 2026-07-15): ...
SYNC: 2026-07-15-<記事slug>.md
✅ 1 件の変更
changed=true
翌日ぶんの記事が対象に入り、既存の記事が誤って消えない (DELETE 0件) ことまで確認できた。ローカルで再現できないのは「決まった時刻にそもそも起動するか」だ。そこは次回の実行時に本番 URL を一度見れば済む。
これができたのは、日付が外から注入できる入力として設計されていたからだ。datetime.now() を直に呼ぶ実装でも、モックを噛ませれば凍結はできる。ただしそれはテストのために本番コードの内側へ手を入れる作業で、思い立った日にすぐ叩ける手軽さは無い。
| 確かめたいこと | 待たずに済む方法 | 前提となる設計 |
|---|---|---|
| 明日この処理は何を拾うか | 日付を差し替えてローカル実行 | 「今日」が外部注入できる |
| 設定が欠けたらどうなるか | 変数を空にして exit code を見る | 検証がスクリプトとして分離されている |
監視すべきは job の成否ではなく、成果物が出たかどうか
「CI が緑なら動いている」と思っている間は、緑が何を保証しているのかを考えない。実際には、例外で落ちなかったことしか保証されていなかった。
引き金は、本番に変数を設定し忘れたことだ。設定が1つ欠けているので、赤く落ちてさえいれば、直すのは変数を入れることで済んだ。増幅したのは、変数が無いときにログを残さず exit 0 で終わるフォールバックが、そのミスを「無害な状態」のまま記録したことだ。監視から見て正常な回と区別がつかなくなったのは、job の conclusion がどちらも success だったからだ。発見を遅らせたのは、その変更が「記事の公開を確定する PR」に同居していたことだ。
ミスは避けられない。避けられるのは、ミスが失敗として表に出ないことのほうだった。
そして、この記事を書くために自分の修正を読み直すまで、Gate2 が何も判定していないことに私は気づいていなかった。fail-loud を書いた当人が、その隣に新しい silent skip を置いていた。この修正も AI 協業で書いたものだ。AI の実装を一度疑って fail-loud に直したはずなのに、同じやり取りの中で、隣に新しい no-op が残っていることには気づかなかった。緑を疑う対象には、今日の自分が書いたコード — 自分が読んで通した AI のコードも入る。AI の修正を受け入れるときも、確かめる手順は同じだ。
fail-loud に変えたのと、日付を注入して明日を先に走らせたのは、別の話に見えて同じことをしている。壊れた事実が返ってくるまでの時間を、「人が思い出すまで」から「次の6時の cron 実行」に、さらに「今」に縮めた。壊れているかどうかを、時間の経過で確かめるのをやめる。緑を信じるのをやめたあとに残るのは、結局それだった。
サイレントな異常と、サイレントな正常は、外から見れば同じ緑をしている。conclusion の欄をいくら睨んでも、この2つは区別がつかない。見分ける方法は一つしかない。落ちたかどうかではなく、成果物が実際に出たかどうかを見ることだ。 今回 fail-loud にできたのは、変数の欠落という1点だった。Gate2 と下流の「差分なし → 緑」も、この記事を書きながら欠陥が見つかったが、この記事では手を付けていない。同じ確認は、自分の scheduled workflow でもできる。conclusion が success で並んでいても、その隣で実際に成果物が更新された記録があるかを見れば、区別がつく。AI が出した実装であれば、確認はもっと早い場所でもできる。「何もしない」分岐を受け入れる前に、それが何を守っているのか、そしてその守る対象が実在するのかを一度確かめる。今回、その一度を省いたから、実在しない環境を守る分岐に気づけないまま本番に入った。
この観測について
cron が入った時点から観測時点までの scheduled run 全件を対象にし、都合の悪い回を除外していない (取得コマンドは冒頭に記載)。
組織名・リポジトリ名・記事の slug は伏せているが、workflow の構造・exit code・出力はそのまま記載している。