四个CI检查同时变绿但实际已失效的真实案例,包括lint超时配置错误、ESLint规则被注释等隐蔽陷阱。
失去守护能力的检查
你的测试套件证明了你的代码能工作。但仓库里没有任何东西能证明你的检查本身还能工作。
绿色的勾和失明的勾看起来一模一样
我们度过了糟糕的一天——不是那种戏剧性的灾难,没有服务宕机,没有用户察觉。只是在同一个仓库里,四个彼此独立的检查都显示绿色,而这四个检查都悄无声息地失去了失败的能力。
这种情况比检查坏掉更糟糕。坏掉的检查会大声嚷嚷:流水线变红,有人来查。而失明的检查是沉默的,沉默和成功看起来毫无区别。你继续合并,仪表盘保持绿色,那个检查本来要拦截的东西直接从它身边走过。
下面列出全部四次,以及具体的数字——因为只有把它们放在一起,规律才变得清晰。
GitHub Actions 里有一个 lint 任务,配置如下:
jobs:
lint:
timeout-minutes: 15 # 整个 job 的预算
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
- run: golangci-lint run --timeout=15m # 工具的预算
两个数字都是 15。Checkout、工具链初始化和模块下载都要从 job 的时间预算里扣除,所以工具自己设置的超时永远到不了——job 会先死掉。
这个区别恰恰是要害。工具的超时会失败并给出说明:timeout after 15m, package x。而 job 超时则直接杀死运行器,GitHub 只写一个 cancelled,什么原因都不给。我们的部署路径被整整封堵了一天,涉及三个提交,其中一个只改了文案,但没有一次运行说出原因。
规则只有一行:job 的限制必须大于其中任何工具的限制。否则你就是在用一次可以诊断的失败换取一次无声的杀死。
几个月前我们彻底修复了行尾符问题:.gitattributes 里写了 * text=auto eol=lf,验证过了,提交了,完成了。花了两天才找到,修复生效了。
它又回来了。我们测量发现,它其实从未离开过:工作区里 1455 个文本文件中有 1230 个仍然带着 CRLF。
.gitattributes 在 checkout 时生效。在规则建立之前创建的工作区会永远保持它的 CRLF 文件,而没有任何东西告诉你这一点——因为 git 在比较时会做归一化:
git status # "nothing to commit, working tree clean"
git diff # empty
file config.ts # ASCII text, with CRLF line terminators
prettier --check . # 287 files broken
在 CI 里执行同样的命令只报告了两个。哪个数字都没用:相信本地数字就要重格式化 285 个没人碰过的文件;忽略它又会漏掉那两个真正有问题的。
一个不主动声明自己的修复,对已有机器来说不是真正的修复。我们现在加了一个检查大声说出这一点,还有一行命令可以修复它——不改内容,不产生提交:
git rm --cached -r -q . && git reset --hard
修好超时问题之后,我们写了一个守卫来防止它再次发生:读取 workflow,如果任何 job 的限制不大于其中工具的限制就失败。
它立刻就失败了。不是对 workflow 失败——而是对我们写在修复上方的那条注释失败,注释引用了旧值 --timeout=15m 来解释之前哪里出了问题。
那个守卫在扫描文本,完全不知道哪行是代码。两行代码修好了它,而这两行才是有趣的部分:
for line in workflow.splitlines():
if line.lstrip().startswith("#"):
continue # prose is not configuration
这是一个小 bug,但它有一个让人不安的寓意。一个读取文本的守卫迟早会读到错误的文本,而它的失败模式不是"它漏掉了东西"——而是"它报告了不存在的东西",这会训练所有人忽略它。然后它就开始漏东西了。
一条机器人消息:4 个新公司注册——购买意向很高。四个企业邮箱域名,值得今天就联系。
其中一个可证明是我们自己的笔记本:我们的编辑器插件在 10:47:10 UTC 启动,账户在 10:47 出现。另外三个完全无法归属。
两个原因,都是结构性的。匿名试用账户会得到一个在 <uuid>@trial.example.dev(我们的自有域名)上生成的地址,而判断"这是不是公司"的代码只知道一份免费邮箱提供商列表。任何不在列表里的都算企业。
而注册处理器根本没有记录来源。客户端好几周以来一直在发送真实的 User-Agent,服务器读取它来做滥用检测,然后把它扔掉了。
所以报告对它所统计的内容没有说错。它统计的东西永远无法回答它被问的那个问题。
这些都不是通过运行检查发现的。四个检查都是绿的。它们是被问了另一个不同的问题才发现的:
这个检查上一次说"不"是什么时候?
这个问题把"什么都没坏"和"我什么都看不见"区分开来,而仪表盘把这两个状态渲染得一模一样。对于新的守卫,回答这个问题很便宜——故意把被保护的东西弄坏,看它变红,再恢复:
# 1. 把守卫保护的东西弄坏
sed -i 's/EXPECTED/WRONG/' config.yaml
# 2. 守卫必须在这里失败
npm run guard && echo "this guard is decoration" && exit 1
# 3. 恢复
git checkout config.yaml
三十秒,一次。如果第 2 步打印出了那行字,你交付的不是守卫——只是一个背后什么都没有的绿灯。
对于已经存在的守卫,答案更难也更有意思:大多数仓库根本不知道答案。没有任何地方记录一个检查上一次拒绝东西是什么时候。一个绿了八个月的守卫要么在保护一个非常稳定的代码库,要么已经悄悄失明了,而你的工具链里没有任何东西能区分这两种情况。
那部分我们还没有解决。修复的形状很清晰——每个守卫一个时间戳,每次它拒绝东西时写入——但我们还没有做出来,我宁可如实说也不把它描述得像是已经做了一样。我们实际做的是开始手工询问这个问题,这就是四个问题在同一天全部浮出水面的方式。
这四个检查每一个都是由有能力的人写的,针对真实的风险,在合并当天是工作的。代码没有腐烂。是脚下的地基变了:运行器迁移了、一台机器比规则更老旧了、添加了一条注释、一个域名被生成了。
测试回答"代码做了正确的事吗?"。正常仓库里没有任何东西回答"检查还在做它该做的事吗?"——而第二个问题没有人负责、没有运行器、没有红灯。
读完这篇文章后如果只做一件事:找出你流水线里绿得最久的那个检查,然后试着让它失败。无论结果如何你都会学到东西,而且只需要大约一分钟。