A Patch Release That Broke Three Consumers
A team maintains an internal Node.js library, acct-utils, used by nine services. Version 1.4.4 was published as a patch release and contained one behavioural change: the default page size returned by paginate() dropped from 50 to 25, and results are now sorted by createdAt instead of id. Service A pins "acct-utils": "1.4.3" exactly; Service B uses "^1.4.0" with a committed package-lock.json last regenerated four months ago; Service C uses "^1.4.0" and runs npm install (not npm ci) in its Docker build, with no lockfile committed. After the release, Service C's nightly export job silently produced files with roughly half the expected rows. Service B's build picked up 1.4.4 only when a developer added an unrelated dependency. No test in the library asserts on default page size; the only test passes an explicit { pageSize: 10 }. The team wants to know whether to revert to 1.4.3, publish 1.5.0, or publish 2.0.0.
1. Which version should the team publish next, and what should they do about the already-published 1.4.4?
Read the worked answer
None of the three options is right alone; the correct move is two releases.
Semver is a contract about what consumers can assume, not about the size of the diff. Changing the default page size from 50 to 25 changes the shape of results for any caller that omits pageSize, and changing the sort order changes pagination correctness even for callers that would tolerate smaller pages. That is a breaking change, so the intended behaviour belongs in 2.0.0, with a changelog entry naming both defaults and a migration example such as paginate(q, { pageSize: 50, orderBy: 'id' }).
But publishing only 2.0.0 leaves every ^1 consumer exposed the next time they re-resolve. So also publish 1.4.5 that restores 50 rows and id ordering - that keeps the ^1 range genuinely compatible, which is what Service B's range already promises.
Do not try to unpublish 1.4.4. npm only permits unpublishing within 72 hours and only when no other package depends on it; afterwards it requires registry support intervention. Even if you could, lockfiles and CI caches already hold the tarball. Instead run npm deprecate [email protected] "Accidental breaking change; use 1.4.5 or 2.0.0" - the message surfaces on install, but pinned builds keep working.
"Revert to 1.4.3" is not available either: version numbers are append-only, so you cannot republish 1.4.3 as something newer.
2. Explain exactly why Service C failed silently while its CI stayed green, and propose three changes that each close a different part of the gap.
Read the worked answer
There is a four-link causal chain, and each link needs its own fix.
Resolution at build time. Service C has a caret range, no committed lockfile, and
npm installin the Dockerfile. Every build therefore resolves^1.4.0to whatever is newest on the registry that day. Fix: commitpackage-lock.json, switch tonpm ci, which installs exactly what the lockfile says and fails if package.json has drifted. This makes the build reproducible and turns upgrades into reviewable diffs.The library's contract was untested. The only test passes
{ pageSize: 10 }, so the default path - the path every consumer actually uses - has no coverage. Fix: a contract test that callspaginate()with no options and asserts 50 rows andidordering, plus one asserting that an explicit small pageSize still honours the sort option. Test the default, not just the override.A wrong result looked like success. The export job exited 0 with half the rows. Fix: assert an invariant in the job - output row count equals source row count, or a checksum against the query count - and exit non-zero on mismatch so the scheduler reports failure.
Nobody was watching. Add alerting on export volume deviation (say >5% from the trailing 7-day mean) so a silent shortfall is detected within one run rather than by a downstream reconciliation weeks later.
3. Service B had a committed lockfile yet still moved to 1.4.4 only when an unrelated dependency was added. Explain the mechanism and what it implies for CI.
Read the worked answer
A lockfile is a starting point for npm install, not a constraint. When you add a dependency, npm has to re-resolve the tree: new hoisting decisions may be required, peer ranges may need rechecking, and the affected subtree is re-evaluated. Versions that still satisfy ^1.4.0 - including 1.4.4 - can be selected, and the lockfile is rewritten in place. The commit that says "add csv-parser" therefore also ships a lockfile diff that silently upgrades acct-utils, and nobody reviews it as a dependency change.
The implication is that Service B's build is only reproducible to the extent that someone enforces the lockfile. Use npm ci in CI and in Docker builds: it errors when the lockfile and package.json disagree, and it never writes the lockfile, so a resolution change can only enter through an explicit commit. Then make those commits legible: label lockfile-only changes in review, or adopt Renovate/Dependabot so dependency bumps arrive as their own PRs with changelogs attached, rather than riding along inside feature work.
A secondary point worth raising with the team: this mechanism means Service B was already one unrelated PR away from the same failure Service C hit, which is why treating the incident as "Service C's problem" would be a mistake.