构建可复现比你想的难,而且它值得
「同一份源码应该构建出同一个产物」听起来像句废话。
但它在大多数项目里并不成立,而且失败方式全都极其安静: 构建照常成功,产物照常能用,只是每次都不一样。
代价在后面才显现:你没法用 diff 判断「这次改动到底改了什么」, 因为产物里全是无关噪声。
那些让构建不可复现的东西#
按我实际踩到的顺序:
一、时间戳。 最容易想到,也最容易修。但它的变体很多:产物里嵌构建时间、
日志文件带时间、Date.now() 漏进某个 ID 的计算。
二、文件系统的枚举顺序。 目录里读文件不会保证顺序。你在本地按 A、B、C 遍历, CI 上可能是 C、A、B——于是拼接出的产物顺序不同。
修法是显式排序:
const files = await readdir(dir);
files.sort(); // 这一行不是可选的
三、哈希的输入里混进了路径。 哈希(绝对路径 + 内容) 会让产物随 checkout 目录变化。
看起来低级,但在「把目录结构也算进去」时很容易顺手写出来。
四、依赖的解析顺序。 锁文件解决了一部分,但 ^1.2.3 这类范围在锁文件被删后
仍会解析到不同版本。
五、当前时区。 这条最阴——见下文。
时区那条:一次真实的 CI 失败#
我给站点加了「定时发布」:frontmatter 里写 date,到点之前不发布。
实现很简单:比较文章日期与当前时刻。
本地全绿。推上去,CI 红了。
UTC+8 的机器:此刻是 9 月 12 日 01:57
CI(UTC): 此刻是 9 月 11 日 17:57
new Date('2026-09-12') 在两种情况下都解析成 UTC 午夜,
但「今天」取决于机器时区。于是同一篇文章在两地判定相反:
本地认为该发布,CI 认为还没到点——而失败形态是「文章静默消失 →
引用它的 [[链接]] 变成断链」。
根子不在比较逻辑写错了,在于「是否发布」依赖了构建时刻。 只要依赖构建时刻,就必然破坏两件事:
- 构建可复现:9 月 11 日与 9 月 12 日构建出的产物不同
- 跨机器一致:作者本地能看到、CI 看不到,或反过来
我最后把这个功能删掉了。不是修不好,是代价算不过来:
draft: true 已经覆盖了「还没写完」这个主要场景,
而定时发布多出来的那点便利,换的是两个更重要的契约。
做不到的事不该留着。
怎么验#
「可复现」是个可以被证明的性质,不用靠信念。
最直接的做法:改变一个本不该影响内容的环境输入,再比哈希。仓库已经把这条 做成命令:
npm run verify:reproducible
它会先扫描生产源码,禁止无参数 new Date() 与 Date.now() 直接读取构建时钟;
再清空 .astro 与 dist,分别在 UTC 与 America/Los_Angeles 跑完整的
Astro + Pagefind,逐文件比较 SHA-256。当前 93 个产物跨时区逐字节一致;
任一文件不同都会列出路径并以非零状态退出。
这里两层检查缺一不可:跨时区构建能抓出日期显示偏移,但抓不出一年才变化一次的 页脚年份;源码时钟检查能在跨年之前就拦住后者。内容里的日期则统一按 UTC 日历显示, 不会因为构建机位于负时区而退到前一天。
更有价值的一种是换一种无关输入再比。比如换行符:
# CRLF 工作区构建一次,LF 工作区构建一次,比对产物
我在这个站上做过这个实验:CRLF 与 LF 两种工作区,产物 sha256 完全一致 (87 个文件)。结论是换行符不影响产物——但这个结论是测出来的,不是想出来的。
为什么值得#
有人会说这是洁癖。不是。
可复现是「用 diff 做判断」的前提。 如果每次构建产物都在抖, 那「这次提交改了什么」就只能靠读代码——而读代码是会漏的。
它也是缓存正确性的前提。构建产物不稳定,内容寻址的缓存就永远不命中, 或者更糟:命中了不该命中的。
而代价通常很小。上面那些来源,每一个的修法都是几行: 排序一下、固定一下、把时间从哈希输入里拿掉。
花的这几行,换来的是「diff 可信」。 这笔账很好算。
相关条目:构建探针。
参考#
- reproducible-builds.org(这个概念的系统性整理)
- 本站的实测:
npm run verify:reproducible会在两个时区完整构建并逐文件比对, 同时禁止构建时钟泄漏;.gitattributes统一换行符,内容层的排序全部显式化
被这些页面引用(1)
这一页也有 markdown 版本:/letterpress/reproducible-builds.md
直接打开上面的 .md 链接即可读取;在支持内容协商的部署平台上, 也可以对页面 URL 发送 Accept: text/markdown。