取り下げた原稿が「救出」経路で復活して自動公開された — 削除ではなく印を残す

  • #自動公開
  • #安全弁
  • #Git
  • #運用設計

今回やったこと

取り下げた原稿が、後から別の自動処理に救出され、公開レーンへ戻る経路を止めた。焦点は、取り下げ操作を「ファイルの削除」として扱わないことにある。削除されたファイルは、回収漏れの作業なのか、公開してはいけない原稿なのかを機械から区別できない。

今回の対策では、原稿に withdrawn: true または status: withdrawn の印を残し、必要なら withdrawnReason: で理由を記録する。さらに drafts/rejected/ に同じ slug の原稿を保存し、公開前に共通ゲートを通す構成へ変更した。この記事は、その設計を実装から読み解く記録である。

削除が「救出対象」に見えた

実際の事故では、事実誤認が判明した原稿のPRをマージせずクローズしたため、作業ブランチだけが孤児化して残った。その後、孤児ブランチを棚卸しする処理が、PRにならず消えかけている作業と取り下げ済み原稿を区別できず、drafts/ready/ に復活させた。復活した原稿は drafts/ready/ から drafts/deploy/ へ進み、段階的な自動配信まで到達した。

ここで問題なのは、救出処理だけを賢くすることではない。人間の判断や別の処理が同じ原稿を再配置する可能性をゼロにできない以上、最後の出口で止める必要がある。scripts/lib/withdrawn.py は、そのための公開側ゲートである。

WITHDRAWN_STATUSES = {"withdrawn", "retracted", "rejected", "取り下げ"}

reason = gate_reason(
    slug,
    text=draft_text,
    rejected_slugs=load_rejected_slugs(rejected_dir),
)
if reason:
    stop_publish(reason)

実装は二つの条件を調べる。ひとつは原稿自身の frontmatter。もうひとつは取り下げ置き場に同じ slug が残っているかである。後者があるため、救出された複製から印が剥がれていても公開を止められる。日付サフィックス付きのファイル名も、素の slug として照合できるようになっている。

ゲートは「存在」ではなく「停止」を検査する

安全弁は、モジュールが存在するだけでは不十分だ。取り下げ原稿をわざと置き、公開レーンが拒否するところまで回帰テストで確認する必要がある。リポジトリには scripts/test_withdrawn_gate.py があり、取り下げ済み原稿と通常の救出対象を区別する条件を固定している。

状態ファイルの扱い公開レーンの判断
通常の下書きready/ に保存続行可能
frontmatter に withdrawn: trueready/ にあってもよい停止
drafts/rejected/ に同じ slug複製が ready/ に戻ってもよい停止
理由だけを記録した通常原稿原稿の状態は未変更状態に応じて判定

取り下げの理由を残すことにも意味がある。単に「無効」とだけ書くと、次の担当者が再検証を始めたり、同じ判断をやり直したりする。withdrawnReason: は公開可否を決める唯一の根拠ではないが、停止理由を運用ログへ渡すための説明になる。

やってみてわかったこと

公開を止める安全弁は、入口の削除処理に置くより出口側に置いた方が、経路の増加に耐えやすい。ただし出口側だけに依存してよいわけではない。取り下げを main に残し、拒否置き場にも履歴を残し、公開レーンが両方を見るという二重化が必要になる。

「無いから回収する」という自動化は、削除を状態として扱わない限り危険である。取り下げは消去ではなく、再利用禁止の状態遷移として保存する。これで、救出処理が改善されていない期間でも、公開という不可逆な出口を止められる。

試した環境・更新履歴

  • 試した環境: リポジトリ内の Python 実装と回帰テストを対象に確認
  • 更新履歴: 2026-09-01 初稿
  • 訂正履歴: なし