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

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');
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);
}

Steps

  1. 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.json

    Copy the output aside before the next build. The build script deletes dist when it starts.

  2. 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.json

    Both hashes changed. At this point the build is not reproducible, and the manifest does not say why.

  3. 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.

  4. Step 4.

    Pin the clock with SOURCE_DATE_EPOCH and 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
    identical

    diff printed nothing and the chain reached echo. One variable removed the timestamp difference from both files.

  5. 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.txt
    
    1c1
    < 052e5e03eedf3c1c3c8382498f58d5c57477adc0c0503cd1f3dad08ac4aa53d0  dist/app.js
    ---
    > 8cf39ace2d4ddd376b27723ba63e5046539fc21a491279b0c68392851e65d18f  dist/app.js

    meta.json matched and app.js did not. The only difference between the two checkouts is the directory they sit in, and one file carries that directory inside it.

  6. 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/*.tgz
    
    buildrepro-1.0.0.tgz
    buildrepro-1.0.0.tgz
    93eefb9ab94a951c8c8008ff29bbb466f674309524079fc9c2fbea7f1b4b8da8 *../pack-c/buildrepro-1.0.0.tgz
    93eefb9ab94a951c8c8008ff29bbb466f674309524079fc9c2fbea7f1b4b8da8 *../pack-d/buildrepro-1.0.0.tgz

    The 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

Sign: Two builds in the same working directory match, and the artefact still fails to reproduce on a colleague's machine.Cause: A second run in the same directory holds the checkout path constant, so a baked-in absolute path never varies. Here the manifests agreed under a pinned epoch and disagreed the moment the same source was built one directory over. Reproducibility is a claim about two checkouts.
Sign: The diff of the artefact is unreadable, so the comparison is abandoned.Cause: Comparing a minified bundle or a tarball line by line produces noise. Compare per file first with a hash manifest, which names the file, then diff only that file. A single changed line is readable; a whole bundle is not.
Sign: SOURCE_DATE_EPOCH is set and nothing changes.Cause: It is a convention, not a runtime feature. Node and npm do not apply it to your code. The build script has to read the variable and use it in place of Date.now, as the one in Prerequisites does. Setting it around a build that ignores it changes no bytes.

What to check next

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.

intermediate10 minpublished updated Maks Verny