你刚发布了一个 patch 版本。测试全绿,CI 通过,semver 规范遵守得无可挑剔。三周后,issue 开始涌入。
关于一个库如何被实际使用,真正的信息存在于依赖方的代码里,而不在库自己的测试或文档中。某个下游项目正在用正则表达式解析你的错误消息;另一个依赖你从未文档化的查询结果排序;还有人在调用那个你认为是内部实现、只是忘了标 private 的方法。Hyrum’s Law 早就说清楚了:用户足够多之后,你 API 的所有可观察行为都会被某人隐式依赖,而语义化版本号能告诉你的只是维护者的意图,不是下游代码实际依赖了什么。
Maven 的一项 2023 年研究显示,11.58% 的依赖更新对客户端造成了破坏性变更,其中近半数出现在非 major 版本升级中。大多数库维护者在发布前没有办法验证自己的版本号是否名副其实。反馈循环是被动的:发布,等待 bug 报告,希望破坏还没扩散太广。
Linux 发行版的做法
Debian 的 DEP-8 规范定义了软件包的测试接口,当一个包是从 unstable 迁移到 testing 的候选时,迁移工具 Britney 会为该包及其所有反向依赖触发 autopkgtest。回归会阻断迁移。一次 Expat 更新如果导致依赖方测试失败,就会留在 unstable 里,直到有人解决问题;Coq 一次更新破坏了 mathcomp-analysis 和 mathcomp-finmap,同样走了这个流程。维护者会在变更到达任何不想尝鲜的用户之前,知道自己破坏了谁以及如何破坏的。
autopkgtest 并不检查 API 兼容性,它运行的是真实消费者的真实测试套件,这些测试套件编码了消费者积累的所有隐式约定,包括上游维护者从未听说过的那些。如果库 Y 在 patch 版本里修改了哈希表的迭代顺序,而包 X 的测试假设这个顺序是稳定的,迁移就会阻断,直到有人决定谁的假设是错的。
Fedora 最近的工作走得更远。通过 tmt、Packit 和 Testing Farm,他们在 PR 阶段运行下游测试,在任何东西发布之前。Cockpit 项目的配置让针对核心库提交 PR 时,cockpit-podman 和其他依赖方的测试套件会自动在提议的变更上运行,结果在合并之前作为状态检查出现。正如他们所说:“在发行版层面发现问题已经太晚了——那时包含回归的新上游版本已经发布,罪魁祸首可能早在几周前就落地了。”
时机决定成本。维护者在 PR 中发现下游破坏时,还在变更内部。他们记得为什么重构了那条错误路径,知道自己考虑过哪些测试,diff 就在眼前。在这个时间点响应下游失败的成本是几分钟的思考,也许加上一种修改后的方案。当同样的破坏三周后以 issue 的形式浮现,维护者必须重新加载变更的上下文,理解下游项目的使用方式以弄清楚为何破坏,决定是向前修复还是回滚,发布新版本,然后希望已经 pin 住旧版本的消费者会解 pin。信息在两种情况下是相同的,都是一个下游测试失败了,但响应成本随着距离引发失败的变更的时间差而增长。
语言生态系统的困境
发行版能做到这些,是因为它们具备语言生态系统所缺乏的结构属性:单一的规范依赖图、标准化的测试接口、共享的执行环境,以及基于下游结果阻断发布的权力。npm、PyPI 和 RubyGems 有碎片化的工具链,没有从外部调用软件包测试的标准方式,执行环境各异,也没有机制能拦截一次发布。尽管如此,一些语言生态系统还是构建了部分版本的下游测试,它们往往属于有资源解决这些问题的编译器团队。
Rust 的 crater 在当前和提议的编译器上编译并测试 crates.io 上的每个 crate,然后对比结果。最近一个向标准库添加 impl From<f16> for f32 的 PR 在 650,587 个被测试的 crate 中破坏了 3,143 个。按 semver 规则,添加 trait 实现是明确向后兼容的,但它破坏了数千个下游项目的类型推断,因为现有代码依赖于这两种类型之间恰好只有一条转换路径。Crater 在这个变更发布之前捕获了它,整个运行在 Linux x86_64 上花了五到六天。没有它,Rust 团队会从 3,143 个独立的 bug 报告中发现这个破坏。
Crater 还受益于 Rust 是编译型语言:类型推断失败会在构建时出现,在任何测试运行之前。在 Python、Ruby 或 JavaScript 中,等效的破坏只会在运行时出现,所以你需要下游测试套件实际覆盖受影响的代码路径。动态生态系统对下游测试的需求反而更强,因为没有编译步骤来捕获容易发现的那类破坏,但信号也更难获取。
Node.js 运行 CITGM(Canary in the Goldmine,金矿里的金丝雀),针对提议的 Node 版本测试约 80 个精心挑选的 npm 包。Node 12 中的一次重构将 isFile 从 Stats.prototype 移到 StatsBase.prototype,对公共 API 什么都没改变,但破坏了 esm 模块,因为它直接遍历了原型链。另一次发布中,EOF 时 readable 事件时序的变化破坏了 dicer 模块,它依赖于那个事件同步触发。
这些工具都是由拥有专用基础设施预算的团队构建的。一个在 npm 或 PyPI 上发布广泛使用软件包的个人库维护者,即使面临着同样的问题,也没有任何可比拟的东西。
信号流向了错误的方向
Renovate 的 Merge Confidence 从数百万个更新 PR 中聚合数据,告诉消费者某次更新是否安全:发布多久了,有多少 Renovate 用户采用了它,有多少百分比的更新测试通过。信号来自真实项目的真实测试结果,但它在发布之后才到达,并流向消费者,而不是流回发布了变更的维护者。
Dependabot 在安全更新 PR 上显示兼容性分数,从做了同样更新的其他公开仓库的 CI 结果计算而来,但只有在至少存在五个候选更新时才显示,数据也不流回维护者。作者正在 dependabot.ecosyste.ms 索引 Dependabot PR,构建这个信号的开放版本,目前追踪每次更新的合并百分比,作为特定版本升级在整个生态系统中造成多少麻烦的粗略代理。
还有一个可见性缺口。注册中心追踪哪些包声明了对其他包的依赖,但使用库的应用程序大多是不可见的:依赖某个 gem 的 Rails 应用不会出现在 RubyGems 的反向依赖列表中,使用某个 npm 包的公司内部服务不会出现在 npmjs.com 上。维护者对依赖方的视图局限于注册中心能看到的,严重偏向其他库,而错过了应用程序。应用程序才是那些更奇怪的使用模式和更出人意料的隐式约定出现的地方。
ecosyste.ms 跨包和开源仓库追踪依赖方,扫描 GitHub、GitLab 及其他代码托管平台上数百万个仓库中的清单文件。维护者可以看到哪些应用程序实际使用了他们的库,这是构建下游测试系统所需要的视图。
缺失的那块拼图
作者想在 ecosyste.ms 之上构建这样一个工具。维护者将服务连接到自己的 CI,在每次 PR 或预发布分支上,它查询 ecosyste.ms 获取该包的前 N 个依赖方,包括库和应用程序,按依赖方数量、下载量和提交新鲜度的某种组合排名。它克隆每一个,用库的提议版本替换当前发布版本,在隔离环境中运行它们的测试套件。结果作为 PR 上的报告返回:哪些依赖方被测试了,哪些出现了回归,堆栈跟踪是什么样的,维护者的哪些变更可能导致了每次失败。
一个维护者在打标签发布前查看这份报告,会看到对他们来说目前不可见的东西:流行应用程序用正则解析错误消息,修改措辞会导致破坏;一个广泛使用的包装库调用了他们认为是内部实现、正要删除的方法;他们优化批量数据库调用改变了回调顺序,两个下游项目的集成测试依赖于这个顺序。
Michal Gorny 的文章列出了下游测试 Python 包的失败模式:测试套件假设自己在一次性容器中就修改已安装文件、pytest 插件引发意外的测试收集、测试需要网络访问或 Docker、时序相关的断言、跨架构的浮点精度差异、源码发行版完全省略了测试文件。任何跨注册中心尝试这件事的服务都需要优雅处理所有这些问题,区分真正的回归和环境噪声,这是 Debian 用 autopkgtest 花了多年时间精炼、仍未完全解决的难题。
这类开发者工具通常通过出售企业版来盈利,但大型公司面临内部团队之间类似协调问题时,已经用 monorepo(单体仓库)解决了。当所有代码在一棵树里,下游测试就是普通 CI:合并前运行所有受影响的测试,不需要特殊基础设施。Google、Meta 和 Microsoft 在让这件事运转起来上投入了大量资源,在他们的 monorepo 内部,问题已经解决了。没有人会为开源维护者的这个问题购买企业版,这就让开源维护者成为这类工具的唯一受众,而他们没有钱。
ecosyste.ms 已经提供了依赖方发现,包元数据中链接了源码仓库,测试套件遵循生态系统惯例足以自动化,容器基础设施让隔离环境变得廉价。Crater 和 autopkgtest 已经证明了这种方法在生态系统规模上可行。缺失的那块是将这些拼接成个人维护者无需编译器团队的预算或发行版的基础设施就能使用的东西。