深夜に走らせている無人パイプラインが、ある朝、公開してはいけない原稿を公開していました。
実行ログを開くと、品質ゲートはたしかに「✅ 合格」と出力しています。その数行下で git push も成功しています。どこにも赤い文字はありません。それなのに、公開された本文には、ゲートが弾くはずの表現が残っていました。
しばらく画面を眺めて、ようやく気づきました。ゲートが読んだファイルと、git add が拾ったファイルは、同じパスを指してはいるものの、同じ内容ではなかったのです。ゲートが走ったあと、公開までのわずかな時間のあいだに、別の処理がそのファイルを書き換えていました。
エラーは一件も出ていません。すべての工程が「成功」しています。この静かさが、無人運用でいちばん厄介なところだと感じます。
検証と公開のあいだに、何が入り込むのか
これは古典的な TOCTOU(Time-of-check to time-of-use、検査時と使用時の乖離)です。ファイルシステム上のパスは値ではなく参照であり、検査した瞬間の内容を保持してはくれません。
無人パイプラインで私が実際に踏んだ侵入経路は、次の4つでした。
| 侵入経路 | 何が起きるか | 気づきにくい理由 |
| 再生成 |
ゲート違反を補強するつもりで本文を書き直したが、再ゲートを忘れたまま push に進む |
ログには古い「✅ 合格」がそのまま残っている |
| 自動整形・自動修正 |
整合性チェックの --fix がファイルを書き換え、副産物の .bak まで残す |
git add -A が意図しないファイルを一緒に拾う |
| 一時ファイルの残骸 |
固定名の一時ファイルへの書き込みが失敗し、前回実行の内容が読まれる |
書き込み失敗が非ゼロ終了として伝播しない |
| 並行プロセス |
別のスケジュール実行が同じ作業ディレクトリを触る |
両方の実行が個別には成功して見える |
どれも「ゲートを厳しくする」では防げません。ゲートの内容ではなく、ゲートと公開のあいだに問題があるからです。
合格証という考え方 — 判定を内容に束ねる
私が採った解決策は、ゲートの合否をそれ自体で完結させず、検査した内容そのもののダイジェストに束ねることでした。
ゲートが通ったとき、次の情報を持つ小さな JSON を発行します。
- 検査した各ファイルの相対パスと sha256 ダイジェスト
- 実行したゲートの名前と、ゲートスクリプト自身のダイジェスト
- 発行時刻(単調に進む時計と壁時計の両方)
- パイプラインの実行 ID
この JSON を「合格証(receipt)」と呼んでいます。公開の直前に合格証を読み、そこに書かれたダイジェストをもう一度ファイルから計算し直して照合します。1バイトでも変わっていれば、公開しません。
肝心なのは、合格証がファイルの内容に対して発行されるという点です。パスに対してではありません。パスは書き換わりますが、ダイジェストは嘘をつきません。
合格証を発行する
まず発行側です。ゲートを実行し、すべて通った場合にのみ合格証を書き出します。
#!/usr/bin/env python3
"""gate_receipt.py — 品質ゲートを実行し、合格時に内容ダイジェスト付きの合格証を発行する。"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
from pathlib import Path
RECEIPT_NAME = ".gate-receipt.json"
def digest(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def run_gate(script: Path, targets: list[Path]) -> tuple[bool, str]:
"""ゲートを1つ実行する。戻り値は (合格したか, 出力)。"""
proc = subprocess.run(
[sys.executable, str(script), *[str(t) for t in targets]],
capture_output=True,
text=True,
timeout=300,
)
output = (proc.stdout + proc.stderr).strip()
return proc.returncode == 0, output
def issue(repo: Path, gates: list[Path], targets: list[Path]) -> int:
results = {}
for gate in gates:
passed, output = run_gate(gate, targets)
results[gate.name] = {
"passed": passed,
"gate_digest": digest(gate),
"tail": output.splitlines()[-3:],
}
status = "✅" if passed else "❌"
print(f"{status} {gate.name}")
if not passed:
print(output)
if not all(r["passed"] for r in results.values()):
# 合格証を発行しない。既存の合格証があれば必ず無効化する。
(repo / RECEIPT_NAME).unlink(missing_ok=True)
print("合格証は発行されませんでした。公開してはいけません。")
return 1
receipt = {
"version": 1,
"run_id": os.environ.get("PIPELINE_RUN_ID", str(os.getpid())),
"issued_at_wall": time.time(),
"issued_at_mono": time.monotonic(),
"gates": results,
"artifacts": {
str(t.relative_to(repo)): digest(t) for t in targets
},
}
(repo / RECEIPT_NAME).write_text(json.dumps(receipt, indent=2, sort_keys=True))
print(f"合格証を発行しました: {len(receipt['artifacts'])} ファイル")
return 0
if __name__ == "__main__":
repo_root = Path(sys.argv[1]).resolve()
gate_dir = Path(sys.argv[2]).resolve()
files = [Path(p).resolve() for p in sys.argv[3:]]
sys.exit(issue(repo_root, sorted(gate_dir.glob("*_gate.py")), files))
ここで意図的に書いている細部が3つあります。
ひとつめは、ゲートが1つでも落ちたときに既存の合格証を消すことです。前回の実行が残した合格証が生き残っていると、今回の失敗が過去の成功で覆い隠されます。合格証は「存在するだけで意味を持つ」ので、無効化を忘れると最悪の形で裏切られます。
ふたつめは、ゲートスクリプト自身のダイジェストを記録していることです。ゲートを書き換えたのに古い合格証が有効なままだと、「どの基準で通ったのか」が失われます。
みっつめは、壁時計と単調時計の両方を残していることです。壁時計は NTP 補正や時間帯の扱いで逆行しますが、単調時計は逆行しません。合格証の鮮度判定には単調時計を使います。
公開直前に照合する
次が検証側です。git push の直前、あるいはデプロイの直前に必ず通します。
#!/usr/bin/env python3
"""verify_receipt.py — 合格証を検証する。1つでも不一致なら非ゼロで終了する。"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from gate_receipt import RECEIPT_NAME, digest
MAX_AGE_SECONDS = 900 # 合格証の有効期間は15分
def verify(repo: Path) -> int:
receipt_path = repo / RECEIPT_NAME
if not receipt_path.exists():
print("❌ 合格証がありません。ゲートを実行してください。")
return 1
receipt = json.loads(receipt_path.read_text())
age = time.monotonic() - receipt["issued_at_mono"]
if age < 0 or age > MAX_AGE_SECONDS:
print(f"❌ 合格証が古すぎます(経過 {age:.0f} 秒)。再ゲートしてください。")
return 1
failures = []
for rel, expected in receipt["artifacts"].items():
target = repo / rel
if not target.exists():
failures.append(f"{rel}: 消失")
continue
actual = digest(target)
if actual != expected:
failures.append(f"{rel}: 内容が変わりました")
for name, meta in receipt["gates"].items():
gate_path = repo / "_gates" / name
if gate_path.exists() and digest(gate_path) != meta["gate_digest"]:
failures.append(f"{name}: ゲート自体が変更されました")
if failures:
print("❌ 合格証と実体が一致しません:")
for f in failures:
print(f" - {f}")
return 1
print(f"✅ 合格証を検証しました({len(receipt['artifacts'])} ファイル・経過 {age:.0f} 秒)")
return 0
if __name__ == "__main__":
sys.exit(verify(Path(sys.argv[1]).resolve()))
age < 0 を失敗として扱っているのは、単調時計の値が別プロセス由来である場合を弾くためです。プロセスが変われば time.monotonic() の原点は変わり得ます。同一実行内でのみ有効、という制約を明示的に検査しています。
有効期間を15分に置いたのは、私の運用で「ゲートから push までの中央値が 40 秒、最長でも 6 分」だったためです。この分布はパイプラインごとに違います。まず1週間ほど計測し、最長値の2倍程度を上限に取ることを推奨します。
同一バッチ実行が観測点を消す
ここで、もうひとつの根の深い問題に触れておきます。
ゲートと公開を同じシェル呼び出しに詰め込むと、あいだに人間もエージェントも観測点を持てません。エージェントは自分が書いたコマンド列の途中結果を読めないまま、最後の終了コードだけを見て「成功した」と判断します。
シェルの結合子ごとの挙動を整理すると、危うさがはっきりします。
| 書き方 | ゲートが落ちたとき | 危険度 |
gate; git push |
push が実行される |
高(違反物が公開される) |
gate && git push |
push は止まるが、パイプラインは終了 0 を返し得る |
中(失敗が沈黙する) |
gate | tee log |
終了コードが tee のものになる |
高(常に成功に見える) |
| ゲートと push を別呼び出しに分離 |
ゲート結果を読んでから判断できる |
低 |
set -euo pipefail を付ければ2番目と3番目は緩和されます。それでも私は分離を選びました。理由は単純で、分離しておくと、判断の主体が結果を読むという事実が構造として残るからです。エージェントに任せる工程では、この「読む機会」の有無が、そのまま事故率に効いてきます。
合格証は、この分離を安全に成立させる接着剤でもあります。呼び出しをまたいでも、内容に束ねられた判定は持ち越せます。
実測 — 導入前後で何が変わったか
個人開発で4リポジトリの無人公開パイプラインを回しています。合格証を入れる前後で、90日ずつ比較しました。
| 指標 | 導入前(90日) | 導入後(90日) |
| ゲート合格後に内容が変わったまま公開された件数 | 3 件 | 0 件 |
| 公開直前に検証がブロックした件数 | — | 11 件 |
| ブロック内訳:再ゲート忘れ | — | 6 件 |
ブロック内訳:--fix による書き換え | — | 4 件 |
| ブロック内訳:一時ファイルの残骸混入 | — | 1 件 |
| 検証の追加所要時間(12ファイル・中央値) | — | 0.41 秒 |
| 誤検知(正当な公開を止めた件数) | — | 0 件 |
導入前の3件は、いずれも事後に読者から指摘を受けて気づいたものです。ブロックされた11件は、公開されていれば同じ経路をたどっていたはずでした。
追加コストは 0.41 秒です。パイプライン1回の総実行時間に対して 0.3% にも届きません。sha256 の計算はファイル I/O に対して十分に速く、記事のような数十 KB のテキストでは実質的に無視できます。この数字を見たとき、なぜもっと早く入れなかったのだろうと、少し胸が痛くなりました。
4段階で導入する
いきなり全工程に入れる必要はありません。私は次の順で入れました。
- 計測する:まずゲート終了から公開までの経過時間を記録します。有効期間を決める根拠になります。ここで想定より長い尾を見つけたら、その時点で並行プロセスを疑ってください。
- 発行だけ入れる:合格証を書き出しますが、公開はブロックしません。1週間ほど回し、不一致がどれだけ起きるかを観察します。私の場合、この段階で週に1件前後の不一致が見えました。
- 警告モードにする:不一致を検出したら大きく出力しますが、まだ止めません。誤検知の有無を確認します。生成物を CI 側で再生成している場合、ここで必ず引っかかります。対象ファイルから除外するか、生成前のソースだけをダイジェスト対象にします。
- 強制モードに切り替える:
verify_receipt.py の非ゼロ終了で公開を止めます。同時に、ゲートと公開を別の呼び出しに分離します。
3段階目を飛ばさないでください。自動生成物をダイジェスト対象に含めたまま強制モードへ進むと、正当な公開が全部止まります。私は一度これで夜間の実行を全滅させました。
運用して気づいた落とし穴
mtime での代替は成り立ちません。 最初、私はダイジェストではなくファイルの更新時刻を記録しようとしました。同一秒内の書き換えを取りこぼしますし、ファイルシステムによって粒度が違います。ダイジェストなら曖昧さがありません。
--fix を持つツールは、直したファイル以外も残します。 整合性チェックの自動修正が .bak を残し、git add -A がそれを一緒にコミットしたことがありました。合格証は「検査したファイル」しか守りません。公開対象の集合そのものも合格証に束ね、git status --porcelain の出力と突き合わせるのが確実です。
合格証を作業ディレクトリに置くとコミットに紛れます。 .gitignore に入れるか、リポジトリの外(実行 ID 名のディレクトリ)に置いてください。私は後者に落ち着きました。合格証は成果物ではなく、実行の付随物です。
どこまでやるかは、失敗の可逆性で決める
すべてのパイプラインに合格証が必要なわけではありません。
| 状況 | 推奨 |
| 手元で自分が見ながら1リポジトリを更新する |
不要。人間の目が最後の検証になっています |
| 無人で公開するが、取り消しが容易(社内ドキュメント等) |
発行と警告モードまで。止めるほどのコストではありません |
| 無人で公開し、取り消しに外部影響が伴う(検索インデックス・配信・課金) |
強制モード。ゲートと公開の呼び出し分離もあわせて |
| 複数のスケジュール実行が同じ作業ディレクトリを共有する |
強制モードに加えて、作業ディレクトリを実行ごとに分ける |
判断軸は「取り消しにどれだけの手間と外部影響が伴うか」です。私はこの設計を、記事の公開だけでなく個人開発しているアプリのリリース工程にも同じ形で持ち込みました。取り消しの重さが似ているからです。公開してから消した URL は、検索エンジンの索引にも、読んだ人の記憶にも残ります。この不可逆性の前では、0.41 秒はあまりに安い保険でした。
次の一手
まずは計測から始めてみてください。手元のパイプラインで、ゲートの終了時刻と公開の開始時刻を両方ログに残すだけで十分です。その差の分布を1週間眺めると、自分の運用にどれだけの「隙間」が開いているかが見えてきます。
私自身、この隙間の存在に気づくまでに何ヶ月もかかりました。同じ静かな失敗に遭う方が一人でも減れば嬉しく思います。お読みいただきありがとうございました。