Your CI is green and your pipeline produced nothing
Your CI is green and your pipeline produced nothing
你的 CI 显示绿色,但流水线却什么也没产出
Two failures broke an unattended pipeline I ran for a few months, and neither of them looked like an error. I want to describe them precisely, because I spent a long time debugging the wrong thing both times. 我运行了几个月的无人值守流水线出现了两次故障,但它们看起来都不像报错。我想精确地描述这些问题,因为我在这两次排查中都浪费了大量时间去调试错误的方向。
Failure one: exit 0, nothing on disk 故障一:退出码为 0,但磁盘上什么也没有
A publisher posted a blog entry successfully. It then verified the post by rebuilding the URL from parts — including a hardcoded month: const check = ${base}/2026/08/${slug}.html; // August, forever. On September 1st, every publish reported 404. Publishing worked perfectly. The check was broken. Because the check ran inside the same script and swallowed its own error, the job still exited 0. For a week the dashboard was green and the blog was empty.
一个发布程序成功发布了一篇博客文章。随后,它通过重新拼接 URL 来验证文章是否发布成功——其中包含了一个硬编码的月份:const check = ${base}/2026/08/${slug}.html; // August, forever。9 月 1 日,所有的发布都报了 404 错误。发布过程本身完全正常,但验证逻辑坏了。由于验证逻辑运行在同一个脚本中并吞掉了自己的错误,任务依然以 0 退出。整整一周,仪表盘显示一切正常(绿色),但博客却是空的。
Failure two: the work finished, then cleanup died 故障二:工作已完成,但清理阶段挂了
An image job produced 43 of 45 files, then threw EPERM deleting its own temp directory — a child process still held a handle on Windows. Exit code 1. The 43 finished images were discarded and a fallback path never ran, because everything downstream keyed off the exit code. Re-running cost forty minutes of compute to undo an rm -rf.
一个图像处理任务生成了 45 个文件中的 43 个,随后在删除临时目录时抛出了 EPERM 错误——因为在 Windows 上仍有子进程持有该目录的句柄。退出码为 1。那 43 个已完成的图像被丢弃,后续的兜底路径也从未执行,因为下游的所有逻辑都依赖于退出码。重新运行任务花费了 40 分钟的计算资源,仅仅是为了撤销一次 rm -rf。
The thing both have in common 这两者的共同点
An exit code has one bit of information, and the interesting question has two: 退出码只包含 1 位信息,但真正值得关注的问题包含 2 位:
| Artifacts missing | Artifacts present | |
|---|---|---|
| Exit 0 | Silent failure | Pass |
| Exit != 0 | Failure | False failure |
An exit code cannot tell the top-left cell from the top-right one. Both return 0. It cannot separate the bottom two either. It only knows the row, and the column is the half that decides whether you actually shipped. 退出码无法区分左上角和右上角的单元格,因为它们都返回 0。它也无法区分底部的两个单元格。它只知道“行”(退出状态),而“列”(产物状态)才是决定你是否真正完成交付的关键。
What I check now 我现在检查的内容
After the command finishes, before deciding anything: 在命令执行完毕后,在做出任何决定之前,我会检查:
- exists — the obvious one
- 存在性 — 最显而易见的检查。
- size — ffmpeg exits 0 having written a 0-byte mp4 more often than you’d think
- 大小 — 你可能想不到,ffmpeg 在写入 0 字节的 mp4 文件时,退出码竟然也是 0。
- count, recursively — dist/index.js plus dist/assets/*.css is four artifacts, not one. Counting only the top level made a normal bundler build look empty
- 递归计数 —
dist/index.js加上dist/assets/*.css是四个产物,而不是一个。只统计顶层目录会让正常的打包构建看起来像是空的。 - freshness — not older than N seconds
- 新鲜度 — 文件不能比 N 秒更旧。
- novelty — and this is the one that actually matters
- 新颖度 — 这是真正重要的一点。
Novelty is the check everyone skips. A file with the right name and the right size, written yesterday, passes every naive check ever written. A pipeline that has been dead for a week keeps reporting green. So snapshot mtimes before the command runs: “新颖度”是每个人都会忽略的检查。一个文件名正确、大小正确、但却是昨天生成的文件,可以通过任何简单的检查。一个已经死掉一周的流水线会持续报告绿色。因此,要在命令运行前记录文件的修改时间(mtime):
const before = stamp(dir); // newest mtime under dir
run(command);
const after = stamp(dir);
if (before !== null && after === before) {
// the files are there, but this run did not make them
}
One trap: a directory’s own mtime does not change when its files are overwritten in place. It changes on create, delete and rename only. If you stat the directory itself, every incremental rebuild that reuses filenames looks like it produced nothing. You have to walk the tree and take the newest mtime among the entries. I shipped that bug and it flagged healthy builds for a day before I understood why. 一个陷阱是:当目录内的文件被原地覆盖时,目录本身的 mtime 不会改变。它只在创建、删除和重命名时才会改变。如果你只检查目录本身,那么每一个复用文件名的增量构建看起来都像是没有产出。你必须遍历目录树,获取所有条目中最新的 mtime。我曾发布过这个 Bug,导致健康的构建被标记为失败,直到一天后我才明白原因。
Thresholds go stale, so learn them instead 阈值会过时,所以要让系统自动学习
You set minEntries: 30. The job grows to 200 over six months. The day it emits 40, nothing fires. Recording what each check produced on its healthy runs fixes this: [X] out/img <- 5 vs usual 30 (dropped below 50% of baseline).
你设置了 minEntries: 30。六个月后,任务产出增长到了 200 个。当某天它只产出 40 个时,没有任何报警触发。记录每次健康运行时的产出数据可以解决这个问题:[X] out/img <- 5 vs usual 30 (dropped below 50% of baseline)。
Nobody configured 30. Two rules keep it honest: 没人手动配置过 30。两条规则保证了其准确性:
- Only passing runs teach the baseline. Otherwise a broken run quietly lowers the bar and the next broken run looks normal. 只有成功的运行才能更新基准线。否则,一次失败的运行会悄悄降低标准,导致下一次失败的运行看起来很正常。
- Never record a measurement you did not finish. If the walk hit its budget and bailed, publishing the partial count as if it were the real one is exactly the lie the whole exercise exists to prevent. 永远不要记录未完成的测量结果。如果遍历过程达到了限制并中途退出,将部分统计结果当作完整结果发布,这正是我们试图通过这些检查来避免的谎言。
The one that cost me the most 让我损失最惨重的一次
I built a version that, when a tree was too large to walk, “withheld judgement” — which I implemented as ok = true. A build that produced literally nothing inside a large directory came back PASS, green dashboard, exit 0. Withholding a verdict is not the same as passing one. It now refuses the check outright and exits 2, and the reason travels all the way to the dashboard and the Slack message. If I can’t measure it, I don’t get to say it’s fine.
我曾构建过一个版本,当目录树太大无法遍历时,它会“保留判断”——我将其实现为 ok = true。结果,一个在大目录内实际上什么也没产出的构建返回了 PASS,仪表盘显示绿色,退出码为 0。保留判断并不等于通过。现在,它会直接拒绝检查并以退出码 2 退出,原因会一路传送到仪表盘和 Slack 消息中。如果我无法衡量它,我就不能说它没问题。
Should a false failure fail your build? “虚假失败”应该导致构建失败吗?
I made FALSE_FAILURE exit 0 on purpose. If every artifact is on disk and the process only died in cleanup, re-running a forty-minute render buys nothing. I’m genuinely unsure this is right, and it’s the decision I’d most like to hear arguments against. What do you do — trust the artifacts, or trust the exit code?
我特意让 FALSE_FAILURE 返回退出码 0。如果所有产物都在磁盘上,且进程只是在清理阶段挂掉的,那么重新运行一个耗时 40 分钟的渲染任务毫无意义。我不确定这样做是否正确,这也是我最想听取反对意见的决定。你会怎么做——相信产物,还是相信退出码?
I packaged this up as a small tool. The five failure patterns and a runnable two-second demo are free, no signup: Five ways your pipeline lies to you. The tool itself is $19 — but run the demo first, it reproduces every verdict on your machine and installs nothing. 我将这些逻辑封装成了一个小工具。五种故障模式和一个可运行的 2 秒演示是免费的,无需注册:Five ways your pipeline lies to you。该工具本身售价 19 美元——但请先运行演示,它会在你的机器上重现每种判定结果,且不会安装任何东西。