<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>可复现 · 此间札记</title><description>带有「可复现」标签的文章订阅。</description><link>https://xiaoxusop.github.io/letterpress/</link><language>zh-CN</language><copyright>© XIAOXUsop</copyright><atom:link href="https://xiaoxusop.github.io/letterpress/tags/%E5%8F%AF%E5%A4%8D%E7%8E%B0/rss.xml" rel="self" type="application/rss+xml"/><item><title>构建可复现比你想的难，而且它值得</title><link>https://xiaoxusop.github.io/letterpress/reproducible-builds/</link><guid isPermaLink="true">https://xiaoxusop.github.io/letterpress/reproducible-builds/</guid><description>同一份源码在两台机器上构建出不同的产物——这件事的代价不是洁癖，是你没法用 diff 判断&quot;这次改动干了什么&quot;。</description><pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;「同一份源码应该构建出同一个产物」听起来像句废话。&lt;/p&gt;
&lt;p&gt;但它在大多数项目里&lt;strong&gt;并不成立&lt;/strong&gt;，而且失败方式全都极其安静：
构建照常成功，产物照常能用，只是&lt;strong&gt;每次都不一样&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;代价在后面才显现：你没法用 diff 判断「这次改动到底改了什么」，
因为产物里全是无关噪声。&lt;/p&gt;
&lt;h2 id=&quot;那些让构建不可复现的东西&quot;&gt;那些让构建不可复现的东西&lt;a class=&quot;heading-anchor&quot; href=&quot;#那些让构建不可复现的东西&quot; aria-label=&quot;此小节的永久链接&quot; data-pagefind-ignore=&quot;&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;按我实际踩到的顺序：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一、时间戳。&lt;/strong&gt; 最容易想到，也最容易修。但它的变体很多：产物里嵌构建时间、
日志文件带时间、&lt;code&gt;Date.now()&lt;/code&gt; 漏进某个 ID 的计算。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二、文件系统的枚举顺序。&lt;/strong&gt; 目录里读文件不会保证顺序。你在本地按 A、B、C 遍历，
CI 上可能是 C、A、B——于是拼接出的产物顺序不同。&lt;/p&gt;
&lt;p&gt;修法是&lt;strong&gt;显式排序&lt;/strong&gt;：&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-dimmed&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#22272e;color:#24292e;--shiki-dark:#adbac7; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F47067&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#6CB6FF&quot;&gt; files&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F47067&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F47067&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#DCBDFB&quot;&gt; readdir&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#ADBAC7&quot;&gt;(dir);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#ADBAC7&quot;&gt;files.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#DCBDFB&quot;&gt;sort&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#ADBAC7&quot;&gt;();   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#768390&quot;&gt;// 这一行不是可选的&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三、哈希的输入里混进了路径。&lt;/strong&gt; &lt;code&gt;哈希(绝对路径 + 内容)&lt;/code&gt; 会让产物随 checkout 目录变化。
看起来低级，但在「把目录结构也算进去」时很容易顺手写出来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;四、依赖的解析顺序。&lt;/strong&gt; 锁文件解决了一部分，但 &lt;code&gt;^1.2.3&lt;/code&gt; 这类范围在锁文件被删后
仍会解析到不同版本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;五、当前时区。&lt;/strong&gt; 这条最阴——见下文。&lt;/p&gt;
&lt;h2 id=&quot;时区那条一次真实的-ci-失败&quot;&gt;时区那条：一次真实的 CI 失败&lt;a class=&quot;heading-anchor&quot; href=&quot;#时区那条一次真实的-ci-失败&quot; aria-label=&quot;此小节的永久链接&quot; data-pagefind-ignore=&quot;&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;我给站点加了「定时发布」：frontmatter 里写 &lt;code&gt;date&lt;/code&gt;，到点之前不发布。&lt;/p&gt;
&lt;p&gt;实现很简单：比较文章日期与当前时刻。&lt;/p&gt;
&lt;p&gt;本地全绿。推上去，&lt;strong&gt;CI 红了&lt;/strong&gt;。&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-dimmed&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#22272e;color:#24292e;--shiki-dark:#adbac7; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;UTC+8 的机器：此刻是 9 月 12 日 01:57&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;CI（UTC）：  此刻是 9 月 11 日 17:57&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;new Date(&apos;2026-09-12&apos;)&lt;/code&gt; 在两种情况下都解析成 &lt;strong&gt;UTC 午夜&lt;/strong&gt;，
但「今天」取决于机器时区。于是&lt;strong&gt;同一篇文章在两地判定相反&lt;/strong&gt;：
本地认为该发布，CI 认为还没到点——而失败形态是「文章静默消失 →
引用它的 &lt;code&gt;[[链接]]&lt;/code&gt; 变成断链」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;根子不在比较逻辑写错了，在于「是否发布」依赖了构建时刻。&lt;/strong&gt;
只要依赖构建时刻，就必然破坏两件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;构建可复现&lt;/strong&gt;：9 月 11 日与 9 月 12 日构建出的产物不同&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨机器一致&lt;/strong&gt;：作者本地能看到、CI 看不到，或反过来&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我最后&lt;strong&gt;把这个功能删掉了&lt;/strong&gt;。不是修不好，是代价算不过来：
&lt;code&gt;draft: true&lt;/code&gt; 已经覆盖了「还没写完」这个主要场景，
而定时发布多出来的那点便利，换的是两个更重要的契约。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;做不到的事不该留着。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;怎么验&quot;&gt;怎么验&lt;a class=&quot;heading-anchor&quot; href=&quot;#怎么验&quot; aria-label=&quot;此小节的永久链接&quot; data-pagefind-ignore=&quot;&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;「可复现」是个可以被证明的性质，不用靠信念。&lt;/p&gt;
&lt;p&gt;最直接的做法：&lt;strong&gt;改变一个本不该影响内容的环境输入，再比哈希&lt;/strong&gt;。仓库已经把这条
做成命令：&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-dimmed&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#22272e;color:#24292e;--shiki-dark:#adbac7; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#F69D50&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#96D0FF&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#96D0FF&quot;&gt; verify:reproducible&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会先扫描生产源码，禁止无参数 &lt;code&gt;new Date()&lt;/code&gt; 与 &lt;code&gt;Date.now()&lt;/code&gt; 直接读取构建时钟；
再清空 &lt;code&gt;.astro&lt;/code&gt; 与 &lt;code&gt;dist&lt;/code&gt;，分别在 UTC 与 America/Los_Angeles 跑完整的
Astro + Pagefind，逐文件比较 SHA-256。当前 93 个产物跨时区逐字节一致；
任一文件不同都会列出路径并以非零状态退出。&lt;/p&gt;
&lt;p&gt;这里两层检查缺一不可：跨时区构建能抓出日期显示偏移，但抓不出一年才变化一次的
页脚年份；源码时钟检查能在跨年之前就拦住后者。内容里的日期则统一按 UTC 日历显示，
不会因为构建机位于负时区而退到前一天。&lt;/p&gt;
&lt;p&gt;更有价值的一种是&lt;strong&gt;换一种无关输入再比&lt;/strong&gt;。比如换行符：&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-dimmed&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#22272e;color:#24292e;--shiki-dark:#adbac7; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#768390&quot;&gt;# CRLF 工作区构建一次，LF 工作区构建一次，比对产物&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我在这个站上做过这个实验：&lt;strong&gt;CRLF 与 LF 两种工作区，产物 sha256 完全一致&lt;/strong&gt;
（87 个文件）。结论是换行符不影响产物——但这个结论&lt;strong&gt;是测出来的，不是想出来的&lt;/strong&gt;。&lt;/p&gt;
&lt;h2 id=&quot;为什么值得&quot;&gt;为什么值得&lt;a class=&quot;heading-anchor&quot; href=&quot;#为什么值得&quot; aria-label=&quot;此小节的永久链接&quot; data-pagefind-ignore=&quot;&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;有人会说这是洁癖。不是。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;可复现是「用 diff 做判断」的前提。&lt;/strong&gt; 如果每次构建产物都在抖，
那「这次提交改了什么」就只能靠读代码——而读代码是会漏的。&lt;/p&gt;
&lt;p&gt;它也是&lt;strong&gt;缓存正确性&lt;/strong&gt;的前提。构建产物不稳定，内容寻址的缓存就永远不命中，
或者更糟：命中了不该命中的。&lt;/p&gt;
&lt;p&gt;而代价通常很小。上面那些来源，每一个的修法都是几行：
排序一下、固定一下、把时间从哈希输入里拿掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;花的这几行，换来的是「diff 可信」。&lt;/strong&gt; 这笔账很好算。&lt;/p&gt;
&lt;p&gt;相关条目：&lt;a href=&quot;/letterpress/wiki/build-probe/&quot;&gt;构建探针&lt;/a&gt;。&lt;/p&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;a class=&quot;heading-anchor&quot; href=&quot;#参考&quot; aria-label=&quot;此小节的永久链接&quot; data-pagefind-ignore=&quot;&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;reproducible-builds.org（这个概念的系统性整理）&lt;/li&gt;
&lt;li&gt;本站的实测：&lt;code&gt;npm run verify:reproducible&lt;/code&gt; 会在两个时区完整构建并逐文件比对，
同时禁止构建时钟泄漏；&lt;code&gt;.gitattributes&lt;/code&gt; 统一换行符，内容层的排序全部显式化&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>构建</category><category>可复现</category><category>ci</category><author>XIAOXUsop</author></item></channel></rss>