How to check if a build is reproducible
Build twice into separate directories, hash every file, and compare the two manifests. A reproducible build gives identical hashes. Here the second run changed both files, and diff on the artefact named the cause on one line: a build timestamp written into the output.
Why check this
A reproducible build is what lets you prove that the archive on a server came from the commit that was reviewed. Without it, a hash mismatch between a CI artefact and a local rebuild has two explanations, a tampered file and a clock, and nothing on hand tells them apart.
Run this before a release artefact is signed, and again whenever a build tool is upgraded. The failure it prevents is an investigation that cannot conclude: a customer reports a checksum that does not match the published one, and the team has no way to show the difference is a timestamp rather than a change in the code.
Prerequisites
- Node.js and npm on PATH, plus
diffandsha256sum. Git Bash carries both. - Two directories to build in, because a second run in the same directory misses the whole class of path-dependent differences. Step 5 shows one.
- The demo below is a build that bakes in both a timestamp and an absolute path, so there is something to find.
package.jsonrunsnode build.mjs:
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
const stamp = process.env.SOURCE_DATE_EPOCH
? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1000).toISOString()
: new Date().toISOString();
rmSync('dist', { recursive: true, force: true });
mkdirSync('dist');
writeFileSync(
'dist/app.js',
'// built ' + stamp + ' from ' + resolve('src/index.js') + '\n' + readFileSync('src/index.js', 'utf8')
);
writeFileSync('dist/meta.json', JSON.stringify({ name: 'buildrepro', builtAt: stamp }, null, 2) + '\n');
manifest.mjshashes an output directory, one line per file, sorted so two runs compare cleanly:
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync } from 'node:fs';
const walk = (dir) =>
readdirSync(dir).flatMap((f) => {
const p = dir + '/' + f;
return statSync(p).isDirectory() ? walk(p) : [p];
});
for (const file of walk(process.argv[2] ?? 'dist').sort()) {
console.log(createHash('sha256').update(readFileSync(file)).digest('hex') + ' ' + file);
}
- Reproducible Builds defines
SOURCE_DATE_EPOCH, the convention step 4 uses.
Steps
- Step 1.
Build once, keep the output, and hash it.
npm run build && cp -r dist snap-1 && node manifest.mjs snap-1> buildrepro@1.0.0 build > node build.mjs bf484d34e6eef14b771998833995c648bd5a2eb23683a031a5e2555f79f492a6 snap-1/app.js 214190be1893edd5cdeb7a148af2fe7ad2db55c214069d41243eab96f142e628 snap-1/meta.jsonCopy the output aside before the next build. The build script deletes
distwhen it starts. - Step 2.
Build again from the same source, with nothing changed between the two runs.
npm run build && cp -r dist snap-2 && node manifest.mjs snap-2> buildrepro@1.0.0 build > node build.mjs 80b5a0cc34af85ef2769b1af70ce2deb15877335fc4d8a61bc6bbf677b40448a snap-2/app.js 81e2cc2d257f66d8e1f78943c0f886fc524b04878e65fe6a22a0d49513a87022 snap-2/meta.jsonBoth hashes changed. At this point the build is not reproducible, and the manifest does not say why.
- Step 3.
Diff the artefact itself. This is the step that turns a hash mismatch into a cause.
diff -u snap-1/app.js snap-2/app.js--- snap-1/app.js 2026-09-12 10:30:53.208487300 +0300 +++ snap-2/app.js 2026-09-12 10:30:53.824983400 +0300 @@ -1,2 +1,2 @@ -// built 2026-09-12T07:30:53.145Z from C:\Users\khark\…\scratchpad\buildrepro\src\index.js +// built 2026-09-12T07:30:53.776Z from C:\Users\khark\…\scratchpad\buildrepro\src\index.js export const greet = (n) => 'hello ' + n;Two causes on one line. The clock differs between runs, and the absolute path will differ between machines even though it is stable here.
- Step 4.
Pin the clock with
SOURCE_DATE_EPOCHand repeat the pair.SOURCE_DATE_EPOCH=1700000000 npm run build --silent && node manifest.mjs dist > a.txt && SOURCE_DATE_EPOCH=1700000000 npm run build --silent && node manifest.mjs dist > b.txt && diff a.txt b.txt && cat a.txt && echo "identical"052e5e03eedf3c1c3c8382498f58d5c57477adc0c0503cd1f3dad08ac4aa53d0 dist/app.js 3958c060351a749ca867a7b86117687300d6a074fc31bae7a522ff5c577108e8 dist/meta.json identicaldiffprinted nothing and the chain reachedecho. One variable removed the timestamp difference from both files. - Step 5.
Build the same source from a second checkout, with the same pinned epoch, and compare against the manifest from step 4.
SOURCE_DATE_EPOCH=1700000000 npm run build --silent && node manifest.mjs dist > c.txt && diff ../buildrepro/a.txt c.txt1c1 < 052e5e03eedf3c1c3c8382498f58d5c57477adc0c0503cd1f3dad08ac4aa53d0 dist/app.js --- > 8cf39ace2d4ddd376b27723ba63e5046539fc21a491279b0c68392851e65d18f dist/app.jsmeta.jsonmatched andapp.jsdid not. The only difference between the two checkouts is the directory they sit in, and one file carries that directory inside it. - Step 6.
Compare against a tool that already normalises its output, so you can tell a broken build from a broken method.
npm pack --silent --pack-destination ../pack-c && npm pack --silent --pack-destination ../pack-d && sha256sum ../pack-c/*.tgz ../pack-d/*.tgzbuildrepro-1.0.0.tgz buildrepro-1.0.0.tgz 93eefb9ab94a951c8c8008ff29bbb466f674309524079fc9c2fbea7f1b4b8da8 *../pack-c/buildrepro-1.0.0.tgz 93eefb9ab94a951c8c8008ff29bbb466f674309524079fc9c2fbea7f1b4b8da8 *../pack-d/buildrepro-1.0.0.tgzThe same bytes twice, from a tar and gzip stream that both have room for a modification time. Whatever npm writes into those headers does not come from the clock, which is the property the build script above lacks.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | Identical manifests from two runs in one directory | The build has no per-run randomness | Repeat from a second checkout before calling it reproducible. | | One file differs, the rest match | A single source of variance | Diff that file. The cause is usually on the first or last line. | | Every file differs | A value that reaches every artefact | Look for a timestamp, a build id or a hash of the output directory. | | Differs only across checkouts | A path is baked into the output | Write relative paths, or map the root through a compiler flag. | | Hashes match, sizes differ | The manifest is reading the wrong directory | Confirm the path argument. Nothing else produces that pair. |
Common mistakes
What to check next
- How to check if package lock is in sync: a build cannot reproduce while the dependency set moves under it.
- How to check environment parity: the runtime and package versions the two builds ran with.
- How to check node version of a project: a different Node major changes emitted output for some toolchains.
- How to check if a npm package is installed globally: a build step resolved from a machine rather than the lockfile is not reproducible anywhere else.
- Pull request checklist: where an artefact comparison fits before a release.
FAQ
How do I check if a build is reproducible without special tooling?
Build twice, hash the output tree both times, and compare the two lists. That is steps 1 and 2, and it needs nothing beyond the shell. The comparison only becomes a proof once the second build runs from a different directory.
What usually breaks reproducibility?
A timestamp, an absolute path, a random build id, and file ordering from a directory listing. The first two appeared on a single line here. Ordering shows up as identical file contents in a different sequence inside an archive.
Does SOURCE_DATE_EPOCH make any build reproducible?
No. It is a value the build has to read. Step 4 works because the demo build script checks the variable. A toolchain that calls the system clock directly ignores it, and the manifests keep changing.
Should the lockfile be part of this check?
Yes. Reinstall with npm ci before each build so the dependency set is fixed. A npm install between the two runs can change a transitive version and produce a difference that has nothing to do with the build script.
Why compare against npm pack?
It is a local build step that already normalises timestamps, so two runs agree byte for byte. When your own build disagrees and npm pack agrees on the same machine, the variance is in the build script rather than in the filesystem or the shell.
Verified
Verified by Maks Vernynode 22.23.2npm 10.9.8coreutils sha256sum 8.32
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny