排程發布不是把內容寫進資料表就算完成,而是一條跨越產稿、儲存、校稿、發布與網站可見性的狀態鏈。本文以「排程發布 Fail-Closed」為核心,說明如何把雙語文章、狀態與時間欄位寫成可測試契約,並讓任何不一致都停在安全邊界。重點不是特定網站的內部實作,而是任何內容管線都能採用的狀態機、回讀與驗證方法。
為什麼排程發布必須採 Fail-Closed
排程發布 Fail-Closed 的直接答案是:只要狀態、語言、翻譯群組、日期或網站回應有一項無法證明正確,流程就停止,不以先前成功或模糊字串推定可以發布。
一般自動化最危險的錯覺,是把外層排程顯示成功等同於文章已安全上線。其實一條發布鏈至少有五個不同事實:排程程序有沒有正常結束、內容套件是否通過驗證、資料列是否精確寫入、Editor 是否完成狀態轉換,以及公開網址是否真的可讀。前一層成功,不能替後一層背書;後一層失敗,也不必然表示前一層沒有寫入。
Fail-closed 與一般錯誤處理的差異,在於預設決策。Fail-open 會在資訊缺漏時繼續,例如把 Draft、Drafted 或大小寫不同的值視為同義,或在回讀失敗時沿用昨天的快照。Fail-closed 則要求證據完整才前進:待校稿狀態必須精確等於 Drafted,發布時間必須為空,兩篇語言集合必須正好是 zh-TW 與 en,而且共用一個非空翻譯群組。這些條件任何一項不成立,後續寫入與公開動作都不得啟動。
這種設計也能降低重跑風險。排程可能因網路、程序中止或外部服務延遲而重試;如果新稿可以覆寫既有 slug,或只憑內容標題判斷是否存在,就可能造成重複列、錯誤覆蓋或不同語言失配。安全作法是讓每日產稿只能新增新 slug,遇到既有資料立即失敗,並把冪等性放在明確識別欄位與回讀比對上,而不是依賴「大概是同一篇」的文字相似度。
對 SEO 與 GEO 而言,這不只是維運問題。錯誤狀態可能造成尚未校稿的頁面被索引、翻譯頁面缺一、canonical 尚未建立,或結構化資料與可見內容不同步。可靠的狀態契約讓搜尋引擎看見的是已完成審核的一致文件,也讓生成式搜尋系統取得可追溯且語意相符的雙語內容。
用狀態機定義 Drafted 與 Published 不變量
最小狀態機只需要兩個可持久化狀態:Drafted 代表已寫入但尚未發布,Published 代表 Editor 已核准;兩者必須與發布時間形成不可分割的不變量。
先定義資料列規則,而不是先寫一堆條件分支。當狀態是 Drafted,PublishedOn 必須為空;當狀態是 Published,PublishedOn 必須是包含時區位移的完整時間。未知狀態一律無效,不自動正規化,不把 Draft 推測成 Drafted。如此可以讓拼字漂移直接成為可觀察錯誤,而不是沉默地穿過 Editor gate。
雙語套件另有群組不變量。每次產稿正好兩篇,語言集合不可重複也不可缺漏,兩篇 translationGroup 必須完全相同且非空,但 slug 必須不同。翻譯群組表示兩篇共享核心結論與來源證據;它不代表逐句直譯。中文可以先回答台灣技術讀者最在意的排程風險,英文則可採用 release gate 與 state transition 的自然術語,只要技術主張、來源集合與測試結論一致即可。
狀態轉換的權限也應拆開。產稿工作只建立 Drafted,不得自行填入發布時間;Editor 才能在校稿、來源 hash、格式與本機測試通過後,將狀態改成 Published 並同時寫入完整時間。這項分權避免單一工作既產生內容又宣告自己審核成功,也讓稽核紀錄能回答「哪一層做了什麼」。
資料表只是持久化媒介,不是狀態真相的唯一來源。Google Sheets values.append 官方介面會回傳 updates 資訊,適合確認附加操作的範圍;但寫入回應仍應配合立即回讀,確認 Status、PublishedOn、Language、TranslationGroup 與 slug。回應表示 API 接受要求,不等於另一個流程沒有在下一秒改寫資料,因此 Editor 啟動前還要再做一次 context gate。
建議將不變量整理成決策表:Drafted + 空白時間 可進入校稿;Drafted + 非空時間 拒絕;Published + 完整時間 可進入網站驗證;Published + 空白時間 拒絕;任何未知狀態拒絕。決策表比散落在提示詞與試算表公式中的慣例更容易寫成自動測試,也更容易在變更時審查。
分離寫入、Editor 與網站可見性 Gate
可靠管線要把資料寫入、編輯核准與網站可見性視為三個獨立 gate;每個 gate 都有自己的輸入、通過條件與失敗訊息,不能互相替代。
第一個是內容與寫入 gate。它檢查 slug 唯一、正文長度、標題結構、來源檔存在、SHA-256 相符、官方參考網址可開啟,以及可重現清單完整。通過後只附加兩筆 Drafted,並保持 PublishedOn 空白。寫入完成後,立即依回傳範圍與識別欄位回讀;若列數、語言或群組不符,就標記寫入層失敗,不進入下一階段。
第二個是 Editor gate。Editor 不應只查「今天有草稿」,而要確認今天正好兩筆、語言集合精確、翻譯群組一致、slug 與本機套件完全相同,且上游產稿工作確實完成。Editor 還要重新計算來源 hash,因為產稿後到校稿前,來源檔仍可能改變。若 hash 漂移,正確決策是要求重新產生或重新審查,不應把舊稿與新來源混為一談。
第三個是公開網站 gate。RFC 9110 將 404 定義為來源伺服器沒有找到目前表示,或不願揭露其存在。對草稿網址而言,404 是合理且期望的可見性結果;它只證明該次 HTTP 要求沒有取得公開表示,不證明資料列是否已寫入。Editor 將狀態改成 Published 後,才期待頁面回 200,並進一步檢查唯一 canonical、BlogPosting 與可索引設定。
這三層必須使用精確回報語言。例如草稿階段應寫「Sheet 已寫入 Drafted,網站尚未發布」,而不是「文章已上線」。如果資料列已改為 Published 但網址仍 404,應寫「資料層已發布、網站尚未同步」,保留兩個事實,不猜測一定是快取或伺服器故障。精確狀態可以避免值班者追錯方向,也能讓後續事件分析找到真正斷點。
同樣原則適用於 Dashboard。報表能讀到搜尋數據或產生圖表,不代表 Markdown inventory 正常;已知來源目錄存在卻盤點為零時,應將內容來源 gate 標為 degraded 或 failed,並輸出解析後根目錄、枚舉數與過濾統計,但不可輸出筆記正文。成功旗標必須對應明確範圍,不能用一部分資料成功掩蓋另一部分資料缺失。
驗證與重現:用 Python 3.11 測試契約
這組驗證在 Windows 11 排程環境以 Python 3.11.15 實際執行,使用標準庫 unittest,不連線正式系統、不修改遠端資料,也不要求任何真實帳號。
- 建立空白測試目錄,確認
python --version為Python 3.11.15,並將下列內容儲存為fail_closed_fixture.py。 - 在該目錄執行
python fail_closed_fixture.py;三個測試必須全部顯示ok,結尾為Ran 3 tests與OK。 - 將第二筆狀態從
Drafted改成Draft後重跑;正確 fixture 仍應通過,因為負向案例要求package_is_editor_ready回傳 False。若函式接受該資料,負向測試就會失敗。
from dataclasses import dataclass
from datetime import datetime
from unittest import TestCase, main
@dataclass(frozen=True)
class ArticleRow:
language: str
status: str
translation_group: str
published_on: str = ""
def package_is_editor_ready(rows):
return (
len(rows) == 2
and {row.language for row in rows} == {"zh-TW", "en"}
and all(row.status == "Drafted" for row in rows)
and all(row.published_on == "" for row in rows)
and len({row.translation_group for row in rows}) == 1
and bool(rows[0].translation_group)
)
def is_complete_timestamp(value):
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return False
return parsed.tzinfo is not None and parsed.utcoffset() is not None
def row_invariant_holds(row):
if row.status == "Drafted":
return row.published_on == ""
if row.status == "Published":
return is_complete_timestamp(row.published_on)
return False
class ContractTests(TestCase):
def test_pair(self):
rows = [ArticleRow("zh-TW", "Drafted", "fixture-group"),
ArticleRow("en", "Drafted", "fixture-group")]
self.assertTrue(package_is_editor_ready(rows))
def test_drift(self):
rows = [ArticleRow("zh-TW", "Drafted", "fixture-group"),
ArticleRow("en", "Draft", "other-group")]
self.assertFalse(package_is_editor_ready(rows))
def test_time_invariant(self):
self.assertTrue(row_invariant_holds(
ArticleRow("en", "Published", "fixture-group", "2026-08-31T09:00:00+08:00")))
self.assertFalse(row_invariant_holds(
ArticleRow("en", "Published", "fixture-group")))
self.assertFalse(row_invariant_holds(
ArticleRow("en", "Published", "fixture-group", "2026-08-31T09:00:00")))
if __name__ == "__main__":
main(verbosity=2)
本次真實執行結果為三項測試全部通過,程序 exit code 為 0;沒有捏造 benchmark、處理量或成功率。測試涵蓋正確雙語草稿、錯誤狀態與群組漂移、發布時間不變量。它刻意不測正式網站與資料表,因為本機 fixture 的目的,是先證明決策函式在安全 sandbox 內會拒絕錯誤輸入。
測試失敗條件必須可觀察:正確草稿被拒絕、非標準狀態被接受、Published 沒有時間卻通過,或 Drafted 帶時間仍通過,都應造成斷言失敗與非零 exit code。這比只檢查程序是否印出文字可靠,因為每項主張都有對應斷言。
實作安全的重跑、回讀與錯誤回報
安全重跑的原則是先查明目前狀態,再決定是否重做純計算步驟;任何可能新增或改寫遠端資料的步驟,都必須先以 slug 與群組回讀防止重複。
產稿與 validator 通常是可安全重跑的純計算,只要輸入來源與版本固定。upsert 則不是;它應在寫入前拒絕既有 slug,並在寫入後記錄 updatedRange。Editor 也不應對已是 Published 的列再次套用發布時間,因為重寫時間會破壞首次發布的歷史意義。若需要修訂,應使用獨立 ModifiedOn,而不是假裝重新首次發布。
外部服務失敗時,不要沿用過期 context 當成線上證據。舊快照可用於離線診斷或主題去重,但不能證明目前資料列仍是 Drafted,也不能授權 Editor 進行狀態轉換。恢復後應重新取得 context、重新驗證 hash、重新檢查 slug,再從最後一個可證明的 gate 繼續。
錯誤訊息要同時包含層級、期望與觀察值,但避免洩漏來源正文或環境細節。例如「Editor gate failed: expected two Drafted rows, observed one」比「發布失敗」可行動;「site gate failed: expected 200 after approval, observed 404」則保留 HTTP 事實。記錄 hash 可以證明來源版本,不需要把筆記內容寫入 log。
版本固定也很重要。驗證清單應明列 Python 3.11.15、標準庫、Windows 11 或等價 sandbox,以及完整命令。若未在排程環境執行,就必須明寫「讀者可重現步驟,排程環境未執行」;若已執行,則只回報真實測試名稱、數量與 exit code,不延伸成效能結論。
發布前檢查清單與結論
發布前應依序確認來源、套件、資料列、Editor 與網站五層證據;任何一層缺失就停在當前狀態,保留可重跑資訊,而不是用人工猜測跨過 gate。
- 來源層:根目錄存在、安全候選大於零、每個引用檔完整讀取,SHA-256 與套件一致。
- 套件層:正好一篇中文與一篇英文,slug 不同、翻譯群組相同,核心結論與官方來源一致。
- 內容層:H1 唯一、主要段落可直接回答、驗證步驟可在 sandbox 執行,Schema 與可見內容相符。
- 寫入層:只新增兩筆 Drafted,PublishedOn 空白,updatedRange 與回讀列數一致。
- Editor 層:只選精確 Drafted,重新驗證來源與測試,通過後才同時寫 Published 與完整時間。
- 網站層:草稿 404 不宣稱公開;發布後要求 200、canonical、BlogPosting 與索引設定分別通過。
排程發布 Fail-Closed 的價值,在於把「看起來有跑完」改造成「每一層都能提出證據」。狀態機限制合法轉換,不變量綁定狀態與時間,雙語群組確保兩個語言版本一致,分層 gate 則阻止局部成功被誤報成完整發布。最後再用固定版本的本機測試與公開規格查核,管線就能在不確定時安全停止,在證據完整時明確前進。