A client's image endpoint started returning 500s on a Wednesday afternoon. The deploy that morning had been green in every way we normally check: npm ci succeeded, the TypeScript build succeeded, the container pushed, health checks passed. The only thing that had actually changed was the base image, which had picked up a newer Node — and with it, npm 12.
sharp was installed. Every file was on disk, the version in node_modules matched the lockfile exactly, and requiring it threw at runtime because the platform binary its install script fetches had never been fetched. Nothing in that chain reported a failure. The install did precisely what it was told to do, which was nothing.
We've now run this migration across eleven repositories — client apps, our own tooling, two monorepos with a combined three thousand-odd dependencies. The first instinct on every one of those teams was the same instinct we had: pin npm to 11 in CI and deal with it after the release. That is not a rollback. It is a decision to have this happen on a laptop you don't control, on a day you didn't pick.
Three defaults flipped, and only one of them is quiet
npm 12 is GA and tagged latest, which means you do not adopt it — you receive it. A fresh developer machine receives it. A rebuilt CI image receives it. A Dockerfile that says node:22 without a patch pin receives it on whatever Tuesday the upstream image moves. Three install-time behaviours that used to happen automatically are now things you have to ask for.
- Lifecycle scripts no longer run— preinstall, install and postinstall from your dependencies, plus the implicit node-gyp build that used to fire for any package with a binding.gyp and no install script of its own. This is the one that produces a green install and a broken application.
- Git dependencies no longer resolve— anything specified as a GitHub shorthand or a git+ssh URL. Internal forks pinned to a commit, a patched library you never got round to publishing, a private package that predates your registry.
- Remote-URL dependencies no longer resolve— a dependency pointing at a tarball on a CDN or an artefact server. Rarer than the other two, and when it exists it is almost always load-bearing and undocumented.
Two of those three announce themselves properly. A git dependency that will not resolve is a failed install with a message naming the package, and you fix it in the ten minutes after you read it. Nobody ships that by accident. The first one is a different category of event, because a lifecycle script that doesn't run is not an error. It is an absence, and absences don't have exit codes.
This is a good change and we're glad it landed. Install-time script execution has been the most reliably exploited surface in the npm ecosystem for years — a compromised transitive dependency four levels down your tree gets arbitrary code execution on your machine and your build agents, before a single line of your application has run. Making that opt-in is correct. It is also a behaviour change dressed as a version bump, and it needs to be planned like one.
The half that fails silently
The packages that break are not obscure. They're the ones in nearly every stack we touch: sharp and its platform binaries, Prisma and its generated client, Playwright and its browser downloads, esbuild's binary fetch, anything compiling native code through node-gyp, husky wiring up git hooks, and the long tail of packages that print a funding notice you have never once read.
Each fails at a different distance from the install. Prisma fails at the first query, which is usually the first request in staging — annoying, obvious, cheap. Playwright fails when your end-to-end suite runs, which is far enough away that two engineers will look at the test before anyone looks at the install. sharp fails on the first image resize, which in the client case above was a route that ran maybe forty times a day, so it took six hours and a customer email to surface. husky is the worst of them, because it fails at nothing: the hooks are simply not installed, and your team quietly stops running the pre-commit checks that a linting policy assumes are running.
So the first thing we do is not a fix. It's an inventory, because you cannot approve a list you have not read. The script below walks the installed tree, reports every package that declares an install-time script alongside how it got into the tree, and separately reports every git or remote-URL specifier anywhere in the manifest set.
The numbers it returns are the reason this migration is smaller than it sounds. On the largest monorepo we ran it against — 1,412 packages in the installed tree — 34 declared an install-time script and exactly 6 of those did something the application could not live without. The other 28 printed a funding banner, wrote a telemetry opt-out file, or tried to install a git hook in a directory that isn't a git repository during CI. Not running them is not a regression. In two cases it made the install measurably faster.

