# 构建可复现比你想的难，而且它值得

日期：2025-11-20  
标签：构建、可复现、ci

> 同一份源码在两台机器上构建出不同的产物——这件事的代价不是洁癖，是你没法用 diff 判断"这次改动干了什么"。

来源：https://xiaoxusop.github.io/letterpress/reproducible-builds/
语言：zh-CN
类型：文章

## 被引用

- [构建探针](https://xiaoxusop.github.io/letterpress/wiki/build-probe/)

## 正文

「同一份源码应该构建出同一个产物」听起来像句废话。

但它在大多数项目里**并不成立**，而且失败方式全都极其安静：
构建照常成功，产物照常能用，只是**每次都不一样**。

代价在后面才显现：你没法用 diff 判断「这次改动到底改了什么」，
因为产物里全是无关噪声。

## 那些让构建不可复现的东西

按我实际踩到的顺序：

**一、时间戳。** 最容易想到，也最容易修。但它的变体很多：产物里嵌构建时间、
日志文件带时间、`Date.now()` 漏进某个 ID 的计算。

**二、文件系统的枚举顺序。** 目录里读文件不会保证顺序。你在本地按 A、B、C 遍历，
CI 上可能是 C、A、B——于是拼接出的产物顺序不同。

修法是**显式排序**：

```js
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 认为还没到点——而失败形态是「文章静默消失 →
引用它的 `[[链接]]` 变成断链」。

**根子不在比较逻辑写错了，在于「是否发布」依赖了构建时刻。**
只要依赖构建时刻，就必然破坏两件事：

1. **构建可复现**：9 月 11 日与 9 月 12 日构建出的产物不同
2. **跨机器一致**：作者本地能看到、CI 看不到，或反过来

我最后**把这个功能删掉了**。不是修不好，是代价算不过来：
`draft: true` 已经覆盖了「还没写完」这个主要场景，
而定时发布多出来的那点便利，换的是两个更重要的契约。

**做不到的事不该留着。**

## 怎么验

「可复现」是个可以被证明的性质，不用靠信念。

最直接的做法：**改变一个本不该影响内容的环境输入，再比哈希**。仓库已经把这条
做成命令：

```bash
npm run verify:reproducible
```

它会先扫描生产源码，禁止无参数 `new Date()` 与 `Date.now()` 直接读取构建时钟；
再清空 `.astro` 与 `dist`，分别在 UTC 与 America/Los_Angeles 跑完整的
Astro + Pagefind，逐文件比较 SHA-256。当前 93 个产物跨时区逐字节一致；
任一文件不同都会列出路径并以非零状态退出。

这里两层检查缺一不可：跨时区构建能抓出日期显示偏移，但抓不出一年才变化一次的
页脚年份；源码时钟检查能在跨年之前就拦住后者。内容里的日期则统一按 UTC 日历显示，
不会因为构建机位于负时区而退到前一天。

更有价值的一种是**换一种无关输入再比**。比如换行符：

```bash
# CRLF 工作区构建一次，LF 工作区构建一次，比对产物
```

我在这个站上做过这个实验：**CRLF 与 LF 两种工作区，产物 sha256 完全一致**
（87 个文件）。结论是换行符不影响产物——但这个结论**是测出来的，不是想出来的**。

## 为什么值得

有人会说这是洁癖。不是。

**可复现是「用 diff 做判断」的前提。** 如果每次构建产物都在抖，
那「这次提交改了什么」就只能靠读代码——而读代码是会漏的。

它也是**缓存正确性**的前提。构建产物不稳定，内容寻址的缓存就永远不命中，
或者更糟：命中了不该命中的。

而代价通常很小。上面那些来源，每一个的修法都是几行：
排序一下、固定一下、把时间从哈希输入里拿掉。

**花的这几行，换来的是「diff 可信」。** 这笔账很好算。

相关条目：[构建探针](https://xiaoxusop.github.io/letterpress/wiki/build-probe/)。

## 参考

- reproducible-builds.org（这个概念的系统性整理）
- 本站的实测：`npm run verify:reproducible` 会在两个时区完整构建并逐文件比对，
  同时禁止构建时钟泄漏；`.gitattributes` 统一换行符，内容层的排序全部显式化
