Why Did a Patch Release Break My CI Overnight

Nothing changed. The last commit was Friday afternoon, the pipeline was green, and no one has pushed since. It is Monday morning and CI is red on a build that touched none of your code. Somewhere in the dependency tree, a “patch” release shipped that wasn’t one.

This is one of the most disorienting failures in a Node project, because every instinct tells you to look at your own diff and there isn’t one. The change came from outside the repository, admitted through a version range you probably wrote months ago and haven’t thought about since. The fix is not mysterious once you know where to look, and the policy that stops it recurring is a single line. Here is the whole diagnosis, in order.

The lie hiding in “nothing changed”

Open your package.json and look at a dependency line: "some-lib": "^1.4.0". That caret is not a version. It is a range. The caret means “any release that does not change the leftmost non-zero digit” — so ^1.4.0 resolves to anything from 1.4.0 up to, but not including, 2.0.0. A tilde (~1.4.0) is tighter, allowing only patch bumps up to 1.5.0. Either way, you have signed a standing instruction: install the newest thing that fits.

Free · 4 minutes

Do you actually know what you are running — and what it is about to cost you?

Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.

Semver is a promise, not a guarantee. The convention says a patch release fixes bugs and breaks nothing. The reality is that a maintainer three layers down your tree decided a bug-fix was harmless, published 3.4.2, and it wasn’t harmless for your usage. Your direct dependency’s caret range let their patch in, and because the range sits on a transitive dependency — a dependency of a dependency — it never appeared in your package.json at all. That is why your diff is empty and your build is broken at the same time.

The precondition for this happening overnight is almost always one of two things: your lockfile is not committed, or your CI runs npm install instead of npm ci. Both let the resolver re-shop the registry on every build. Confirm which before you touch anything.

Reproduce the exact tree, don’t guess it

Before you can diff anything, you need the tree CI actually built, not the one your laptop happens to have from three weeks ago. That is what npm ci is for. Unlike npm install, it deletes node_modules first, installs strictly from package-lock.json, touches nothing in your package.json, and errors out if the two files are out of sync. It is the only install command that gives you a reproducible tree.

# Reproduce the exact tree from the committed lockfile
rm -rf node_modules
npm ci

# If npm ci itself errors with "can only install with an
# existing package-lock.json ... in sync", that IS your answer:
# the lockfile drifted, or was never committed. Fix that first.

If npm ci reproduces the failure, good — you now have a deterministic red build you can bisect. If npm ci passes but the pipeline is red, your CI is not using it. Fix the pipeline command and much of the mystery evaporates on its own.

Diff the lockfile against the last green build

The package-lock.json records the exact resolved version and integrity hash of every package in the tree, transitive ones included. That makes it the single source of truth for “what moved”. Find the commit of your last green build and diff the lockfile against it.

# The SHA of the last commit that built green (from your CI history)
GREEN=abc1234

# What moved in the lockfile between then and now?
git diff "$GREEN" HEAD -- package-lock.json | grep -E '"version"|"resolved"'

# Which of your dependencies pulls the suspect package in, and at what version?
npm ls some-transitive-lib

If the lockfile did change between the two commits, the diff hands you the culprit directly: a line where 3.4.1 became 3.4.2. If the lockfile did not change and the build still broke, that confirms the resolver is re-shopping — the version that broke you exists only in the CI runner’s node_modules, never committed anywhere. In that case, capture npm ls output from a good run and a bad run and diff those instead. npm ls some-transitive-lib prints the path through the tree, so you can see exactly which direct dependency’s caret range admitted the bump.

Confirm it, then pin it

Do not skip the confirmation step. Read the changelog or the release diff for the exact version that appeared, and satisfy yourself that this change is what breaks your usage. If reproduction is fiddly, git bisect across your recent commits is worth the few minutes — but remember that if the lockfile is committed and unchanged, your own commits are innocent and bisect will point at nothing. That null result is itself diagnostic: the change came from the registry, not your history.

Once you have named the version, force the whole tree back to a known-good one. For a transitive dependency you don’t control directly, npm’s overrides field (available since npm 8.3) is the right tool — it rewrites the resolved version wherever that package appears, however deep.

// package.json — pin the transitive dependency to the last good patch
{
  "overrides": {
    "some-transitive-lib": "3.4.1"
  }
}

Run npm install once to regenerate the lockfile with the pin applied, verify with npm ls some-transitive-lib that only 3.4.1 remains, then commit both files. The override is a deliberate, documented decision — leave a comment or a note in the PR explaining why, so the next person doesn’t quietly “tidy it up” and let the bump back in.

The policy that stops it recurring

The pin fixes today. Three habits stop tomorrow. First, commit the lockfile and treat it as source — it is the record of the exact tree you tested, and a build that doesn’t install from it is a build you never really tested. Second, make CI use npm ci, never npm install, so the pipeline can only ever build the committed tree and fails loudly if the lockfile drifts. Third, for the packages where surprise is genuinely unacceptable, pin exactly: set save-exact=true in .npmrc so new dependencies are written without a caret in the first place.

None of this means freezing your dependencies forever. It means updates arrive through a pull request that a human reviews and CI tests, on a schedule you chose — not through an unattended overnight resolution nobody approved. That is exactly the enforcement a pre-commit and CI baseline is meant to guarantee, and an unexplained overnight red build is precisely the kind of event your change-failure rate should be catching and forcing you to explain.

A caret range is a promise you make to a stranger that their next release is safe to install without asking. Stop making it on the dependencies you cannot afford to be wrong about.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.