不可变发布,不等于每次从零开工

发布要有自己的家当,不代表每件家当都得重新造。
1. 隔离是目标,重装不是⌗
不可变 release 很容易长成这样:导出一份源码,安装依赖,构建界面,封成只读目录。下一次提交,再来一遍。
这套流程好理解,也很容易把两件事绑死:发布内容必须独立,于是生产这些内容的工作必须重复。
其实不是同一个要求。
如果一次修改只涉及界面从未引用的后端代码,依赖声明没有变化,界面输入也没有变化,那么再次安装和打包,并不会因为多跑了一遍就增加隔离性。
这次发布存储的改动,把身份拆成了三层:
| 身份 | 回答的问题 | 依据 |
|---|---|---|
| Release 身份 | 这一份发布包含哪棵源码树? | Git tree |
| 依赖制品身份 | 这些安装输入是否相同? | 依赖输入的 SHA-256 摘要 |
| 界面制品身份 | 这些界面构建输入是否相同? | 界面输入及依赖摘要的 SHA-256 摘要 |
不同 release 可以使用相同制品,但不能因此变成同一个可写目录。
真正值得留下的设计不是「加了缓存」,而是:把需要独立的东西和可以等价的东西分开命名。
2. 缓存键不是提交号,也不只是锁文件⌗
用提交号缓存制品,逻辑当然简单。但任何后端改动都会产生新提交,缓存也就跟着失效。它准确地识别了版本,却没有识别工作是否值得重做。
另一头,只拿依赖锁文件当键,又会漏掉安装环境和安装行为。
dependencyInputs 在这两者之间划出一条明确边界。它把以下内容纳入摘要:
- 根包和 workspace 包中影响依赖安装的字段,包括依赖、覆盖规则、补丁声明、平台约束等。
preinstall、install、postinstall、prepare安装生命周期脚本的文本。- 锁文件、包管理器配置、补丁目录内容。
- Bun 版本、操作系统和处理器架构。
对象键经过排序;文件按路径排序,摘要还写入路径和内容长度。文件缺失也作为一种输入状态参与计算。它不是把一堆内容随手拼起来,再希望边界碰巧没有歧义。
相反,包版本号、普通后端源码以及 workspace 的一般源码变化,不会仅凭自身变化就让依赖制品失效。
这依赖一个重要前提:安装结果必须确实由已建模的输入决定。
源码纳入了安装脚本的文本,却没有自动追踪脚本执行时读取的所有文件,也没有捕获任意环境变量或网络响应。如果脚本读取一个未计入摘要的源码文件来生成安装产物,单纯修改那个文件就可能复用旧结果。这是输入模型的适用边界,不是 SHA-256 能替工程师解决的问题。
因此,这是一套针对当前构建约定的输入模型,不是通用的密闭构建证明。推广到别的项目,第一件事应当是检查安装行为,而不是复制哈希函数。
3. 界面边界,不能画在界面目录门口⌗
界面往往会导入目录之外的共享类型或运行时代码。只对 ui/ 做摘要,看起来很省事,却可能在共享代码变化时继续提供旧界面。
本次实现先收集界面目录的文件,再以其中非测试代码文件为入口,遍历导入图,把可达的共享文件也加入摘要。此外,摘要还包含根目录中的相关构建配置、ui:build 命令和依赖摘要。
依赖输入 ───────────────► 依赖摘要
│
界面目录文件 ────────────────┤
界面代码的传递导入 ──────────┤
根构建配置与构建命令 ────────┤
▼
界面摘要
这使两种变化得到不同待遇:
| 变化 | 依赖制品 | 界面制品 |
|---|---|---|
| 无关后端文件变化 | 可复用 | 可复用 |
| 界面引用的共享运行时代码变化 | 可复用 | 重建 |
| 界面配置变化 | 通常可复用 | 重建 |
| 锁文件变化 | 重新安装 | 重建 |
这里有意采用了一种保守做法:界面目录内的文件广泛参与摘要,而不是仅计算最终 bundle 真正用到的最小集合。生成目录 dist、安装目录 node_modules 和 .git 被排除,避免把结果重新算进输入。
它追求的不是最高命中率,而是让失效边界能被读懂和测试。代价也明确:收集文件、遍历导入图和计算摘要仍然需要工作;缓存命中并不等于零成本。
4. 复用内容,不共用可写现场⌗
缓存命中之后,最省代码的办法是让所有 release 都指向同一个依赖目录。但 workspace 会让这个办法很快变得不省心。
一个依赖链接指向的,到底是当前 release 的源码,还是开发工作区,或者缓存自己的目录?路径能解析,只说明文件找到了,不说明找对了。
新的流程保留每份 release 的独立文件树:
源码树 ──导出──► staging
│
├─ 依赖命中:克隆依赖目录
│ 依赖未命中:安装,再保存制品
│
├─ 界面命中:克隆界面产物
│ 界面未命中:构建,再保存制品
│
└─ 写发布信息 → 封为只读 → rename 成正式 release
macOS 路径使用 cp -c -R 克隆;其他平台使用带 COPYFILE_FICLONE 的递归复制。后者请求文件系统克隆,不应被解释成在所有平台都保证物理块共享。
写时复制的价值在于:可以共享底层内容,同时保有各自的文件树。它不是硬链接,也不是让发布目录直接依赖一个公共可写现场。
workspace 的相对符号链接保持原样,因此在新 release 里仍解析到这份 release 自己的 workspace 源码。缓存存的是安装制品,不是某个旧版本的 workspace 源码替身。
制品缓存先写入临时目录,设为只读后再重命名到摘要目录。release 也在 staging 完成收尾与只读化,最后才进入正式位置。已封好的依赖克隆会被记录下来,最终只读化时跳过,避免再次遍历这些目录做相同工作。
这里的只读是文件权限层的封装,不是面对高权限进程的安全沙箱。它表达的是发布生命周期约束。
5. 最容易说谎的,是版本号⌗
界面 bundle 可以复用,界面上显示的提交号怎么办?
如果提交号和构建时间永久内联在 JavaScript 里,新 release 复用旧 bundle,就会显示旧提交。反过来,为了更新一行版本文字就重新打包整个界面,又把刚省下来的工作加回去了。
这次改动把发布元数据拆到 build-info.js,由每份 release 单独写入。界面代码通过运行时全局值读取它,不再只依赖编译期常量。
但直接读「最新元数据」仍然不对。旧 PWA 页面可能还在运行旧 bundle;此时拿新 release 的提交号贴上去,只是换一种方式说谎。
所以,元数据按界面输入摘要索引,bundle 的启动代码只接受与自己摘要匹配的那一项:
| 当前页面遇到什么 | 显示什么 |
|---|---|
| 新 release,界面输入相同 | 采用新 release 的提交号和时间 |
| 新 release,界面输入不同 | 保留这个旧 bundle 原有的标记 |
| 没有可用元数据 | 保留 bundle 原有标记 |
这还要求缓存策略配合:build-info.js 不进入 Workbox 预缓存,HTTP 响应设为 no-cache。这里的语义是复用前重新验证,不是禁止保存任何副本。
它没有声称解决所有 PWA 更新问题。它解决的是一个窄而重要的事实边界:旧代码不能仅因为看到了新发布的信息,就冒充新代码。
6. 缓存必须有退场机制⌗
制品从 release 中独立出来后,也获得了独立堆积的能力。只清理发布目录,制品缓存不会自动消失。
新的清理规则先决定保留哪些 release:当前版本、已知良好版本、试用期版本及其前任,以及按配置保留的最近版本。随后读取它们记录的依赖摘要和界面摘要。
两类缓存分别保留:
- 所有被留存 release 引用的制品。
- 该类别最近使用的一份制品,即使它暂时没有被留存 release 引用。
最近使用时间记录在缓存根目录的 mtime 上,每次使用后更新,不改制品文件内容。依赖与界面各算各的最近使用,不能用一个笼统的「最新缓存」代替。
这个策略把回滚可用性与少量热缓存同时纳入保留规则,但没有引入引用计数数据库。它也不承诺固定容量:如果留存 release 使用了许多不同制品,这些制品就都得保留。
7. 测试证明跳过了什么,而不是快了多少⌗
这次改动的回归测试,最有价值的地方不是「构建成功」,而是把复用的边界拆开检查。
| 测试中的操作 | 代码断言 |
|---|---|
| 修改无关后端与 workspace 源码 | 依赖和界面摘要保持相同 |
| 修改共享文件的传递导入目标 | 界面摘要变化 |
| 修改开发目录里的已安装产物 | 新 release 仍取得缓存中的安装产物 |
| 构建第二份可复用 release | 日志中没有安装和界面构建命令 |
| 检查 workspace 链接 | 解析到新 release 自己的源码 |
| 修改界面输入 | 复用依赖,重新构建界面 |
| 修改锁文件 | 重新安装依赖,重新构建界面 |
| 清理旧 release 与孤立制品 | 被引用制品及每类最近使用制品仍在 |
元数据测试还分别检查了同摘要切换发布信息、不同摘要拒绝串用,以及 HTTP 重新验证策略。
以上来自测试代码的断言;本文没有重新执行测试,也没有安装耗时、磁盘占用或端到端发布时延的测量。因此能说的是:命中路径跳过了原本的安装与打包工作,不能凭空报一个加速百分比。
对一个不断迭代的 agent 运行时,这种优化的意义很朴素:后端行为在变,发布仍有独立源码和依赖现场,而没有变化的界面不必陪着重新开工。
不可变发布约束的是成品如何存在,不是要求生产线失忆。
源码依据:提交 1482c21f 中的 src/release/inputs.ts、src/release/store.ts、src/release/build-label.ts、src/server/http-shared.ts 与 ui/vite.config.ts;克隆机制同时参照该提交中的 src/rehearsal/clonefile.ts。测试依据:src/release/inputs.test.ts、src/release/build-label.test.ts、src/release/release.test.ts。