ある朝、ログを見返していて血の気が引きました。前夜に品質ゲートが「違反あり」で止めたはずの記事が、本番に公開されていたのです。ゲートのログには確かに 🛑 違反検出 と出ている。なのに同じ実行の最後で git push が走り、記事はサイトに並んでいました。
原因はゲートそのものではありませんでした。私自身が、検証と公開を一つのシェル呼び出しにまとめて書いていたことでした。個人開発で複数サイトの投稿を無人化していると、こうした「シェルの終了コードの解釈」がそのまま事故になります。同じ轍を踏まないために、何が起きていたのか、どう組み直したのかを残します。
同じ呼び出しにまとめた瞬間に何が起きたか
問題のタスク本体は、おおよそこう書かれていました。
python3 article_gate.py " $JA " " $EN " ; git push origin main
一見、上から順に「ゲート → push」で安全に見えます。けれど ; は前のコマンドの成否を一切見ません。ゲートが終了コード 1 で落ちても、シェルは何事もなかったかのように次の git push を実行します。つまりこの一行は、最初から「ゲートの結果に関わらず必ず push する」という意味だったのです。
無人実行では人間が途中のログを見て止める瞬間がありません。; で繋いだ時点で、検証は「実行されるだけで、何も守っていない飾り」になっていました。
; と && の違いと、最終終了コードしか残らないこと
まず ; を && に替えるだけで、この事故の大半は防げます。
# 危険: ゲートが落ちても push する
python3 article_gate.py " $JA " " $EN " ; git push origin main
# まし: ゲートが成功した時だけ push する
python3 article_gate.py " $JA " " $EN " && git push origin main
&& は左側が終了コード 0(成功)のときだけ右側を実行します。ここまでは多くの方がご存じだと思います。
落とし穴は、エージェントやスケジューラがこの一行を「ひとつのコマンド」として扱い、最後の終了コードしか手元に残らない ことです。a && b で a が失敗すれば全体は失敗扱いになりますが、a && b で a が成功して b(push)が失敗したときと、そもそも a で止まったときを、ログを読まずに区別できません。「成功/失敗」の一値しか観測できない実行系では、検証の判断を外側が受け取れないまま不可逆操作まで一息で進んでしまいます。だからこそ私は、&& で繋ぐだけでは足りないと考えています。
パイプ・コマンド置換・set -e が黙って素通りさせる
&& を使っていても、終了コードが途中で別の値に化けると検証は無力化します。無人運用で実際に踏みやすいのが次の3つです。いずれも本番運用で一度は回避策を考えることになる箇所です。
1. パイプ
ログを残そうとして tee に流すと、$? はパイプ末尾のコマンド(ここでは tee)の終了コードになります。tee はまず失敗しないので、ゲートが落ちても全体は成功に見えます。
# 罠: $? は tee のもの。ゲートの 1 は消える
python3 article_gate.py " $JA " " $EN " | tee gate.log && git push origin main
これは set -o pipefail を立てれば、パイプ内のどれかが落ちた時点でパイプ全体を失敗にできます。
2. コマンド置換
出力を変数に取ると、$() の中の終了コードは外側に伝わりません。
# 罠: RESULT に出力は入るが、ゲートの成否は捨てられる
RESULT = $( python3 article_gate.py " $JA " " $EN " )
git push origin main # ← RESULT の中身を誰も判定していない
3. set -e の過信
set -e を立てても、コマンドが && や || の左側にあるとき、if の条件にあるとき、関数やパイプの内側にあるときなど、set -e が発動しない文脈が多くあります。「set -e を入れたから安全」という思い込みが一番危険です。
私が学んだのは、検証の終了コードは「自動で効く」ことを期待せず、自分で明示的に拾って分岐する しかない、ということでした。
set -euo pipefail
if ! python3 article_gate.py " $JA " " $EN " ; then
echo "🛑 article_gate 違反。push せず終了します"
exit 1
fi
if ! python3 templating_gate.py " $WORK " --check " $JA " " $EN " ; then
echo "🛑 templating_gate 違反。push せず終了します"
exit 1
fi
if ! cmd; then ... fi は終了コードを確実に観測する、最も誤解の少ない書き方です。ここでようやく、ゲートが「飾り」ではなく「門」になります。
公開を「マーカーがある時だけ」に変える二段構え
終了コードを正しく拾えても、検証と push が地続きである限り、将来また誰か(あるいは未来の自分)が一行にまとめてしまうリスクは残ります。そこで私は、検証フェーズと公開フェーズを物理的に切り離し、間に「公開可マーカー」を挟む 設計に変えました。
検証フェーズは、4種のゲートを全て通過したときだけ、記事のスラッグを書いた使い捨てのマーカーを作ります。
# --- フェーズ1: 検証のみ。push は絶対にしない ---
set -euo pipefail
MARKER = " $WORK /.ready-to-publish"
rm -f " $MARKER "
run_gate () {
if ! " $@ " ; then
echo "🛑 ゲート失敗: $* "
exit 1
fi
}
run_gate python3 article_gate.py " $JA " " $EN "
run_gate python3 templating_gate.py " $WORK " --check " $JA " " $EN "
run_gate python3 frontmatter_integrity.py " $WORK "
run_gate python3 redirect_integrity.py " $WORK "
# 全通過時のみ、スラッグと時刻を刻んだマーカーを作る
printf '%s\n%s\n' " $SLUG " "$( date -u +%s)" > " $MARKER "
echo "✅ 全ゲート通過。公開可マーカーを作成しました"
公開フェーズは別の呼び出しで走り、マーカーが「存在し、対象スラッグが一致し、十分に新しい」ことを確認できなければ push を拒みます。
# --- フェーズ2: 公開。マーカーが正当なときだけ push する ---
set -euo pipefail
MARKER = " $WORK /.ready-to-publish"
if [ ! -f " $MARKER " ]; then
echo "🛑 公開可マーカーがありません。検証が通っていないと判断し中止します"
exit 1
fi
MARK_SLUG = $( sed -n '1p' " $MARKER " )
MARK_TS = $( sed -n '2p' " $MARKER " )
AGE = $(( $(date -u +%s ) - MARK_TS ))
if [ " $MARK_SLUG " != " $SLUG " ]; then
echo "🛑 マーカーのスラッグ ( $MARK_SLUG ) が対象 ( $SLUG ) と一致しません。中止します"
exit 1
fi
if [ " $AGE " -gt 600 ]; then
echo "🛑 マーカーが古すぎます (${ AGE }s)。検証をやり直してください"
exit 1
fi
git add content/
git commit -m "Add: $SLUG (JA+EN)"
git push origin main
rm -f " $MARKER " # 使い捨て。次回の取り違えを防ぐ
マーカーにスラッグと時刻を持たせるのが肝です。スラッグ一致を見れば「前回の残骸マーカーで別の記事を公開する」事故を防げますし、鮮度(ここでは10分)を見れば「ずっと前に通った検証を根拠に今push する」ことも防げます。マーカーは push 後に必ず消し、毎回ゼロから作り直します。
無人運用では「別の呼び出しに割る」こと自体が安全装置
二段構えの本質は、ファイル一個のマーカーそのものではありません。検証と公開を別の実行に割ることで、間に「結果を観測する地点」が強制的に生まれる ことです。
一つの呼び出しに gate && push とまとめると、ゲートの判断は実行系の外から見えないまま、不可逆操作まで一気に流れます。呼び出しを分ければ、フェーズ1の終了コードと出力を受け取ってからでないとフェーズ2に進めません。エージェントに任せる運用なら、フェーズ1のログを読ませ、✅ 全ゲート通過 を確認してからフェーズ2を別途実行させる——この「証拠を見てから不可逆操作」という順序が、設計として担保されます。
私自身は今、無人タスクの本体を見直すとき、まず「検証コマンドと、push・デプロイ・削除のような取り返しのつかない操作が、同じ呼び出しの中で地続きになっていないか」を真っ先に確認するようにしています。地続きになっていたら、たとえ && で繋いであっても割る。これは複数サイトを一人で回す中で、何度かヒヤリとして辿り着いた線引きです。個人的には、検証と不可逆操作は必ず別の呼び出しに割ることを推奨します。私はこの順序を、運用上の安全装置そのものだと考えています。
まず確かめてほしいこと
お手元の無人タスク(スケジュール実行・CI・デプロイスクリプト)を一つ開いて、検証コマンドと不可逆操作が同じ行・同じ呼び出しに同居している箇所を探してみてください。; で繋いでいたら今すぐ事故が起きうる状態です。&& でも、パイプやコマンド置換で終了コードが化けていないかを確認し、可能なら検証と公開を別の呼び出しに割って間にマーカーを挟む——まずはこの一箇所からで十分です。
同じように複数のものを無人で回している方の参考になれば幸いです。私もまだ運用しながら学んでいる途中です。