The allowlist is the deliverable
With the inventory in hand, npm approve-scripts does the actual work. It walks the packages in your tree that want to run install-time code, asks about each one, and writes the resulting allowlist into your project. That file is the point of the whole exercise, and the mistake we watched two teams make in week one was treating it as generated output rather than as source.
Commit it, and review it the way you review a lockfile — which is to say, look at the diff every time it changes. Six entries is a list a human can read in ten seconds and hold an opinion about. The value shows up on the pull request three months from now where a new package quietly asks for install-time execution and the diff says so in one line, in front of a reviewer, before it runs on anyone's machine. That signal did not exist at all in npm 11.
The two escape hatches that look like fixes are blanket-allowing every package and setting ignore-scripts=falseglobally in a CI image. Both restore npm 11 behaviour, both make the change invisible to reviewers, and the second one is worse than never migrating — it silently re-arms install-time execution for every repo that image builds, including the ones whose teams believe they are protected. If you need a temporary bypass, put it in one job with an expiry date in the comment, not in the base image.
Then prove it, and prove it somewhere that cannot be lying to you. A developer machine already has the built binaries sitting in a cache, so a local npm ci will happily succeed for reasons that will not exist on a fresh agent. The job below is the one that actually tells you whether the allowlist is complete: no cache, no restore, and a smoke step that exercises the specific packages the audit flagged.
Two of our eleven repos passed that job on the first attempt and both were greenfield. The other nine each surfaced one thing the audit had not: a package pulled in only under an optional peer, a native module that built from source on Linux and downloaded a prebuilt on macOS, and in one memorable case a postinstall that existed purely to patch a bug in a dependency the team had forgotten was patched.
Git and remote dependencies: the ones you forgot you had
These break loudly, which makes them easy to fix and easy to fix badly. The failure names the package and stops the install, so somebody finds it inside a minute. What they do next matters, because the obvious move — turn the setting back on so the git URL resolves again — restores the exact situation the change was designed to remove.
Almost every git dependency we found was one of three things. A fork of a public package carrying a patch that was never upstreamed. An internal package that predates the team having a private registry. Or a pin to a specific commit on a public repo, made during an incident, by someone who intended it to last a week. All three have the same real fix, and it is not a config flag: publish it to your private registry, or move the patch into patch-package or npm overrides where it is visible in the repo instead of hidden behind a URL.
“A dependency that resolves over ssh from a repo three people can force-push is not a dependency. It's a shared mutable variable with a longer install time.”
Budget an afternoon per git dependency, not ten minutes, because the reason it is a git dependency is usually a story. On two of the eleven repos this was the largest single chunk of the migration, and on both it was work that had been owed for two years. That is the honest argument for doing this now rather than pinning: npm 12 is not creating this work, it is invoicing you for it.
The publish token belongs in the same change
If you publish packages, the second half of this is the credential, and it is the half teams skip because nothing is currently broken. Granular access tokens that bypass two-factor on publish are on the way out, and a long-lived npm token sitting in CI secrets has always been the single credential that turns any repo compromise into a supply-chain compromise. It does not expire, it does not rotate, and roughly nobody audits which workflows can read it.
Trusted publishing removes it. The registry verifies a short-lived OIDC token minted by the CI provider for that specific workflow run, so the thing that proves you are allowed to publish is the run itself rather than a string somebody pasted into a settings page in 2023.
The trade is real and worth stating: your ability to publish is now tied to your CI provider being up and to a workflow file that a repo admin can edit. We think that is a much better failure mode than a token nobody can enumerate the readers of, and the provenance attestation you get alongside it is the first thing we have seen that lets a consumer verify which commit and which workflow produced a tarball.
The order we run it in
Run the audit while you are still on npm 11, when everything works and the output is a scope document rather than a triage list. Fix the git and remote dependencies first, because they are the only part with real design decisions in them and they are the part that will overrun. Then approve scripts, commit the allowlist, and add the cold-install job. Do the publish credential last, in its own pull request, so that if it goes sideways you are debugging one thing.
Across eleven repositories the median was under a day of work, and the two that ran into a second week were both carrying git dependencies that should have been published years earlier. The distribution is what convinced us to stop recommending the pin: the work is small for almost everyone, and the way you find out you are the exception should be a failing branch on a Tuesday, not a customer email about broken images.
And if you take one thing from this: the risky failure here is not the install that stops. It is the install that succeeds. Add the cold-cache job before you change anything else, because that job is the only part of your pipeline currently capable of telling the difference.









