先给判断

自动发布最容易被低估的不是生成文章,而是回答“这一篇是否已经被另一个运行发布过”。把激活状态和队列放在本地 ignored 文件里,只能支持单进程演示;一旦出现两个 worker、进程中断或生产已成功但回执未写入,系统就无法可靠判断下一步。

Omnihex 的 R1 把状态放在不连接 Pages 部署的 automation/content-pipeline-state 分支。它不是第二条发布分支,而是 activation、candidate、run、publication 和精确 public manifest 的唯一状态所有者。

CAS 的含义

Git 本身不是数据库,但一个受限的 fast-forward push 可以充当简单的 compare-and-swap。运行先读取 state_head,在本地计算 claim,再只允许从这个旧 head 快进推送。如果另一个运行先写入,远端 head 改变,旧运行的 push 被拒绝;正确行为是 fetch、重读并安全退出,而不是 force push。

read state_head
  -> claim candidate with run_id
  -> commit
  -> push state_head..new_head
  -> if rejected: another worker won; reconcile and stop

这不是分布式锁的完整替代品。它依赖正常 Git 远端、明确的身份字段和失败时不重试写入的纪律,但对“一个候选只能被一个运行领取”已经足够有用。

为什么要拆成多种 receipt

candidate 说明要处理什么:logical ID、source hash、content hash、slug 和 localized routes。run 说明这次执行经历了哪些状态、用了哪些来源、通过了哪些 QA。publication 说明哪个 production SHA、部署和实际路由完成了发布。public-manifest 则回答当前生产到底有哪些公开路由。

把四类对象混成一条日志,会让恢复变得模糊。生产已经成功但 publication receipt 没写入时,reconcile 应比较 live SHA、内容 hash 和路由,确认是同一个候选后补写回执;它不应再次发布。

幂等检查的最小集合

同一运行不得重复创建:

  • run_id
  • candidate_id
  • logical_content_id
  • source_hash 和 content_hash
  • slug/locale
  • 每个 canonical route
  • publication ID

这些不是为了让状态文件看起来复杂,而是避免一个“重试”变成第二篇文章、第二套图片或第二次生产部署。

状态分支不能变成部署旁路

状态分支不应触发 Pages Preview 或 production。它只存非敏感 JSON,生产仍沿用 bot worktree → main → validate → publish-production → production。这样内容状态可以耐久化,却不会因为状态提交直接公开草稿。

这条边界也解释了为什么 exact public manifest 很重要。发布前计算 previous_public_set + exactly_one_candidate + deterministic_derived_routes,再与构建产物比较。数量增加并不等于正确;必须知道增加了哪几个 URL,草稿和无关路由都不能出现。

失败与修复

状态 head 漂移:fetch、重读、放弃本次 claim。锁残留:只有超过 stale threshold 且能证明原进程不存在时才恢复。生产已成功、回执缺失:reconcile,不再发布。live hard gate 失败:按现有非破坏性回滚流程保存证据并停止。

适用边界

这种 Git-native 状态适合低频、可审计、候选数量有限的内容发布。它不是高并发消息队列,也不是跨平台营销数据库。若未来接入多个外部草稿箱,应增加 adapter receipt,但仍保持 state branch 为唯一状态 owner。

来源与边界

Git 文档支持 fast-forward push 和 linked worktree 的基础语义;本文关于 CAS、receipt、manifest 和 reconcile 的细节来自 Omnihex 当前公开仓库实现与测试,是工程案例,不是 Git 官方的分布式一致性保证。