The Build That Always Compiles the Same: Tracing Nondeterminism to Its Sources
Photo: Codex, CC BY-SA 4.0, via Wikimedia Commons
"Works on my machine" is a statement about a laptop, not about a build. It cannot be tested, handed to a new hire, or attached to an incident review. Reproducibility converts it into a checkable claim: the same source, the same toolchain, and the same declared environment produce a byte-identical artifact. The definition is deliberately narrow. It says nothing about whether the software is correct, only that the route from source to artifact has no hidden inputs.
That narrowness is what makes the property useful. A build that agrees with itself across machines is a debugging instrument and a supply-chain control well before it is a compliance line item.
Where the variation enters
Most teams discover nondeterminism only when a cache misses or a container is rebuilt on a different host. The causes are ordinary, and they cluster into five groups.
Directory order
APIs such as readdir do not promise an order, and neither do shell globs once a directory grows. A bundler that concatenates files in discovery order will produce different bytes on different filesystems. The fix is boring: sort every list of input paths explicitly before it reaches the compiler.
Clocks
Build scripts love to stamp the current time into a banner, a manifest, or an archive entry. Zip and tar both record modification times by default. The reproducible-builds convention replaces the wall clock with a declared value: set SOURCE_DATE_EPOCH, and every tool that honors it will use that number instead of time(). For tools that do not honor it, pass a fixed timestamp yourself.
Paths
Debug information, source maps, and generated headers often embed absolute paths such as /home/ana/ci/workspace/app. Build the same commit in a different directory and the bytes change. Compilers offer path trimming for exactly this reason; bundlers offer a source root. Use them, and keep generated files free of the checkout location.
Floating dependencies
A range like ^1.4.2 is a promise to accept whatever the registry serves next Tuesday. If the lockfile is missing, ignored, or regenerated during the build, the build is a function of the registry's current state. Pin the toolchain as well: a Node major upgrade, a different npm version, or a compiler patch release can all change output.
Ambient environment
Locale, timezone, umask, parallelism, and the order in which concurrent tasks finish can all leak into artifacts. Some of these matter only when a tool sorts strings with locale-aware comparison or writes files with inherited permissions.
A small project, fixed line by line
Consider a Node command-line tool bundled with esbuild and shipped as a tarball. The original pipeline is three lines in package.json and produces a different checksum on every run.
First, pin the inputs. Commit the lockfile, install with npm ci instead of npm install, and state the toolchain in the manifest so a reader can see it without opening a CI file.
{
"name": "report-cli",
"version": "2.3.0",
"packageManager": "[email protected]",
"engines": { "node": ">=22.11.0 <23" },
"scripts": { "build": "node scripts/build.mjs" },
"dependencies": { "esbuild": "0.24.0" }
}
Second, remove the clock. The build script reads SOURCE_DATE_EPOCH, falls back to a fixed date, and passes the value to the bundler rather than calling Date.now().
import { build } from 'esbuild';
const epoch = process.env.SOURCE_DATE_EPOCH ?? '1767225600';
await build({
entryPoints: ['src/cli.ts'],
bundle: true,
platform: 'node',
outfile: 'dist/cli.js',
sourcemap: false,
define: { __BUILD_EPOCH__: JSON.stringify(epoch) },
banner: { js: `// report-cli 2.3.0 (epoch ${epoch})` },
logLevel: 'warning'
});
Third, normalize the archive. GNU tar can sort entries, set ownership to zero, and stamp a single modification time; gzip's -n flag stops it from recording the original file mtime and name.
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf - dist | gzip -n -9 > report-cli.tgz
Fourth, audit the source for stray nondeterminism: generated UUIDs, Object.keys ordering that reaches disk, test fixtures written with timestamps, and any step that fetches from the network during the build.
The four changes are not clever. They are the difference between a build that happens to agree with itself and one that is required to.
Making the claim checkable in CI
A claim nobody verifies decays. Add a job that builds twice under deliberately different conditions and compares the results. Different working directories, timezones, and umasks catch a surprising share of leaks.
- name: Reproducible build check
run: |
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
npm ci
TZ=UTC umask 022 npm run build
cp report-cli.tgz /tmp/first.tgz
rm -rf dist node_modules
npm ci
TZ=America/Los_Angeles umask 077 npm run build
cmp /tmp/first.tgz report-cli.tgz
sha256sum /tmp/first.tgz report-cli.tgz
For a stronger claim, run the second build inside the same pinned container image by digest and delete local caches between runs. Record the expected hash alongside the release so downstream consumers can compare an independent rebuild.
What the property buys
Reproducibility pays for itself in three places. During debugging, it separates source changes from environment changes; if a rebuild of the previous commit no longer matches its stored hash, the problem is in the pipeline, not the patch. During incident response, it lets an engineer rebuild the exact artifact from the exact tag and inspect it. Across the supply chain, it lets someone outside your organization verify that the published tarball corresponds to the public source, which is the only meaningful defense against a compromised release step.
Content-addressed caches and remote build caches become trustworthy only when the key is the content, not the machine. A cache that returns different bytes for the same key is worse than no cache at all.
None of this requires a large program. It requires deciding that the build has a contract, writing that contract down in the form of pinned versions and declared epochs, and then testing it in CI. "Works on my machine" becomes "works the same everywhere, and here is the hash to prove it."
That is a claim a team can act on.