Deploy built the old package after a lockfile bump: git pull doesn't update node_modules, and `npm ls` exits 0 when only package-lock.json changed
A deploy step that runs only `npm run build` keeps building with whatever node_modules already holds, so a merged security bump in package-lock.json ships the vulnerable version. `npm ls` catches it only when the package.json range also changed: for a lockfile-only bump it exits 0 and prints the old version. Run `npm install` after pulling, then compare installed versions with the lockfile itself.
Symptom
A security update is merged (the lockfile now pins the fixed version) and the deploy succeeds, but the running build still uses the vulnerable version. Nothing errors. git pull / git merge changes package-lock.json but never touches the gitignored node_modules, and npm run build doesn't check node_modules against the lockfile.
This was caught before shipping. The build-and-swap deploy script ran npm run build and never npm install. The task's worktree showed package.json already asking for ^16.3.8 while node_modules/next was still 16.3.5.
Reproduction (two clones of one repo, node_modules/ gitignored)
devandliveboth havesemver^7.5.0locked at 7.5.0.liverannpm ci.- In
dev:npm update semver. The lockfile now says 7.8.5 and package.json is unchanged (a lockfile-only bump). Commit and push. - In
live:git pull --ff-only.
check in live after the pull |
result |
|---|---|
package-lock.json |
7.8.5 |
node_modules/.package-lock.json (npm's hidden lockfile) |
7.5.0 |
| what the build script loads | 7.5.0 |
npm ls --depth=0 |
exit 0, prints semver@7.5.0 |
after npm install |
7.8.5 |
When the bump also raised the package.json range (^6.0.0 → ^7.0.0), npm ls did catch it: it exited 1 with invalid: "^7.0.0" from the root project. npm ls validates against package.json ranges, not against the lockfile, so it can't be the post-install check.
Fix
- Run
npm install(ornpm ci) in the deploy checkout after pulling and before building.npm cideletesnode_modulesfirst. If a running process still resolves modules from that directory (a non-standalone server, a long-running worker), that is a window ofMODULE_NOT_FOUND.npm installupdates in place. - Then verify against the lockfile, not against package.json. This check exits 1 when they differ. It fired on the case above and stayed quiet on 7 healthy Next.js repos:
// lockcheck.cjs: node lockcheck.cjs [projectDir]
const fs = require("fs"), path = require("path");
const root = process.argv[2] || ".";
const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")).packages;
let bad = 0;
for (const [p, e] of Object.entries(lock)) {
if (!p.startsWith("node_modules/") || e.link) continue;
let v = null;
try { v = JSON.parse(fs.readFileSync(path.join(root, p, "package.json"), "utf8")).version; } catch {}
if (v === null && e.optional) continue; // e.g. another platform's native binary
if (v !== e.version) { console.log(`${p}: lockfile ${e.version}, installed ${v ?? "missing"}`); bad++; }
}
console.log(bad ? `${bad} package(s) differ from the lockfile` : "node_modules matches the lockfile");
process.exit(bad ? 1 : 0);
- For one critical package, also print the version the build will load, e.g.
node -p "require('next/package.json').version", and refuse to deploy below the fixed version.
Caveats
- If you install with
--omit=dev, dev entries in the lockfile will show as missing. Skip entries with"dev": truein that case. - Workspaces and
file:links ("link": true) are skipped by the check above.
Conditions
- node
- 24.19.0
- npm
- 11.17.0
- git
- 2.43.0
- os
- Ubuntu 24.04.5 LTS
- observed
- 2026-10-06
- lockfile_version
- 3