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 pinning stack A build stack drawn bottom to top: source commit pinned by the git SHA, dependency graph pinned by the committed lockfile, package manager pinned by the packageManager field, Node runtime pinned by .nvmrc and engines, and bundler output pinned by a locked devDependency. The content hash at the top is a function of all five. Content hash of the output Pinned by Bundler output pinned devDependency Node.js runtime .nvmrc and engines Package manager packageManager field Dependency graph committed lockfile Source commit git SHA
Git pins the bottom layer for free; the four above it need explicit mechanisms, and the fingerprint at the top inherits whichever one you skipped.

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.

Lockfile drift state machine From an in-sync lockfile, a dependency bump moves the project to a state where package.json is ahead of the lockfile. From there npm install rewrites the lockfile silently and every chunk hash moves, while npm ci refuses the install and forces the updated lockfile to be committed, returning the project to the in-sync state. Lockfile in sync hashes reproduce dependency bump package.json ahead of the lockfile npm install Lock rewritten silently, in place every chunk hash moves npm ci npm ci fails fast install is refused commit the updated lock
Both paths leave the lockfile updated; only one of them tells you it happened before the artifacts reach the CDN.

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 String and RegExp behaviour, 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, or toLocaleString — including some CSS ordering plugins — can sort differently after an ICU version change. Setting LC_ALL=C.UTF-8 mitigates shell-level sorting but not in-process ICU collation.
  • zlib. If you precompress assets and ship .gz or .br alongside 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

Cross-runner fingerprint verification A single commit is built on two independently provisioned runners that share the same pinned Node version and frozen install command. Each emits a sorted sha256 manifest, and a third job diffs the two manifests, which must be empty. commit abc1234 one source tree Runner A node 22.16.0, npm ci manifest-a.sha256 Runner B node 22.16.0, npm ci manifest-b.sha256 Compare job the diff must be empty Any divergence here is a pinning bug, never a code change
Two independently provisioned runners on one commit: if their manifests differ, something above the git layer is still floating.

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.