技术9/19/2026·约 4 分钟

本地绿、CI 红,而红在一个你从没碰过的 target 上

两个 Android SDK 常量被挪进了「看起来最合理」的那个文件——而那个文件正被一个 module extension 加载。我们的锁文件失效了,CI 在 Go linter 上变红。

看起来最合理的地方

我们抬高了 Android 的 target SDK。与此同时,两个常量——最小 SDK 与 target SDK——被挪进了 versions.bzl,那里已经住着别的 Android 版本,而任何有理性的人都会称之为它们的正确归属。

那是错误的地方,而原因与版本无关,也与 Android 无关。

download_sdk.bzl 是 android_sdk 这个 module extension 的实现——负责下载并固定 SDK 与原生工具链的代码——而它 load 了 versions.bzl。这意味着 versions.bzl 里的每一样东西都进入了那个 extension 的传递 load 闭包,往里面加任何东西都会改变它的摘要。

这意味着锁文件陈旧了。而报错点名的是:

ERROR: MODULE.bazel.lock is no longer up-to-date because: The implementation
of the extension '…Toolchains/SDK:download_sdk.bzl%android_sdk' or one of its
transitive .bzl files has changed.

在一次两行的版本文件改动之后读到这条消息,你不会被指向你的改动。你被指向了一个下载工具链。

为什么这里绿、那里红

同一个提交在每一台开发者的机器上是绿的,在 CI 里是红的。不是间歇性的,不是有时如此——而是结构性地如此,而这个差别就是整个故事。

Bazel 的本地默认是把陈旧的锁文件更新掉。于是本地那次陈旧只是跑任何东西时的副产物,构建继续下去。没有人见过 CI 看到的状态,因为产生那个状态的机器已经把它修好了。

CI 配置为 lockfile_mode=error。它拒绝修复,因为在 CI 内部修锁文件,正是你得到一个「结果取决于它何时运行」的构建的方式。于是 CI 报出真相,而真相是红的——lint:go 红、lint:rust 红、架构守卫红。

三个红色作业,没有一个与 Android 有关。依赖是间接的:一个被 extension 加载的文件变了,于是所有传递使用那个 extension 的 target 全部失效,而那个集合是整个仓库。

这条教训很窄,而且完全可以执行:

用你最严格那个读者的 flag 去验证,不要用你自己的默认值。

确认修法只花了一条命令,而它不是任何人否则会跑的那一条:

bazel build --lockfile_mode=error //DevOps/Tooling/Guardrails/runner:guard  ✓
bazel build --lockfile_mode=error //Products/Android/riff:riff_aab        ✓
MODULE.bazel.lock 无改动;manifest 仍是 target 36 / min 24

真正的错误

把常量挪到另一个文件是我们落地的修法,但把它描述成「挪出 module extension 的加载图」其实低估了它。更深的那一点是:那两个常量本来就不该在一起。

一对描述的是操作系统会对你的应用做什么:哪些行为是必需的、哪些被弃用、平台期待哪些权限。它们在你决定采纳一项新平台策略时改变。

那个文件里的其余几个描述的是下载什么:拿哪个 android.jar 编译、用哪个模拟器镜像测。它们在新的工具链发布时改变。

这是两组事实、两套变更原因,把它们放进一个文件,就把它们的变更速率绑在了一起。代价不是风格问题,而是可量的:每当我们改一次 target SDK,module 锁文件就会动——尽管关于工具链下载什么都没变。

所以那个文件错在目录只是表象。它错在把两个变更原因并成了一个单位。一旦你能说出那两个原因,正确的切分就是显然的,新文件的名字也是显然的:manifest_sdk.bzl。

这与「按消费者切分字符串表」是同一条教训,也与「把按会话的界面和按版本的界面分开」是同一条。一个文件就是一个变更单位。 你放进去的任何东西,都是在声明「这些东西会一起变」,而这句话由读取它的那个工具强制执行。

能做结构性修法的时候

还有一个版本,连新文件都不需要。

构建的别处有一个 module extension 在产出产品元数据的投影,而修法是把那个投影整个搬出 extension——extension 只做它独一无二该做的事,其余一切读一份生成的文件。那次改动让锁文件少了几行,而不是被更新——这正是做对了的标志:你无法从一个锁文件分辨它变大是因为某个依赖真的变了,还是因为你自己的源文件被某个 load 闭包吞进去了。

它背后的诊断值得保留:

如果一个你经常编辑的文件改变了某个你并不拥有的摘要,你就把一份日常文件放进了别人的闭包。把它挪出去。

这类构建系统把锁文件当作「我们抓取了什么」的记录。但它也无可避免地是「我们加载了什么」的记录。那是两件事,而第二件是你要管的——一个仓库自己的锁文件最常见的 churn 来源根本不是依赖更新,而是某个人往一个恰好位于某个 extension 内部的文件里加了一个常量。

小结

搞清楚你自己的哪些文件位于某个依赖的 load 闭包里。被 module extension 加载的文件与锁文件里的每个哈希都耦合,而由此产生的报错会点名 extension 的实现而不是你的改动——这正是一种没人能从报错里归因的失败形状。

至少跑一次最严格那个读者的 flag。 本地默认会修复,CI 会拒绝。任何在这两者有差异的仓库里,都存在一类按构造就不可见的失效,而修法只是一个换了 flag 的命令。本地绿的意思是「工具把它修好了」,那和「工具没有提出异议」不是同一个断言。

然后去找两组事实、两个变更原因。target SDK 说的是平台对你的应用做什么;版本文件说的是下载什么。同一个 extension、同一个仓库、同一个目录——而把它们绑在一起意味着每次策略改动都要动锁文件。当一次改动引发了与它毫无逻辑关联的工作时,那是一个信号,而且它通常指向一个文件边界,而不是某个工具。

并且在可以的地方,把你自己的文件整个搬出闭包,而不是在里面找一个更好的地址。一份变短的锁文件是一份在说真话的锁文件;而一份每次普通编辑都会被更新的锁文件,已经不再是任何你能控制的东西的记录了。

继续阅读