DEV Community

Vigoss Luke
Vigoss Luke

Posted on

npm v12 Just Shipped — Here's What Broke in My CI and How I Fixed It

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

If your CI, Makefile, or release scripts use npm view --json, grep for it now:

grep -r "npm view" .github/ Makefile scripts/ package.json
Enter fullscreen mode Exit fullscreen mode

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]'
Enter fullscreen mode Exit fullscreen mode

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"}]
Enter fullscreen mode Exit fullscreen mode

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]')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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://)"
Enter fullscreen mode Exit fullscreen mode

Or audit your lockfile:

grep -r "git+" package-lock.json
Enter fullscreen mode Exit fullscreen mode

If you find transitive git deps, your options, ordered by long-term stability:

  1. Ask the dependency author to publish to npm registry — this is the real fix
  2. Fork + publish internally — fork the git dep, publish to your private registry or npm, depend on that version
  3. Use overrides in package.json — redirect the git dep to a registry version:
   "overrides": {
     "internal-lib": "npm:internal-lib-registry@1.0.0"
   }
Enter fullscreen mode Exit fullscreen mode
  1. Allow git globally — last resort: allow-git=true in .npmrc or --allow-git flag. 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"}
Enter fullscreen mode Exit fullscreen mode

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+" {} \;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Audit your scriptsnpm approve-scripts --allow-scripts-pending (run in CI-equivalent Docker)
  2. Commit the allowlistallowScripts in package.json
  3. Add CI smoke tests — one per native dependency, right after npm ci
  4. Check for shrinkwrap — if npm-shrinkwrap.json exists, migrate to bundleDependencies or package-lock.json
  5. Grep for npm view --json — update jq filters from . to .[0]
  6. Audit git depsgrep -r "git+" package-lock.json, set overrides or migrate
  7. Check npm rebuild usage — 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)