Nobody touched the code. The pull request was a one-line copy fix. But CI is red on a branch that was green yesterday, and the diff that broke it is in pnpm-lock.yaml — a transitive dependency went from 2.3.4 to 2.4.0 overnight because three levels down, something declared ^2.3.0. The "minor" bump removed an export. That's semantic versioning failing exactly as designed: the number said safe, the range trusted it, and the promise was a lie.
The system only works if authors mean the numbers. MAJOR.MINOR.PATCH encodes a contract — increment major for an incompatible public-API change, minor for a backwards-compatible feature, patch for a backwards-compatible fix — and every ^ range in every package.json downstream is betting the publisher honored it.
| Change | Version bump |
|---|---|
| Fix incorrect output, no API change | Patch: 1.4.2 → 1.4.3 |
| Add an optional, backwards-compatible capability | Minor: 1.4.2 → 1.5.0 |
| Remove or change existing public behavior | Major: 1.4.2 → 2.0.0 |
The mistakes that turn versions into roulette
Calling a breaking change "minor" because it's small
The size of the change is irrelevant; compatibility is the only axis. Removing one export, renaming one option, tightening one validation that used to pass — all major, no matter how few lines. "It's a tiny break" is the single most common way a ^ range detonates a downstream build, because the whole point of the range is that it auto-installs your minors.
Assuming 0.x behaves like 1.x
Before 1.0.0, anything can change at any time — semver explicitly says the initial-development API is unstable. And the caret rule is different down here: ^0.2.3 does not mean <1.0.0. It resolves to >=0.2.3 <0.3.0, treating the minor as the breaking position. People read ^0.2.3 as "any 0.x" and get surprised when 0.3.0 never installs.
Shipping a lockfile-free application
For a library, ranges are correct — you don't want to pin your consumers. For a deployed application, ranges without a committed lockfile mean your reproducible build depends on what the registry served this morning. Commit package-lock.json / pnpm-lock.yaml so the same commit resolves to the same bytes in six months.
Burning a major on a rename you could have deprecated
Deprecation is usually the cheaper mitigation. Ship the replacement, mark the old path deprecated with a runtime or type-level warning, and remove it in the next major — don't yank it in a patch and surprise everyone who trusted the number.
^ vs ~: what you're actually allowing
The two range operators get muddled constantly, and the difference is which position they pin:
| Range | Expands to | Allows | Blocks |
|---|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0 | New minors and patches (1.3.0, 1.9.9) | Majors (2.0.0) |
~1.2.3 | >=1.2.3 <1.3.0 | Patches only (1.2.4, 1.2.9) | Minors (1.3.0) |
^0.2.3 | >=0.2.3 <0.3.0 | Patches only (0.2.x) | 0.3.0 |
^ (the npm default) trusts minors; ~ trusts only patches. Reach for ~ when you've been burned by a dependency's loose interpretation of "minor" and want new features to be a deliberate npm update, not a silent one.
Versions are a communication channel, not a build number. The publisher's job is to make the number match the risk; the consumer's job is to pick a range that matches how much they trust it — and to commit a lockfile so trust isn't required on every install.
Cover photo by Pixabay on Pexels.
