连续失败 255 次的 CI 暴露了什么
一个检测器评估任务从首次运行起便持续失败,历经 255 次提交仍被团队忽略。节选展示的直接原因是脚本依赖本机存在、CI 环境缺失的评估语料文件。
完整中文译文
我的 release 分支上有一个 CI 任务,每次 push 都会失败。包括它第一次运行的时候。整整持续了 255 次提交。
没人提 issue,没人用二分法排查。我在它依然报红的情况下,把分支合进了 main,因为到了那时,“detector-eval 是红的”已经像天气一样——它就是这样,一直都是这样,不需要解释。
它从来没有绿过。一次都没有。而我竟然不知道,因为我从没想过要问。
这是 agentic-suite 中 Agent 安全工具 agentwatch 发布 v0.2.1 时发生的事。证据都在 v0.2.0 实地测试报告和发布就绪审查记录里。
原封不动的报错
这个 workflow 很小,是在 detector-credibility 里程碑中加入的:让异常检测器在 CI 中处理一套纳入版本管理的语料,以此建立对检测器的信任:
- name: Detector eval corpus v0 (DET-1)
run: python scripts/detector_eval.py --json
每次向分支 push,以及每个 PR,都会运行它。每次运行都这样收场:
FileNotFoundError: [Errno 2] No such file or directory:
'.../services/analytics/data/detector-corpus-v0.json'
Process completed with exit code 1.
脚本里写死了默认语料路径:services/analytics/data/detector-corpus-v0.json。这个文件在我的机器上存在,但在全新 clone 的仓库里不存在。
一行 gitignore
根目录的 .gitignore 里有这样一条规则,添加时附带的注释是 # Local dev data:
data/
临时文件、本地运行数据、用完就扔的输出——这条规则很合理。它对 services/analytics/data/ 一视同仁,就像对其他所有匹配目录一样,丝毫不在乎里面是什么。结果,这套作为 CI 门禁输入的语料,被一条本来用于排除杂物的规则忽略了。
最扎心的是:生成器一直就在仓库里。scripts/generate_detector_corpus.py,与 workflow 文件所在目录只隔着一层目录。这套语料是可以构建出来的产物,并不需要手工维护。明明有两种修复方式——在 workflow 中生成它,或者把它提交到仓库——我却选了第三种,也是我在 255 次提交里实际采用的方式:什么都不做,因为我从没把它归类为一个问题。
下面这个特点,让问题能够存活这么久。我想把它明确说出来,因为我认为这才是具有普遍性的失效模式:
这个 workflow 从第一次运行开始就是红的。
不是某次回归之后才变红,而是从它存在的第一笔提交开始,每次 push 都红,一直如此。这意味着没人见过它变绿,也就意味着没人见过它因为某个有意义的原因而变红。没有变化,没有前后对照。这个检查没有权威,因为检查的权威完全来自一句话:“它之前是绿的,现在不是了。”
我回头数了一下。合并时,这个分支上有 255 次提交,而这个 workflow 几乎贯穿了全部提交:
$ git rev-list --count 31100ba..a673eef
255
在这段时间里的每次 push——整个实地测试、检测器加固、发布准备阶段——都带着那个红色叉号交付了。
而且它并不孤单。security.yml 也在失败,耗时 0 秒,GitHub 只给出一句简短的提示:“This run likely failed because of a workflow file issue.” 每次 push 都有两个红色检查,两者都失去了意义,也都被忽略。我反复琢磨的是这一点:在它们都报红的情况下,任何一个检查所覆盖的功能真的发生回归,也不会被察觉。这些噪声消耗的不只是注意力,还吞掉了这些检查原本应该传递的信号。
这样的噪声,究竟让人养成了什么习惯
我做发布就绪审查时,只定了一条简单规则:列出分支上每一个报红的检查,要求逐一解释。问的不是“它是不是红的”,而是“它为什么红,这个原因能不能接受”。
没有回归,两个检查本身都坏了。这就是 CI 噪声悄无声息的危险:连续几周,我都把它们理解成“偶发失败,应该没事”,可事实却是“从结构上就不可能通过”。
提示:“它一直都是红的”,是 CI 中代价最高的一句话。这意味着从来没人见过这个检查正常履职,因此也没人能判断它什么时候失去了作用。
在 .gitignore 中加两行,再做一次提交:
!services/analytics/data/
!services/analytics/data/*.json
把语料加入白名单,然后提交它们——detector-corpus-v0.json,以及旁边的 LLM 语料。它们都是按照预定数据结构人工构造的合成测试样本;和其他所有文件一样,也会接受敏感信息扫描;没有理由不随仓库一起交付。另一种方式——在 workflow 中调用生成器——也完全可以。关键在于,让输入成为可审查、受版本管理的状态,而不是散落在本地环境里的文件。
这是修复后 main 上的运行记录:
completed success detector-eval main 52s
completed success detector-eval main 49s
completed success detector-eval main 51s
completed success detector-eval main 48s
绿了。结果稳定可复现。而现在,它终于第一次能够因为真正的问题而变红了——这正是这个检查的全部价值。
我从这件事里记住了什么
一个从来没绿过的检查,算不上检查。它只是一个装饰性的叉号。它无法捕捉回归,因为它分不清回归和背景噪声。
每个新 workflow,都要在获得信任之前,至少被亲眼确认成功运行一次。要有一次确实见证过的绿色基线。如果它一上来就无法变绿,那就是这个检查自身的 P0 bug——它会污染周围所有检查的信号。
在发布就绪审查时,列出所有报红项,并要求给出原因。目的不是把它们全部修好——现在,security.yml 确实仍带着问题保留下来,但已有负责人,也明确了原因——而是把它们分类。当我要求把原因写下来时,“一直都这样”就从一个借口,变成了一项审查发现。
写下这些文字时,main 上的 security.yml 问题仍未解决。依然是 0s,依然失败。但它不再像天气一样被视为理所当然了——审查日志里记录了它,有行号,也有负责人的名字。这种区别,就是整件事的意义所在。
这些改动都已随 agentwatch v0.2.1 发布——可以在 PyPI 上获取。
今晚,你值得对自己的仓库问一句:哪个检查连续报红的时间最长?有没有人知道它为什么红,还是只知道它是红的?运行 gh run list,大约一分钟就能找到答案。这一分钟,会是你这周成本最低的一次审计。
发布版本:GitHub 上的 agentwatch v0.2.1 · PyPI 上的 agentwatch v0.2.1
v0.2.0 实地测试报告
workflow:detector-eval.yml · 脚本:detector_eval.py · 那个一直都在的生成器:generate_detector_corpus.py
修复:在 .gitignore 中为语料添加白名单——语料现在随仓库一起放在 services/analytics/data/ 中交付
工具套件:agentic-suite——完整的 Agent 安全工具集
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。