Pinning Lockfiles and Node Versions for Stable Hashes
A commit is supposed to be a complete description of what the build will produce, but in a JavaScript project it is only a description of the source. The dependency graph, the package manager, the Node runtime, and the bundler binary all live outside the commit, and every one of them can move underneath you between two builds of the same SHA. When they do, the fingerprints move with them: the code is identical, the [contenthash:8] is not, and the immutable cache you built your deployment strategy on starts churning for no reason. This page covers how to nail every one of those inputs down so that the same commit produces the same bytes on a laptop, on a CI runner, and on a rebuild six months from now.
What Actually Has to Be Pinned
Think of a build as a stack. At the bottom is the commit, which Git already pins perfectly. Above it sit four layers that Git does not pin at all, and a fingerprint at the top that is a function of every layer beneath it. A change in any layer propagates upward. Pinning four out of five is not a partial win — it leaves exactly as much drift as pinning none of them, because the unpinned layer is free to move on its own schedule.
The Install Command Is a Determinism Decision
The single largest source of cross-machine hash divergence is an install command that is allowed to resolve versions rather than replay them. npm install treats package.json as the source of truth and the lockfile as a hint: if a caret range in package.json permits a newer version than the lockfile records, npm will happily install the newer one and rewrite the lockfile in place. On a developer laptop that is convenient. In CI it means the dependency graph is a function of the day you ran the job.
npm ci inverts the relationship. It treats the lockfile as authoritative, deletes node_modules before installing, and hard-fails if package.json and the lockfile disagree. It never writes to the lockfile. That failure mode is the point: a red build on a stale lockfile is enormously cheaper than a silent dependency bump that ships a full set of new fingerprints.
| Install command | Lockfile role | Rewrites the lockfile? | Determinism guarantee | CI cost |
|---|---|---|---|---|
npm install |
Advisory | Yes, silently | None — resolves ranges at install time | Slowest: full resolution pass |
npm ci |
Authoritative | No, fails instead | Exact graph replay, node_modules wiped first |
Fast: no resolution, cache-friendly |
yarn install (Yarn 1) |
Advisory | Yes | None | Slow |
yarn install --frozen-lockfile (Yarn 1) |
Authoritative | No | Exact replay | Fast |
yarn install --immutable (Yarn 3/4) |
Authoritative | No, fails on any change | Exact replay, checks the cache checksum too | Fast |
pnpm install |
Advisory | Yes | None | Moderate |
pnpm install --frozen-lockfile |
Authoritative | No | Exact replay of the resolved graph | Fastest: hard-linked store |
Yarn 3 and 4 default --immutable to on when CI=true, and pnpm defaults --frozen-lockfile to on in CI as well. Neither default survives a script that explicitly calls the bare command, so write the flag out even where it is redundant.
lockfileVersion Drift
npm’s lockfile format has changed twice, and the format number decides how much of the graph is actually recorded. lockfileVersion: 1 (npm 6) records a nested tree; lockfileVersion: 2 (npm 7 and 8) records both the nested tree and a flat packages map; lockfileVersion: 3 (npm 9 and later) drops the legacy tree and keeps only the flat map. Running npm install with a newer npm against an older lockfile silently migrates the format, which rewrites nearly every line of the file and, in the version-1 to version-3 case, can also change how peer dependencies were resolved.
The visible symptom is a pull request with a 4,000-line lockfile diff and no dependency changes in package.json, followed by a deploy in which every vendor chunk gets a new hash. Pin the package manager itself, not just its packages.
Caret Ranges and Transitive Float
A committed lockfile pins your transitive graph, but the caret ranges in package.json decide what happens the next time someone regenerates it. A dependency declared as "lodash-es": "^4.17.21" will float to any 4.x release, and a transitive dependency two levels down can float even when your own direct dependency is exact-pinned. Minified output is extremely sensitive to this: a patch release that renames one internal helper changes the minified bytes of the vendor chunk and therefore its fingerprint, with no observable behaviour change.
Two controls tighten this without freezing your project in amber:
{
"name": "storefront",
"packageManager": "npm@10.9.2",
"engines": {
"node": "22.16.0",
"npm": "10.9.2"
},
"dependencies": {
"lodash-es": "4.17.21",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"overrides": {
"semver": "7.6.3",
"postcss": "8.4.47"
},
"scripts": {
"build": "vite build"
}
}
Exact versions on direct dependencies remove the float you control. overrides (npm 8.3+; resolutions in Yarn, pnpm.overrides in pnpm) removes the float you do not control, by forcing a single resolved version of a transitive package that would otherwise appear at two versions in the tree. Duplicated transitive versions are worth hunting down for their own sake — they bloat the vendor bundle and give you two copies of the same code to fingerprint. npm ls semver and pnpm why semver both print the resolution paths.
Add engine-strict=true to .npmrc so the engines block is enforced rather than advisory:
; .npmrc
engine-strict=true
audit=false
fund=false
Pinning the Runtime
Three mechanisms cover the Node version, and they are complementary rather than alternatives. .nvmrc (or .node-version) is read by nvm, fnm, asdf, and the actions/setup-node action, so it covers developer machines and CI with one file. Volta writes a volta block into package.json and swaps the binary transparently when you cd into the project. The engines field plus engine-strict turns a mismatch into an install-time error rather than a build-time surprise. packageManager plus Corepack does the same job for npm, Yarn, and pnpm themselves.
# .nvmrc — exact patch version, no range, no "lts/*"
22.16.0
# Enable Corepack so the packageManager field is enforced, then install
corepack enable
node --version # must print v22.16.0
npm --version # must print 10.9.2
npm ci --prefer-offline --no-audit --no-fund
Why a Patch-Level Bump Moves Every Chunk Hash
It is tempting to treat 22.16.0 versus 22.17.0 as beneath notice. It is not, because the bundler runs on Node, and three things ride along with the runtime:
- V8. Minifiers rely on the engine’s
StringandRegExpbehaviour, and property enumeration order in the rare cases where a plugin iterates a numeric-keyed object. A V8 update inside a Node patch release can shift the exact output of a mangler pass. - ICU. Node ships a bundled ICU data set. Anything in the toolchain that calls
localeCompare,Intl.Collator, ortoLocaleString— including some CSS ordering plugins — can sort differently after an ICU version change. SettingLC_ALL=C.UTF-8mitigates shell-level sorting but not in-process ICU collation. - zlib. If you precompress assets and ship
.gzor.bralongside the originals, those compressed files have fingerprints too. A zlib or Brotli version bump changes the compressed bytes for identical input, so the precompressed variants get new hashes even though the source assets did not change.
The same reasoning applies to the bundler binary. Keep webpack, vite, rollup, and esbuild as exact-pinned devDependencies — never carets — because a minor bundler release routinely changes chunk-splitting heuristics, and that changes hashes across the whole output directory. The webpack output hashing reference covers which of those settings are hash-affecting.
SOURCE_DATE_EPOCH Closes the Last Gap
Pinning versions removes variance between machines. SOURCE_DATE_EPOCH removes variance between runs, by giving any timestamp-writing tool a fixed value derived from the commit rather than the wall clock:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
Because the value comes from the commit, it advances exactly when the source advances — which is precisely the property a content hash needs. Rebuilding an eighteen-month-old tag reproduces its original epoch, and therefore its original fingerprints.
A CI Job That Holds the Line
# .github/workflows/build.yml
name: build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-24.04
env:
TZ: UTC
LC_ALL: C.UTF-8
LANG: C.UTF-8
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Pin the build clock to the commit
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV"
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Enable Corepack
run: corepack enable
- name: Install from the lockfile only
run: npm ci --prefer-offline --no-audit --no-fund
- name: Fail if the install touched the lockfile
run: git diff --exit-code -- package-lock.json
- name: Build
run: npm run build
- name: Record the fingerprint manifest
run: find dist -type f | sort | xargs sha256sum > "manifest-${{ runner.os }}.sha256"
- uses: actions/upload-artifact@v4
with:
name: manifest-${{ github.job }}
path: manifest-*.sha256
The git diff --exit-code step is the one people skip. npm ci should never modify package-lock.json, so if that step ever fails you have found either a corrupted lockfile or a package manager version mismatch — both of which would otherwise show up much later as unexplained hash churn. Wiring this job into a wider release pipeline is covered in the deployment pipeline integration guide.
Verification: Two Builds, One Diff
Run this locally before you trust the CI job. It builds twice from a completely cold dependency tree, which is the only way to catch drift that hides in the install step rather than the build step:
#!/usr/bin/env bash
# scripts/verify-pinning.sh — two cold installs, two builds, one diff
set -euo pipefail
export TZ=UTC
export LC_ALL=C.UTF-8
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
for run in 1 2; do
rm -rf node_modules dist
npm ci --prefer-offline --no-audit --no-fund
npm run build
find dist -type f | sort | xargs sha256sum > "/tmp/pin-${run}.sha256"
echo "run ${run}: $(wc -l < "/tmp/pin-${run}.sha256") files fingerprinted"
done
if diff -u /tmp/pin-1.sha256 /tmp/pin-2.sha256; then
echo "PASS: cold-install builds are byte-identical"
else
echo "FAIL: the dependency graph or the toolchain moved between runs"
exit 1
fi
If that passes locally but the cross-runner comparison fails, the difference is environmental rather than dependency-related, and the bisection workflow for phantom hash changes will isolate it.
When to Reconsider
Exact pinning has a maintenance cost, and there are situations where a looser posture is correct.
Security-patch velocity matters more than hash stability. A project that must absorb transitive security fixes within hours is poorly served by exact pins on every transitive package. The middle ground is exact pins on direct dependencies, a scheduled bot that opens lockfile-refresh pull requests, and a determinism check in CI — you accept planned hash churn instead of unplanned churn.
Library packages, not applications. A published npm library should declare permissive ranges so consumers can deduplicate. Pinning belongs in the application at the top of the tree, which is the thing that actually emits fingerprinted assets.
Short-lived preview environments. A per-branch preview deployed behind Cache-Control: no-store gains nothing from reproducibility. Keep the frozen install for speed, skip the epoch discipline.
One-off migrations. Enabling exact pinning changes the resolved graph once, and that first build legitimately renames every asset. Schedule it like any other full-invalidation event; the trade-off between fingerprint churn and explicit versioning is laid out in content hashing versus semantic versioning.
Frequently Asked Questions
Does npm ci guarantee identical hashes on two machines?
It guarantees an identical dependency tree, which is necessary but not sufficient. Two machines running npm ci from the same lockfile on different Node patch versions can still emit different bytes, because the minifier’s behaviour depends on the engine. Pin the runtime and the package manager alongside the lockfile.
My lockfile changed but package.json did not. Is that a problem?
It is a signal worth investigating. Legitimate causes are a package manager upgrade migrating the lockfileVersion, or someone running a bare npm install and picking up a floated transitive version. Both change your fingerprints. Review the diff, decide whether the new graph is what you want, and commit it deliberately rather than letting it ride along in an unrelated pull request.
Should I commit node_modules instead?
No. It solves determinism by brute force but breaks platform-specific binaries — esbuild, sharp, and SWC all ship per-platform artifacts, so a tree vendored on macOS will not run on a Linux runner. A frozen install from a committed lockfile gives the same guarantee without the platform trap.
Is SOURCE_DATE_EPOCH needed if nothing in my build writes a timestamp?
Set it anyway. It costs one line and it protects you the day someone adds a banner plugin, a ZIP step, or a service-worker precache manifest that stamps the build time. Verifying that nothing consumes it is more work than simply setting it.
Related
- Deterministic build outputs — parent guide covering every entropy source in the pipeline
- Static asset fingerprinting fundamentals — the section this topic sits in
- Diagnosing hashes that move on their own — bisection workflow when a pin turns out to be missing
- Source maps as part of the hashed bytes — the other build setting that renames every asset
- Environment isolation in the deployment pipeline — where the frozen install fits in a release workflow