那份只发了一半的脚本
我们用两个商店发布产品,也要处理几类不同的证据。Riff 有 Android 包和 iOS 二进制;QuipKey 有键盘扩展和多个平台表面;WaveCraft 有编辑器、分享扩展,以及可以独立于二进制改变的商店元数据。
第一版自动发布把每次商店操作当成脚本的一步:
- 创建版本;
- 上传构建;
- 上传元数据;
- 提交审核;
- 等商店。
脚本容易读,但进程之外没有记忆。第三步之后超时,下一轮只能猜版本是否已经存在。回调到达两次,可能让同一个发布前进两次。有人在 App Store Connect 手工改了草稿,本地进程还可能以为自己拥有旧内容。Play 的单 draft 约束和 App Store 的版本资源又让这些猜测以不同方式失败。
修法是停止把发布当成脚本。发布是一台持久状态机,商店只是它的观察者之一。
账本拥有「我们在做什么」
Mainline 的发布账本在进行外部变更前先持久化意图。它点名产品、平台、候选构建、渠道、请求的元数据和当前被准入的操作。后来的每一条回调或轮询,都对着这份记录对账。
顺序很重要。如果进程在创建 App Store 版本之后、记录它之前死掉,下一个进程无法知道应该再创建一个还是继续原来的。如果意图先落盘,reconciler 就能查询商店,把观察到的资源绑定回已有操作。同样的规则也处理「回调先于请求返回」:事件是证据,不是创建第二次状态迁移的许可。
账本把脚本曾经混在一起的三件事拆开:
- 意图——这次发布想做什么;
- 证据——商店、构建系统或审核系统说了什么;
- 策略——这些证据是否足够前进。
商店适配器可以报告一个 draft 存在,但不能在没有账本身份检查的情况下决定它属于当前候选。构建回调可以报告一个 artifact 已上传,但如果候选身份与账本预留的不一致,它不能推进发布。
这就是 CI 里同样重要的「观察与策略分界」。外部系统拥有资源事实;Mainline 拥有发布决策。
两个商店,一个领域
App Store Connect 与 Google Play 没有共享的词汇。一边有版本记录、资源作用域和审核提交;另一边有轨道、draft、编辑会话和 staged rollout。强行让适配器暴露一个假想的共同 API,会制造第二份含义:适配器开始把商店状态翻译成领域从未真正观察到的生命周期状态。
所以领域模型拥有不变式,适配器拥有翻译。
对每个候选,Mainline 只准入一次上传操作。App Store 适配器把它映射成商店要求的版本与构建资源;Play 适配器把它映射成 Play 接受的 edit 与 track 状态。两者返回同样形状的证据:资源身份、观察到的状态、提交内容摘要和观察时间。领域 reconciler 决定证据是否关闭操作、是否安排下一次尝试,或把发布送进终态失败。
适配器也保留商店特有的约束,而不是把它们藏掉。Play 不接受同一轨道上两个 draft,所以 Play 操作必须先选择或替换现有 draft,再上传。App Store 元数据属于版本或产品级资源,因此营销更新不能把创建版本作为副作用。一个抹平这些约束的公共 API,会让调用方看起来简单,却把失败推迟到更晚、也更难归属的一步。
资源作用域阻止无意工作
元数据最能说明问题。商店里有许多装着文字的资源:产品页、版本、审核提交、发布说明。旧的上传器把「同步元数据」当成宽泛动作,缺哪个资源就创建哪个。这让一次无害的文案更新改变了发布状态。
新的操作会点名作用域。产品级营销更新只能写产品级资源;版本更新必须绑定候选的版本身份;审核提交读取固定信封并在提交前检查内容。如果请求的资源不存在,操作报告无法继续,而不会因为刚好有另一个资源可用就创建它。
规则刻意很无聊:
一次写入只能创建它的意图点名的资源。
这使重试变得安全。版本写入之后超时,可以重新读取版本并比较摘要;产品页写入之后超时,也能这样做,而不会顺手找到或创建一个版本。账本里保存了请求作用域,因此恢复不需要从一份半截响应里猜它是什么。
幂等是发布功能
发布 worker 会重启,商店回调会重复,worker 离线时审核状态会改变。网络也可能在服务端接受请求之后、客户端收到响应之前断掉。只处理 happy path 的发布系统,最终会要求人记住一次超时究竟是哪一边赢了。
现在每个外部操作都有身份和收敛规则。如果账本说上传操作已准入,重复回调只更新观察,不创建另一次上传。如果轮询看到商店已经处于目标状态,对账就关闭操作。如果商店进入策略无法接受的状态,账本记录终态理由,而不是无限重试。
终态和重试态同样重要。一个元数据同步耗尽尝试、却没有一次成功观察,不会因为 worker 可能回来就继续显示 running;它是带证据的终态失败。一个无法替换的 Play draft 也不再是一条下一轮会用不同方式解释的匿名 400;发布记录商店约束,并点名违反它的操作。
清理也遵循同一条规则。临时上传、过期 draft 和脱离的 artifact 在发布进入终态之后由专门 reconciler 处理。清理失败就记录为清理失败,不会把成功的构建改写成失败发布,也不会删除以后重新清理所需的证据。
产品团队得到什么
直接效果是少一些神秘的商店状态。更有用的效果是,产品改动可以重新用产品语言描述。
当 WaveCraft 修改付费能力时,元数据同步可以更新正确资源,而不会制造一个幽灵版本。当 Riff 发布 Android 候选时,Play 轨道和 draft 规则属于发布模型,不再是脚本里的部落知识。当 QuipKey 有多个平台 artifact 时,候选身份能阻止键盘扩展或 Android 包被挂到错误发布上。
系统没有假装两个商店相同,而是把差异写清楚,并让必须跨商店一致的决策只有一个 owner。
关键结论
在外部变更之前持久化发布意图。持久账本把超时变成一次对账问题,而不是一次猜测。
让商店适配器只报告事实。App Store Connect 和 Play 翻译自己的资源与状态;发布领域拥有身份、准入和策略。
每次写入都点名资源作用域。元数据操作不能创建版本,重试也不能改变原始意图的含义。
把恢复当成正常路径。重复回调、worker 重启和商店端编辑都是普通发布事件;幂等对账让发布完成时不需要人凭记忆重放一份脚本。