Prologue: "The Friday Night Production Meltdown"
November 2023, Friday 4:30 PM.
30 minutes before deployment, I was confident.
"Last deploy of the week, let me update some libraries."
npm update
Green checkmarks flooded the terminal. Everything looked smooth.
Ran a few local tests, "No issues!" and hit the deploy button.
5:10 PM, Slack notifications cascaded like a waterfall of red.
ERROR: Cannot find module 'createUser'
ERROR: login is not a function
ERROR: Uncaught TypeError at UserService.js:45
Production completely crashed.
CEO called me directly. Cold sweat dripping, I started the rollback process. The question was: "Which version do I roll back to?"
Opened package-lock.json:
{
"dependencies": {
"user-auth-lib": {
"version": "3.0.0" // ← Was 2.8.1 before
}
}
}
Senior developer looked at my screen and sighed.
"Dude, that's a Major version update. 2.x → 3.x is a Breaking Change. Of course it crashed."
Me: "But doesn't npm update only do safe updates?"
Senior: "What's in your package.json?"
{
"dependencies": {
"user-auth-lib": "^2.8.1"
}
}
Senior: "See that caret (^)? That allows Minor updates too. But I think this library developer broke SemVer. They went to 3.0.0 without documenting Breaking Changes."
Spent until 11 PM on emergency fixes. Monday, CEO lectured me for an hour.
That's when I realized: "Version numbers aren't just numbers. They're contracts and promises."
Why I Studied This
After that incident, I needed answers:
- Why did npm update install 3.0.0?
- What's the difference between caret (^) and tilde (~)?
- What exactly is a "Breaking Change"?
- Why do we need lockfiles?
- How do I respond when developers don't follow SemVer?
Most importantly, if I ever build a library: "How do I version it so users can trust it?"
What Confused Me Initially
1. Thought version numbers were arbitrary
"1.0.0 to 1.0.1 or 2.0.0, isn't it just developer preference?"
2. Believed npm update was always safe
"npm will only install safe versions, right?"
3. Didn't understand lockfiles
"Isn't package.json enough?"
4. Pre-release tags were mysterious
"What's v1.0.0-alpha.1? Alpha? Is this official?"
5. Misunderstood caret (^)
"^1.0.0 means all 1.x.x are safe, right?"
Biggest misconception: "Versions are just numbers developers randomly bump."
The Aha Moment: "Versions Are API Contracts"
During rollback, senior drew on the whiteboard:
┌─────────────────────────────────────────────────────┐
│ MAJOR . MINOR . PATCH - PreRelease + Build │
│ 2 . 8 . 1 - beta.3 + 20231115 │
│ │ │ │ │ │ │
│ │ │ │ │ └─ Build metadata
│ │ │ │ └─ Pre-release version
│ │ │ └─ Bug fixes (compatible)
│ │ └─ Features (compatible)
│ └─ Breaking changes (incompatible)
└─────────────────────────────────────────────────────┘
Senior: "This is Semantic Versioning. Each number has clear meaning."
"Bumping MAJOR = Warning that existing code might break"
"Bumping MINOR = New features, existing code safe"
"Bumping PATCH = Only bug fixes, completely safe"
Me: "So user-auth-lib going from 2.8.1 → 3.0.0..."
Senior: "They renamed createUser() to registerUser(), or changed parameter order, or return type... Something in the API changed. That's why they bumped MAJOR."
That moment I understood: "Versions aren't numbers. They're promises. Contracts."
1. MAJOR.MINOR.PATCH Meaning
Format
vMAJOR.MINOR.PATCH
─┬─── ─┬─── ─┬───
│ │ └─ PATCH: Bug fixes (no API changes)
│ └────── MINOR: Features (existing API preserved)
└─────────── MAJOR: API changes (existing code may break)
Real Example: Fake UserAPI Library
v1.0.0 (Initial Release)
// UserAPI v1.0.0
export function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
User code:
import { getUser, createUser } from 'user-api';
getUser(123).then(user => console.log(user));
createUser({ name: 'John' }).then(user => console.log(user));
v1.0.1 (PATCH: Bug Fix)
// UserAPI v1.0.1
// Fixed: Crash when ID is null → Added error handling
export function getUser(id) {
if (!id) {
return Promise.reject(new Error('ID is required')); // ← Fix
}
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
if (!data || !data.name) {
return Promise.reject(new Error('Name is required')); // ← Fix
}
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
Compatibility: Existing code works ✅
Users don't need to change anything.
v1.1.0 (MINOR: New Feature)
// UserAPI v1.1.0
// New feature: Bulk user fetch
export function getUser(id) {
if (!id) {
return Promise.reject(new Error('ID is required'));
}
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
if (!data || !data.name) {
return Promise.reject(new Error('Name is required'));
}
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
// ← New functions! (existing functions unchanged)
export function getUsers(ids) {
return Promise.all(ids.map(id => getUser(id)));
}
export function deleteUser(id) {
return fetch(`/api/users/${id}`, { method: 'DELETE' })
.then(res => res.json());
}
Compatibility: Existing code works ✅
Users can use new functions if they want. Not required.
v2.0.0 (MAJOR: Breaking Change)
// UserAPI v2.0.0
// Breaking Change: Function name changes, async/await
// getUser → fetchUser (renamed!)
export async function fetchUser(id) { // ← Name changed!
if (!id) throw new Error('ID is required');
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// createUser → registerUser (renamed!)
export async function registerUser(data) { // ← Name changed!
if (!data || !data.name) throw new Error('Name is required');
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
});
return res.json();
}
export async function fetchUsers(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
export async function removeUser(id) { // ← deleteUser → removeUser
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
return res.json();
}
Compatibility: Existing code breaks ❌
// Old code (v1.x)
import { getUser, createUser } from 'user-api'; // ← Error! Functions don't exist
getUser(123).then(user => console.log(user)); // ← getUser is not defined
Users must modify code:
// Updated code (v2.x)
import { fetchUser, registerUser } from 'user-api'; // ← Names changed
const user = await fetchUser(123); // ← Changed to async/await
console.log(user);
2. Real-World Dependency Hell and Breaking Changes
Three Incidents I Experienced
Incident 1: Silent MAJOR Update
# package.json (day before deploy)
{
"dependencies": {
"axios": "^0.21.0" // ← Caret present
}
}
# npm install (on new server, deploy day)
npm install
# Result: package-lock.json
{
"axios": "1.0.0" // ← MAJOR jump from 0.x → 1.x!
}
Problem: axios 0.x → 1.x was a Breaking Change.
axios.get()response structure changed- Error handling approach changed
Lesson: v0.x.y is considered unstable, so ^0.21.0 allows 1.0.0.
Incident 2: left-pad Crisis (2016 True Story)
March 2016, an 11-line npm package called left-pad was deleted.
// Entire left-pad package (11 lines)
module.exports = leftpad;
function leftpad(str, len, ch) {
str = String(str);
ch = ch || ' ';
var i = -1;
len = len - str.length;
while (++i < len) {
str = ch + str;
}
return str;
}
But thousands of packages depended on it, including Babel.
npm install
# Error
npm ERR! 404 'left-pad' is not in the npm registry.
Result: Tens of thousands of projects worldwide using React and Babel failed to build simultaneously.
Causes:
- Poor dependency version management
- No lockfiles (package-lock.json didn't exist then)
- No version pinning for critical packages
Lesson: "One tiny dependency can bring down everything."
Incident 3: Unintended PATCH Change
# package.json
{
"dependencies": {
"some-logging-lib": "~2.3.4" // ← Tilde: PATCH only
}
}
# npm update
npm update
# Result
{
"some-logging-lib": "2.3.5" // ← PATCH update
}
After update, production logs exploded:
[INFO] User logged in
[DEBUG] Database query: SELECT * FROM users WHERE id = 123
[DEBUG] Query time: 45ms
[DEBUG] Result: {...}
[INFO] Session created
...
Problem: v2.3.5 developer added "debug logging" as PATCH, but log volume increased 100x, filling disk.
Lesson: "Even PATCH can have side effects. Nothing is 100% safe."
3. Complete Version Range Guide
3.1. Caret ^ (Most Common)
{
"dependencies": {
"react": "^18.2.0"
}
}
Rule: "Leftmost non-zero digit is fixed"
^18.2.0 allows:
✅ 18.2.0
✅ 18.2.1 (PATCH)
✅ 18.3.0 (MINOR)
✅ 18.99.99 (MINOR)
❌ 19.0.0 (MAJOR)
^0.5.2 allows (careful!):
✅ 0.5.2
✅ 0.5.3 (PATCH)
❌ 0.6.0 (Blocks MINOR too!)
❌ 1.0.0 (MAJOR)
^0.0.3 allows (strictest!):
✅ 0.0.3
❌ 0.0.4 (Blocks even PATCH!)
Why designed this way?
- v1.0.0+: Blocking MAJOR is safe
- v0.x.y: Unstable, MINOR might be Breaking
- v0.0.x: Extremely unstable, even PATCH blocked
3.2. Tilde ~ (Conservative)
{
"dependencies": {
"lodash": "~4.17.21"
}
}
Rule: "PATCH updates only"
~4.17.21 allows:
✅ 4.17.21
✅ 4.17.22 (PATCH)
✅ 4.17.99 (PATCH)
❌ 4.18.0 (MINOR)
❌ 5.0.0 (MAJOR)
~1.2 allows (PATCH omitted):
✅ 1.2.0
✅ 1.2.1
❌ 1.3.0
~1 allows (MINOR, PATCH omitted):
✅ 1.0.0
✅ 1.1.0 (MINOR)
✅ 1.99.99 (MINOR)
❌ 2.0.0 (MAJOR)
3.3. Fixed Version (No Symbol)
{
"dependencies": {
"critical-lib": "3.5.2" // ← Exactly 3.5.2 only
}
}
Allows: 3.5.2 only
When to use:
- Legacy projects
- Version changes cause critical bugs
- Banks, healthcare where stability is paramount
3.4. Comparison Operators
{
"dependencies": {
"pkg1": ">=1.2.0", // 1.2.0 or above
"pkg2": ">1.2.0", // Above 1.2.0
"pkg3": "<=2.0.0", // 2.0.0 or below
"pkg4": "<2.0.0", // Below 2.0.0
"pkg5": ">=1.0.0 <2.0.0" // 1.x versions only
}
}
3.5. OR Operator ||
{
"dependencies": {
"pkg": "^1.0.0 || ^2.0.0" // 1.x or 2.x
}
}
Use case: Plugin supports two host versions
{
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0" // React 17 or 18 both OK
}
}
3.6. Hyphen Range -
{
"dependencies": {
"pkg": "1.2.0 - 1.5.0" // 1.2.0 ≤ version ≤ 1.5.0
}
}
Equivalent:
{
"dependencies": {
"pkg": ">=1.2.0 <=1.5.0"
}
}
3.7. X-Ranges
{
"dependencies": {
"pkg1": "1.x", // >=1.0.0 <2.0.0 (1.x versions)
"pkg2": "1.2.x", // >=1.2.0 <1.3.0 (1.2.x versions)
"pkg3": "*" // >=0.0.0 (all versions) ← Dangerous!
}
}
Practical Guide
| Notation | Safety | Update Range | Recommended When |
|---|---|---|---|
^1.2.3 | Medium | MINOR + PATCH | Most cases (npm default) |
~1.2.3 | High | PATCH only | Stability-critical production |
1.2.3 | Highest | No updates | Legacy, banking/healthcare |
>=1.2.3 | Low | All newer versions | Dev tools |
* | Dangerous | All versions | Never use |
^0.x.y | Careful | PATCH only (0.x special) | Unstable packages |
4. Pre-release Versions and Build Metadata
4.1. Pre-release Tags
v1.0.0-alpha.1
v1.0.0-alpha.2
v1.0.0-beta.1
v1.0.0-beta.2
v1.0.0-rc.1 (Release Candidate)
v1.0.0 (Official release)
Meanings:
alpha: Early development, incomplete features, many bugsbeta: Features complete, testing, bugs possiblerc(Release Candidate): Release candidate, nearly complete, final testing
Version Precedence:
1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
4.2. Build Metadata
v1.0.0+20231115
v1.0.0+build.123
v1.0.0-beta.1+exp.sha.5114f85
Meaning: Build time, commit hash, etc. (doesn't affect version comparison)
1.0.0+build.1 == 1.0.0+build.2 (versions are identical)
Real Example: React Version History
# Actual React version examples
18.0.0-alpha-e6be2d531-20211019
18.0.0-beta-24dd07bd2-20211208
18.0.0-rc.0
18.0.0-rc.1
18.0.0 # Official release
18.0.1 # Patch
18.1.0 # Minor
18.2.0
npm install and Pre-releases
# Official versions only
npm install react # → 18.2.0
# Include pre-releases
npm install react@next # → 19.0.0-rc.1 (latest beta)
# Specific pre-release
npm install react@18.0.0-rc.1
# Pin pre-release in package.json
{
"dependencies": {
"react": "18.0.0-rc.1" // ← Exactly this version
}
}
Note: Pre-releases not included in ^ or ~ ranges!
{
"dependencies": {
"react": "^18.0.0" // ← 18.0.0-rc.1 won't install!
}
}
5. Lockfiles: package-lock.json, yarn.lock, pnpm-lock.yaml
5.1. Why Lockfiles?
Problem Scenario
Teammate A's computer (Nov 1, 2023):
npm install
# package.json
{
"dependencies": {
"axios": "^1.5.0"
}
}
# Installed version
axios 1.5.0
Teammate B's computer (Nov 15, 2023):
npm install # Same package.json
# Installed version
axios 1.6.0 # ← Minor update released!
Result: Works on A's computer, bugs on B's computer.
"Works on my machine?" crisis.
Solution: Lockfile
# package-lock.json (shared with team)
{
"name": "my-project",
"version": "1.0.0",
"lockfileVersion": 3,
"dependencies": {
"axios": {
"version": "1.5.0", # ← Exact version locked
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz",
"integrity": "sha512-..." # ← Checksum for tampering detection
}
}
}
Now Teammate B:
npm install # ← Reads package-lock.json
# Installed version
axios 1.5.0 # ← Same version as A!
5.2. Three Guarantees of Lockfiles
- Deterministic Install: Same lockfile → Always same versions
- Integrity Check: Checksum detects package tampering
- Dependency Tree Fixed: Even transitive dependencies locked
5.3. Lockfile Comparison
| Tool | Lockfile | Characteristics |
|---|---|---|
| npm | package-lock.json | JSON format, low readability |
| yarn | yarn.lock | YAML format, high readability |
| pnpm | pnpm-lock.yaml | YAML, uses symlinks |
5.4. Practical Guide
DO:
# Commit lockfile to git
git add package-lock.json
git commit -m "Lock dependencies"
# Use npm ci when lockfile exists (in CI/CD)
npm ci # ← Install exactly per package-lock.json
DON'T:
# Add lockfile to .gitignore (absolutely forbidden!)
echo "package-lock.json" >> .gitignore # ❌
# Install ignoring package-lock.json
npm install --no-package-lock # ❌
# Manually edit lockfile
vim package-lock.json # ❌ (npm manages it)
6. Versioning: npm version Command
6.1. Manual Version Management
# Current version: 1.2.3
# Bump PATCH (1.2.3 → 1.2.4)
npm version patch
# Bump MINOR (1.2.3 → 1.3.0)
npm version minor
# Bump MAJOR (1.2.3 → 2.0.0)
npm version major
Actions:
- Modifies
versionfield in package.json - Auto-creates git commit (
v1.2.4message) - Auto-creates git tag (
v1.2.4)
6.2. Pre-release Versions
# Current version: 1.2.3
# Bump to pre-release (1.2.3 → 1.2.4-0)
npm version prerelease
# Continue pre-release (1.2.4-0 → 1.2.4-1)
npm version prerelease
# Release official version (1.2.4-1 → 1.2.4)
npm version patch
6.3. Specifying Pre-release Tags
# Current version: 1.2.3
# Alpha version (1.2.3 → 1.2.4-alpha.0)
npm version preminor --preid=alpha
# Beta version (1.2.4-alpha.0 → 1.3.0-beta.0)
npm version preminor --preid=beta
# RC version (1.3.0-beta.0 → 1.3.0-rc.0)
npm version prerelease --preid=rc
6.4. Disable Auto Commit
# Don't create git commit/tag
npm version patch --no-git-tag-version
6.5. Practical Workflow
# 1. Develop feature
git checkout -b feature/new-feature
# 2. Development complete, commit
git add .
git commit -m "feat: Add new feature"
# 3. Return to main branch
git checkout main
git merge feature/new-feature
# 4. Bump version (auto commit/tag)
npm version minor # → v1.3.0
# 5. Publish to npm
npm publish
# 6. Push to git (include tags)
git push origin main --tags
7. Conventional Commits and Auto Versioning
7.1. Conventional Commits Rules
# Format
<type>(<scope>): <subject>
# Examples
feat(auth): Add login function # → Bump MINOR
fix(api): Fix null pointer bug # → Bump PATCH
feat(api)!: Change API signature # → Bump MAJOR
# Or
feat(api): Change API signature
BREAKING CHANGE: API signature changed # → Bump MAJOR
Type Categories:
feat: New feature → MINORfix: Bug fix → PATCHdocs: Documentation → No version bumpstyle: Code style → No version bumprefactor: Refactoring → PATCHperf: Performance → PATCHtest: Tests → No version bumpchore: Build config → No version bump
7.2. Automation with standard-version
# Install
npm install --save-dev standard-version
# Add script to package.json
{
"scripts": {
"release": "standard-version"
}
}
# Use
npm run release
Actions:
- Analyze commits since last release
- If
featexists, bump MINOR; if onlyfix, bump PATCH - If
BREAKING CHANGEexists, bump MAJOR - Auto-generate CHANGELOG.md
- Update package.json version
- Git commit & tag
Example CHANGELOG.md:
# Changelog
## [2.1.0] (2023-11-15)
### Features
- **auth**: Add OAuth login ([a3f2c1b](link-to-commit))
- **api**: Support pagination ([8d9e4f7](link-to-commit))
### Bug Fixes
- **api**: Fix null pointer in getUser ([5c6d8a2](link-to-commit))
## [2.0.0] (2023-11-01)
### BREAKING CHANGES
- **api**: Remove deprecated createUser function
Migration: Use registerUser instead
### Features
- **api**: Add registerUser function
7.3. Full Automation with semantic-release
# Install
npm install --save-dev semantic-release
# Configure .releaserc.json
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer", // Analyze commits
"@semantic-release/release-notes-generator", // Generate release notes
"@semantic-release/changelog", // Generate CHANGELOG
"@semantic-release/npm", // Publish to npm
"@semantic-release/git", // Git commit/tag
"@semantic-release/github" // Create GitHub release
]
}
# Run in CI/CD
npx semantic-release
CI/CD Workflow (GitHub Actions):
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm test
# Auto version management & deployment
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Results:
- Push
feat:commit → CI auto-bumps MINOR and publishes to npm - CHANGELOG.md auto-updated
- GitHub Release auto-created
- Git tag auto-created
8. SemVer vs CalVer vs Other Versioning
8.1. Semantic Versioning (SemVer)
v2.3.4 = MAJOR.MINOR.PATCH
Pros:
- Clearly expresses API compatibility
- Easy to automate
- Industry standard
Cons:
- Marketing disadvantage (stays at v1.0.0 long)
- Psychological burden to bump MAJOR
Examples: npm, pip, gems, most libraries
8.2. Calendar Versioning (CalVer)
Ubuntu: YY.MM (e.g., 22.04, 23.10)
PyPI: YYYY.MM.MICRO (e.g., 2023.11.1)
Pros:
- Immediately know release timing
- Suitable for regular releases
- Marketing advantage
Cons:
- No API compatibility info
- Hard to automate
Examples: Ubuntu, Windows (20H2, 21H1), pip itself
8.3. Other Versioning Schemes
Windows
Windows 10 21H2 (2021 second half)
Windows 11 22H2 (2022 second half)
Chrome
Chrome 119.0.6045.123
│ │ │ └─ Patch (bug fixes)
│ │ └─────── Build (auto build number)
│ └────────── Branch (dev branch)
└────────────── Major (feature changes)
Bumps MAJOR every 4-6 weeks (even without API changes).
iOS
iOS 17.1.1
│ │ └─ Patch (urgent bug fixes)
│ └─── Minor (feature additions)
└────── Major (major update, annual release)
8.4. Comparison Table
| Scheme | Format | Pros | Cons | Examples |
|---|---|---|---|---|
| SemVer | MAJOR.MINOR.PATCH | Clear compatibility | Marketing disadvantage | npm, pip, gems |
| CalVer | YY.MM.MICRO | Easy timing | No compatibility info | Ubuntu, pip |
| Chrome-style | MAJOR.BRANCH.BUILD.PATCH | Fast releases | Confusing | Chrome, Edge |
| iOS-style | MAJOR.MINOR.PATCH | Intuitive | Different from SemVer | iOS, macOS |
9. Library Developer Responsibility
9.1. Wrong Examples (Trust Breached)
Mistake 1: Breaking Change in PATCH
v1.5.3 → v1.5.4 (PATCH)
// v1.5.3
function login(username, password) {
return fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
}
// v1.5.4
function login(email, password) { // ← username → email changed!
return fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
}
Result:
- User set
~1.5.3(PATCH only) - Auto-updates to v1.5.4
- All logins fail
- "This library doesn't follow SemVer" → Trust lost
Mistake 2: Breaking Change in MINOR
v2.3.0 → v2.4.0 (MINOR)
// v2.3.0
export function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
// v2.4.0
export async function getUser(id) { // ← Sync → Async change!
const res = await fetch(`/api/users/${id}`);
return res.json();
}
Problem:
// User code (v2.3.0)
getUser(123).then(user => console.log(user)); // ← Works
// After v2.4.0 update
getUser(123).then(user => console.log(user)); // ← Still works, but...
// If user wrote this:
const user = getUser(123); // ← Returns Promise instead of undefined
console.log(user.name); // ← TypeError!
Correct way: Should bump MAJOR (2.3.0 → 3.0.0).
9.2. Right Examples
Method 1: Deprecation Warning
// v2.3.0
export function getUser(id) {
console.warn('getUser() is deprecated. Use fetchUser() instead.');
return fetchUser(id);
}
export function fetchUser(id) { // ← Add new function (MINOR)
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
Timeline:
- v2.3.0: Add
fetchUser(), keepgetUser()(MINOR) - v2.4.0:
getUser()deprecated warning (MINOR) - v3.0.0: Remove
getUser()(MAJOR)
Method 2: Migration Guide
# v3.0.0 Migration Guide
## Breaking Changes
### 1. `getUser()` → `fetchUser()`
**Before (v2.x)**:
\`\`\`javascript
import { getUser } from 'my-lib';
getUser(123).then(user => console.log(user));
\`\`\`
**After (v3.x)**:
\`\`\`javascript
import { fetchUser } from 'my-lib';
const user = await fetchUser(123);
console.log(user);
\`\`\`
**Codemod (auto migration)**:
\`\`\`bash
npx @my-lib/codemod v2-to-v3
\`\`\`
### 2. Login API signature change
**Before**:
\`\`\`javascript
login(username, password)
\`\`\`
**After**:
\`\`\`javascript
login(email, password)
\`\`\`
10. Common Mistakes and Solutions
Mistake 1: Using * or latest
{
"dependencies": {
"some-lib": "*" // ❌ Dangerous!
}
}
Problem: MAJOR updates install automatically.
Solution:
{
"dependencies": {
"some-lib": "^2.3.0" // ✅ Blocks MAJOR
}
}
Mistake 2: devDependencies Too Strict
{
"devDependencies": {
"eslint": "8.56.0", // ← Fixed version
"prettier": "3.1.0"
}
}
Problem: Dev tools update frequently, need manual management.
Solution:
{
"devDependencies": {
"eslint": "^8.56.0", // ✅ Allow Minor updates
"prettier": "^3.1.0"
}
}
Mistake 3: peerDependencies Too Narrow
{
"peerDependencies": {
"react": "18.2.0" // ❌ Only exactly 18.2.0
}
}
Problem: User with React 18.3.0 gets warnings.
Solution:
{
"peerDependencies": {
"react": "^18.0.0" // ✅ Allow all React 18.x
// Or
"react": "^17.0.0 || ^18.0.0" // ✅ Support both 17, 18
}
}
Mistake 4: Adding Lockfile to .gitignore
# .gitignore
node_modules/
package-lock.json # ❌ Absolutely forbidden!
Problem: Different team members install different versions → "Works on my machine?" crisis.
Solution:
# .gitignore
node_modules/
# Never add package-lock.json!
11. Tools and Automation
11.1. npm-check-updates
# Install
npm install -g npm-check-updates
# Check updatable versions
ncu
Checking package.json
[====================] 12/12 100%
axios ^1.5.0 → ^1.6.5 (Minor)
react ^18.2.0 → ^18.2.0 (Up to date)
lodash ^4.17.21 → ^4.17.21 (Up to date)
# Include MAJOR updates
ncu --target latest
react ^18.2.0 → ^19.0.0 (MAJOR! Careful!)
# Auto-update package.json
ncu -u
# Specific package only
ncu axios
11.2. npm outdated
npm outdated
Package Current Wanted Latest Location
axios 1.5.0 1.6.5 1.6.5 my-project
react 18.2.0 18.2.0 19.0.0 my-project
- Current: Currently installed version
- Wanted: Latest within package.json range
- Latest: Latest published to npm
11.3. Dependabot (GitHub)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "my-team"
labels:
- "dependencies"
Actions: Auto-creates PR weekly, shows updatable dependencies.
11.4. Renovate Bot
Stronger alternative to Dependabot:
// renovate.json
{
"extends": ["config:base"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true // Auto-merge Minor/Patch
},
{
"matchUpdateTypes": ["major"],
"labels": ["major-update"],
"reviewers": ["team-lead"] // Major needs review
}
]
}
12. v0.x.y Dangers
Rule
v0.x.y = "Initial development. Anything MAY change at any time."
Meaning: MAJOR of 0 means unstable.
Real Example
React v0.14.0 → v0.15.0 (MINOR update)
// v0.14.0
React.render(<App />, document.body);
// v0.15.0
ReactDOM.render(<App />, document.body); // ← Breaking Change!
Problem: MINOR update but API completely changed!
Caret (^) Behavior Difference
{
"dependencies": {
"stable-lib": "^1.5.0", // Allows all 1.x
"unstable-lib": "^0.5.0" // Only 0.5.x (PATCH only!)
}
}
Reason: v0.x.y MINOR might be Breaking, so only PATCH allowed.
Response Method
{
"dependencies": {
"unstable-lib": "0.5.2" // ← Fixed version
// Or
"unstable-lib": "~0.5.2" // ← PATCH only (explicit)
}
}
13. Summary: SemVer Checklist
Version Bump Decision Tree
Are there changes?
├─ YES
│ ├─ Does existing code break? (Breaking Change)
│ │ ├─ YES → Bump MAJOR (1.0.0 → 2.0.0)
│ │ └─ NO
│ │ ├─ New features?
│ │ │ ├─ YES → Bump MINOR (1.0.0 → 1.1.0)
│ │ │ └─ NO
│ │ │ ├─ Bug fixes?
│ │ │ │ ├─ YES → Bump PATCH (1.0.0 → 1.0.1)
│ │ │ │ └─ NO → No bump (docs, tests only)
└─ NO → No bump
Breaking Change Checklist
Any of these = MAJOR:
- Function/class name changed
- Function parameters added/removed/reordered
- Function return type changed
- Required parameter added
- Default value changed (if behavior changes)
- Error type changed
- Minimum supported version changed (Node.js, Python, etc.)
- Dependency MAJOR update (Breaking propagation)
MINOR Checklist
- New function/class added
- Optional parameter added (with default)
- Existing feature extended (backward compatible)
- Deprecation warning added
PATCH Checklist
- Bug fix
- Performance improvement (no behavior change)
- Internal refactoring (no API change)
- Documentation typo fix
- Type definition fix (TypeScript)
package.json Strategy
{
"dependencies": {
// Frameworks (critical): Fixed or tilde
"react": "18.2.0",
"vue": "~3.3.0",
// Utility libraries: Caret
"lodash": "^4.17.21",
"axios": "^1.6.0",
// v0.x.y (unstable): Fixed
"new-experimental-lib": "0.5.2",
// Plugins: OR range (support multiple host versions)
"eslint-plugin-react": "^7.0.0"
},
"devDependencies": {
// Dev tools: Caret (freely update)
"eslint": "^8.56.0",
"prettier": "^3.1.0",
"jest": "^29.7.0"
},
"peerDependencies": {
// Host library: Wide range
"react": "^17.0.0 || ^18.0.0"
}
}
Final Thoughts: "The Promise Behind Numbers"
When I started learning version management, I thought "It's just numbers, right?"
After blowing up production, I realized.
"Versions aren't numbers. They're trust protocols between developers."
Going from v2.3.4 to v2.3.5 isn't just incrementing. It's a promise: "This update is safe. Your code will still work."
Going from v2.3.4 to v3.0.0 is a warning: "Be careful. You might need to modify code."
Lessons learned:
- MAJOR version: API may change → Update carefully
- MINOR version: New features added → Relatively safe
- PATCH version: Only bugs fixed → Safe (but not 100%)
- Caret (^): Allows MINOR + PATCH → OK for most cases
- Tilde (~): PATCH only → When stability critical
- Fixed version: No updates → Legacy projects
- Lockfile: Version consistency across team → Must commit
If I ever build a library, I'll follow SemVer rigorously.
Because I don't want to ruin someone's Friday night.
"Trust builds slowly, but one Breaking Change destroys it."
If you build libraries, bump version numbers carefully.
Put responsibility into each digit.
That's the minimum courtesy to maintain as a developer.