npm v12 hit stable on July 8. Within 12 hours, three of our CI pipelines were red. Not yellow — red. And the worst part: one of them was green for 4 hours before anyone noticed the deploy was shipping broken containers.
Here's exactly what broke, why it was silent, and how to fix each one.
The One That Scared Me: npm ci Exits 0, Native Modules Never Compiled
This is the trap. Your CI runs npm ci, it exits with code 0. Docker build succeeds. Container deploys. App starts. Everything looks fine — until the first request that hits a native module.
Error: Cannot find module './build/Release/sharp.node'
Here's what happened under the hood:
npm ci
# v12 behavior: encounters sharp's install script
# → script blocked (not in allowlist)
# → no error, no warning in exit code
# → npm ci exits 0
# → node_modules/sharp exists, but build/Release/ is empty
The fix isn't --allow-scripts=all in CI. That defeats the purpose. The fix is:
# Step 1: Audit your actual script needs
npm approve-scripts --allow-scripts-pending
# Step 2: This prints the real list — usually 3-8 packages, not 200
# Example output:
# sharp (postinstall: node-gyp rebuild)
# esbuild (postinstall: node install.js)
# @swc/core (postinstall: node postinstall.js)
# Step 3: Approve the ones you trust
npm approve-scripts sharp esbuild @swc/core
# Step 4: Commit the allowlist
git add package.json # allowScripts field is now populated
git commit -m "add npm v12 script allowlist"
The allowlist lives in package.json under the allowScripts key. It's scoped — if a package isn't in the list, its scripts don't run. Commit it. Your CI will pick it up from the committed package.json with no .npmrc changes needed.
But here's the subtlety: you must run the audit on the same OS and Node version as CI. node-gyp rebuild is conditional — a package might run scripts on Linux but not macOS. Run npm approve-scripts --allow-scripts-pending in a Docker container that matches your CI environment, or you'll miss scripts that only fire on Linux.
The CI Pipeline That Passed Green But Shipped Broken Code
We had a GitHub Actions workflow like this:
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm test
All three steps passed. The problem: npm ci skipped sharp's postinstall (node-gyp rebuild), and our test suite didn't exercise image processing — it mocked the sharp module. Unit tests passed, but the running app crashed on the first real image upload.
The fix: add a smoke test after npm ci.
- name: Install dependencies
run: npm ci
- name: Smoke test — native modules
run: |
node -e "
const sharp = require('sharp');
console.log('sharp loaded:', sharp.versions);
" || (echo 'NATIVE MODULE NOT COMPILED — check allowlist' && exit 1)
- name: Build
run: npm run build
- name: Test
run: npm test
This catches silent native module failures before the build step. Do this for every native dependency that's critical to your app — sharp, bcrypt, canvas, node-sass, @swc/core, esbuild, prisma, better-sqlite3. One smoke test per native dep, run immediately after npm ci. Add it to your CI template. The first time it catches a silent skip, you'll thank yourself.
If you run monorepo CI, put this in a shared workflow or composite action. Every package that touches native deps should have its own smoke test — don't rely on the root npm ci to catch sub-package failures.
npm-shrinkwrap.json Is Gone — And npm Won't Tell You
If your project uses npm-shrinkwrap.json (common in CLI tools published to npm), v12 ignores it. Completely. No warning. Your lockfile is just... not the one you think it is.
# v11: shrinkwrap loaded
$ npm ci
# uses npm-shrinkwrap.json for dependency resolution
# v12: shrinkwrap silently ignored
$ npm ci
# uses package-lock.json (or generates one)
# npm-shrinkwrap.json is treated as if it doesn't exist
This is especially dangerous for published CLI packages. Your users install your tool, and under v12 they get different dependency versions than what you tested against.
Fix: switch to bundleDependencies.
{
"name": "my-cli-tool",
"version": "2.0.0",
"bundleDependencies": [
"yargs",
"chalk",
"ora"
]
}
When published with bundleDependencies, those packages are packed into the tarball. The user installing your CLI gets the exact versions you tested, regardless of npm version — no shrinkwrap needed. This works across npm 11 and 12.
If you absolutely need shrinkwrap-like behavior without bundling, rename npm-shrinkwrap.json to package-lock.json and ensure your publish workflow copies it:
cp npm-shrinkwrap.json package-lock.json
npm publish --lockfile-version=3
But honestly, bundleDependencies is the better answer for published packages. It's simpler, more portable, and works everywhere.
npm view --json Returns an Array Now
This one broke our release automation script. Every script that parses npm view --json output needs updating.
# v11: single result → object
$ npm view lodash@4.17.21 version --json
"4.17.21"
# v12: always array, even for single results
$ npm view lodash@4.17.21 version --json
["4.17.21"]
Our release script was doing:
LATEST=$(npm view my-package version --json)
# v11: echo $LATEST → "1.2.3" (works in jq)
# v12: echo $LATEST → ["1.2.3"] (breaks jq parsing)
If your CI, Makefile, or release scripts use npm view --json, grep for it now:
grep -r "npm view" .github/ Makefile scripts/ package.json
For each hit, check if it parses the JSON output. Every instance needs the same fix:
# Before (v11)
npm view my-package version --json | jq -r '.'
# After (v12)
npm view my-package version --json | jq -r '.[0]'
The array wrapping also hits multi-field queries:
# v11: single version → single object
$ npm view lodash@4.17.21 name version --json
{"name":"lodash","version":"4.17.21"}
# v12: single version → array with one object
$ npm view lodash@4.17.21 name version --json
[{"name":"lodash","version":"4.17.21"}]
If your script iterates with jq '.[]', it'll still work. If it expects a bare object with jq '.version', it breaks. Fix: wrap all your jq filters in .[0] or use jq '.[]' and loop.
Our full release script patch:
- PUBLISHED_VERSION=$(npm view my-package version --json | jq -r '.')
+ PUBLISHED_VERSION=$(npm view my-package version --json | jq -r '.[0]')
This one is easy to miss because it doesn't cause install-time failures. Your release just... silently publishes wrong version strings. Check your scripts before the next release.
Git Dependencies Are Blocked — And Transitive Ones Count
Your package.json might not have git deps, but one of your dependencies might. v12 blocks them at every level of the dependency tree.
# This fails in v12
npm install some-package
# Error: Git dependency "github:user/internal-lib" blocked
# (comes from some-package → its-dep → git dep, not your direct dep)
# What worked in v11:
# npm silently resolved and installed the git dep
To find hidden git deps before you migrate:
# Run while still on npm 11.16.0+
npm ls --all 2>&1 | grep -E "(git\+|ssh://|git://)"
Or audit your lockfile:
grep -r "git+" package-lock.json
If you find transitive git deps, your options, ordered by long-term stability:
- Ask the dependency author to publish to npm registry — this is the real fix
- Fork + publish internally — fork the git dep, publish to your private registry or npm, depend on that version
-
Use
overridesin package.json — redirect the git dep to a registry version:
"overrides": {
"internal-lib": "npm:internal-lib-registry@1.0.0"
}
-
Allow git globally — last resort:
allow-git=truein.npmrcor--allow-gitflag. Only do this if you control the full dependency chain
Overrides are the fastest fix short-term, but pin a specific version. Floating overrides ("*") will bite you on the next upgrade.
Monorepo-Specific: Workspace Protocol Doesn't Save You
If you use npm workspaces, the workspace:* protocol doesn't bypass the git/remote blocks. A workspace package that depends on a git dep still fails:
packages/
app/ → "deps": {"shared": "workspace:*"}
shared/ → "deps": {"internal-lib": "github:org/internal-lib#v1"}
npm install at the root fails because shared pulls a git dep transitively. The fix is the same as above — audit all package.json files in your monorepo, not just the root:
find packages -name package.json -exec grep -l "git+" {} \;
Run this in CI as a pre-flight check before npm ci. Block the build if any git deps are found and haven't been explicitly allowed.
The 24-Hour Checklist
If you're waking up to npm v12, here's the order of operations:
-
Audit your scripts —
npm approve-scripts --allow-scripts-pending(run in CI-equivalent Docker) -
Commit the allowlist —
allowScriptsinpackage.json -
Add CI smoke tests — one per native dependency, right after
npm ci -
Check for shrinkwrap — if
npm-shrinkwrap.jsonexists, migrate tobundleDependenciesorpackage-lock.json -
Grep for
npm view --json— updatejqfilters from.to.[0] -
Audit git deps —
grep -r "git+" package-lock.json, set overrides or migrate -
Check
npm rebuildusage — it's also subject to script blocking; approve scripts first or pass--allow-scripts
I've been tracking v12 changes since pre.1 and maintain a full migration guide with per-environment configs at installsafe.dev.
Top comments (0)