Home DevOps Semantic Versioning Explained: MAJOR, MINOR, PATCH and Why It Matters in CI/CD

Semantic Versioning Explained: MAJOR, MINOR, PATCH and Why It Matters in CI/CD

In Plain English 🔥
Imagine your favourite video game releases an update. A tiny bug fix gets called '1.0.1', a new level pack becomes '1.1.0', and a complete engine rebuild that breaks your old save files is '2.0.0'. That numbering system isn't random — it's a silent contract between the developers and the players, saying exactly how much things have changed. Semantic versioning is that same idea applied to software libraries and APIs, so that any developer in the world can look at a version number and instantly know whether upgrading is safe.
⚡ Quick Answer
Imagine your favourite video game releases an update. A tiny bug fix gets called '1.0.1', a new level pack becomes '1.1.0', and a complete engine rebuild that breaks your old save files is '2.0.0'. That numbering system isn't random — it's a silent contract between the developers and the players, saying exactly how much things have changed. Semantic versioning is that same idea applied to software libraries and APIs, so that any developer in the world can look at a version number and instantly know whether upgrading is safe.

Every time you run 'npm install react' or 'pip install requests', some machine somewhere picks a specific version of that package to give you. If that choice is wrong — say it grabs a newer version that changed how a function works — your app breaks at 2 AM on a Friday. Version numbers are the guardrails that prevent this chaos, and semantic versioning is the agreed-upon language that makes those numbers actually mean something across the entire software industry.

Before semantic versioning became the standard, version numbers were a free-for-all. One team might call their releases 1, 2, 3. Another might use dates like 20240101. A third might just pick numbers that felt right. The result was that you could never know whether upgrading from version 4 to version 5 of a library would silently break your code or work perfectly. Developers wasted enormous amounts of time reading full changelogs just to decide whether an upgrade was safe. Semantic versioning — often abbreviated as SemVer — solved this by assigning a strict, shared meaning to each part of a version number.

By the end of this article you'll know exactly what each number in a version like '3.14.2' means, when to bump which number in your own projects, how automated CI/CD pipelines use SemVer to decide whether to deploy or block a release, and the most common mistakes teams make that cause production incidents. You won't just understand the rules — you'll understand the reasoning behind them.

The Three-Number System: What MAJOR.MINOR.PATCH Actually Means

A semantic version is always written as three numbers separated by dots: MAJOR.MINOR.PATCH. Think of it like a home address with three levels of specificity — country, city, street. Each level tells you something different about how much has changed.

PATCH (the last number) is the smallest change. It means 'we fixed a bug, nothing else moved.' If your code works with version 2.4.5, it will work identically with 2.4.9. Safe to upgrade, no reading required.

MINOR (the middle number) means 'we added something new, but we didn't break anything old.' Your existing code still works exactly as before, but there are new features you can optionally use. Going from 2.4.5 to 2.7.0 is safe.

MAJOR (the first number) is the alarm bell. It means 'we changed something fundamental — old code may break.' Going from version 2.x.x to 3.0.0 requires you to read the migration guide, update your code, and test carefully. This is called a 'breaking change'.

The rule that ties it all together is called backward compatibility. MINOR and PATCH bumps must always be backward compatible — meaning they can never remove or rename existing features. Only a MAJOR bump is allowed to break that contract.

version-bump-examples.yaml · YAML
123456789101112131415161718192021222324252627282930313233343536373839404142
# This file illustrates which version number to bump
# in common real-world scenarios.

# Imagine your library is currently at version 2.4.5
current_version: "2.4.5"

scenarios:

  # SCENARIO 1: You found a null-pointer bug in the login module and fixed it.
  # Nothing was added or removed. Behavior is identical except the bug is gone.
  # ACTION: Bump the PATCH number. Reset nothing else.
  bug_fix:
    change_description: "Fixed null pointer exception in AuthService.validateToken()"
    old_version: "2.4.5"
    new_version: "2.4.6"   # PATCH went 5 -> 6. MAJOR and MINOR stay the same.
    is_breaking_change: false
    added_new_feature: false

  # SCENARIO 2: You added a new optional method getUserProfile() to the UserService.
  # Existing code that doesn't call getUserProfile() is completely unaffected.
  # ACTION: Bump the MINOR number. Reset PATCH to 0 (fresh slate for this feature).
  new_feature:
    change_description: "Added UserService.getUserProfile() method"
    old_version: "2.4.5"
    new_version: "2.5.0"   # MINOR went 4 -> 5. PATCH resets to 0.
    is_breaking_change: false
    added_new_feature: true

  # SCENARIO 3: You renamed the core authenticate() method to login() AND
  # changed the return type. Any code calling authenticate() will now crash.
  # ACTION: Bump the MAJOR number. Reset MINOR and PATCH to 0.
  breaking_change:
    change_description: "Renamed authenticate() to login(), changed return type from bool to AuthToken"
    old_version: "2.4.5"
    new_version: "3.0.0"   # MAJOR went 2 -> 3. MINOR and PATCH both reset to 0.
    is_breaking_change: true
    added_new_feature: false

# THE RESET RULE (critical to remember):
# When you bump MAJOR -> reset MINOR and PATCH to 0
# When you bump MINOR -> reset PATCH to 0, leave MAJOR alone
# When you bump PATCH -> leave MAJOR and MINOR alone
▶ Output
# No runnable output for YAML config — this is a reference document.
# The version transitions shown are:
# Bug fix: 2.4.5 -> 2.4.6
# New feature: 2.4.5 -> 2.5.0
# Breaking: 2.4.5 -> 3.0.0
⚠️
The Reset Rule:Whenever you bump a number, every number to its right resets to zero. Bump MINOR and PATCH resets. Bump MAJOR and both MINOR and PATCH reset. This is mandatory — version 2.5.3 becoming 3.1.0 after a breaking change is wrong because MINOR should restart at 0.

Version 0.x.x — The Special Case Every Beginner Misses

There's a fourth rule that catches almost everyone out: when MAJOR is zero, the normal rules are suspended.

Version 0.x.x means 'this is early development — anything can change at any time without warning.' It's the wild west phase of a project. A MINOR bump during 0.x.x is allowed to be a breaking change, because the library hasn't made a public stability promise yet. The moment you release 1.0.0, you're telling the world: 'this API is stable, and I will honour the SemVer contract going forward.'

This matters enormously in CI/CD pipelines. If your automated dependency updater sees a package jump from 0.8.2 to 0.9.0, it cannot assume that's safe just because only the MINOR number changed. A cautious pipeline should flag 0.x.x upgrades for manual review.

Pre-release labels are another related concept. You can append a hyphen and a label to signal that a version isn't production-ready: '1.0.0-alpha.1', '1.0.0-beta.3', '1.0.0-rc.2'. These come before the official release in terms of precedence — 1.0.0-alpha.1 is older than 1.0.0. A CI/CD pipeline should never automatically deploy a pre-release version to production.

Build metadata can also be appended with a plus sign: '1.0.0+build.20240115'. This is purely informational — two versions that differ only in build metadata are considered equal for the purpose of upgrade decisions.

semver-version-ordering.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243
#!/bin/bash
# This script demonstrates how semantic versions are ordered
# and how to check them in a CI/CD shell script.

# --- PART 1: Understanding version precedence ---
# Versions ordered from OLDEST to NEWEST (left to right):
#
# 1.0.0-alpha.1
# 1.0.0-alpha.2
# 1.0.0-beta.1
# 1.0.0-rc.1
# 1.0.0          <-- official release
# 1.0.1
# 1.1.0
# 2.0.0

echo "=== Version Ordering Demo ==="
echo "Pre-release tags come BEFORE the official release:"
echo "  1.0.0-alpha.1  <  1.0.0-beta.1  <  1.0.0-rc.1  <  1.0.0"
echo ""

# --- PART 2: A simple bash function to check if a version is a pre-release ---
# In CI/CD you often need to block pre-release versions from auto-deploying.

is_prerelease_version() {
    local version_string="$1"  # e.g. "1.0.0-beta.2" or "2.3.1"

    # If the version string contains a hyphen, it's a pre-release.
    # We use bash's built-in string matching for this check.
    if [[ "$version_string" == *"-"* ]]; then
        echo "BLOCKED: '$version_string' is a pre-release — not safe for production auto-deploy."
        return 1  # non-zero exit code signals 'yes, it is a pre-release'
    else
        echo "APPROVED: '$version_string' is a stable release — safe to proceed."
        return 0  # zero exit code signals 'not a pre-release'
    fi
}

# --- Testing the function with different version strings ---
is_prerelease_version "2.3.1"          # Stable release
is_prerelease_version "1.0.0-alpha.1"  # Alpha — block it
is_prerelease_version "3.0.0-rc.2"    # Release candidate — block it
is_prerelease_version "1.5.0"          # Stable minor release
▶ Output
=== Version Ordering Demo ===
Pre-release tags come BEFORE the official release:
1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0

APPROVED: '2.3.1' is a stable release — safe to proceed.
BLOCKED: '1.0.0-alpha.1' is a pre-release — not safe for production auto-deploy.
BLOCKED: '3.0.0-rc.2' is a pre-release — not safe for production auto-deploy.
APPROVED: '1.5.0' is a stable release — safe to proceed.
⚠️
Watch Out:Never treat a 0.x.x MINOR bump the same as a 1.x.x MINOR bump. The 0.x.x phase has no backward compatibility guarantee. If your CI/CD pipeline automatically applies 'safe' MINOR upgrades, make sure it excludes any package still on a 0.x.x version — or you'll wake up to a broken build.

SemVer in CI/CD Pipelines: How Automated Tools Use These Numbers

Understanding the theory is one thing — seeing how CI/CD pipelines act on version numbers is where it becomes powerful in practice.

Package managers like npm, pip, and Cargo use SemVer range specifiers in config files. These are shorthand rules that say 'give me any version that satisfies these constraints.' The caret symbol (^) is the most common: '^2.4.5' means 'give me any version that's at least 2.4.5, but never go to 3.0.0.' The tilde (~) is stricter: '~2.4.5' means 'stay within PATCH updates only — never change the MINOR version.'

Automated tools like Dependabot, Renovate, and GitHub Actions use these rules to decide whether to auto-merge a dependency update or open a pull request for human review. A PATCH bump gets auto-merged. A MINOR bump gets a PR. A MAJOR bump gets a PR with a big warning label.

In your own CI pipeline, you can use SemVer to trigger different deployment strategies. A PATCH release might go straight to production. A MINOR release might go to staging first. A MAJOR release might require a manual approval gate, a feature flag rollout, and a rollback plan.

Conventional Commits is the system that feeds into this automatically. When developers write commit messages using a standard format ('fix:', 'feat:', 'feat!:'), tools like semantic-release can scan those messages, determine the correct version bump, tag the release, and publish to a package registry — all without a human deciding the version number.

github-actions-semver-pipeline.yaml · YAML
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
# A GitHub Actions CI/CD workflow that:
# 1. Runs on every push to main
# 2. Reads the new version from package.json
# 3. Decides the deployment strategy based on the version bump type
# 4. Blocks pre-release versions from reaching production

name: SemVer-Aware Deployment Pipeline

on:
  push:
    branches:
      - main  # Only triggers when code is merged to the main branch

jobs:
  determine_deployment_strategy:
    name: Analyse Version and Choose Deploy Path
    runs-on: ubuntu-latest

    steps:
      - name: Check out the repository code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Fetch full git history so we can compare tags

      - name: Read the current version from package.json
        id: read_version
        run: |
          # Extract the version field from package.json using jq (JSON parser)
          CURRENT_VERSION=$(jq -r '.version' package.json)
          echo "Current version: $CURRENT_VERSION"

          # Store the version so later steps can use it
          echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT

      - name: Block pre-release versions from production
        run: |
          VERSION="${{ steps.read_version.outputs.current_version }}"

          # If the version contains a hyphen, it's a pre-release (e.g. 1.0.0-beta.1)
          if [[ "$VERSION" == *"-"* ]]; then
            echo "ERROR: Pre-release version '$VERSION' cannot be deployed to production."
            echo "Merge a stable release first."
            exit 1  # Fail the pipeline — this blocks the deployment entirely
          fi

          echo "Version '$VERSION' is stable. Proceeding with deployment."

      - name: Extract the MAJOR version number
        id: parse_version
        run: |
          VERSION="${{ steps.read_version.outputs.current_version }}"

          # Split on dots and grab the first segment (MAJOR number)
          MAJOR_VERSION=$(echo "$VERSION" | cut -d'.' -f1)
          echo "Major version: $MAJOR_VERSION"
          echo "major_version=$MAJOR_VERSION" >> $GITHUB_OUTPUT

      - name: Deploy PATCH or MINOR — straight to production
        # This step only runs if the major version hasn't changed from the last release
        # In a real pipeline you'd compare against the previous git tag
        run: |
          echo "Non-breaking change detected."
          echo "Running automated tests, then deploying directly to production..."
          # In a real workflow: ./scripts/deploy-to-production.sh

      - name: Notify team of successful deployment
        run: |
          VERSION="${{ steps.read_version.outputs.current_version }}"
          echo "Deployment complete. Released version $VERSION to production."
          # In a real workflow: post to Slack, PagerDuty, etc.
▶ Output
# When a stable PATCH version (e.g. 2.4.6) is pushed:
Current version: 2.4.6
Version '2.4.6' is stable. Proceeding with deployment.
Major version: 2
Non-breaking change detected.
Running automated tests, then deploying directly to production...
Deployment complete. Released version 2.4.6 to production.

# When a pre-release version (e.g. 2.5.0-beta.1) is pushed:
Current version: 2.5.0-beta.1
ERROR: Pre-release version '2.5.0-beta.1' cannot be deployed to production.
Merge a stable release first.
Error: Process completed with exit code 1.
🔥
Interview Gold:Interviewers love asking how SemVer connects to CI/CD automation. The answer they want: SemVer gives automated tools a machine-readable signal about risk. A PATCH bump means auto-deploy is safe. A MAJOR bump is a human decision gate. Tools like Dependabot, semantic-release, and Renovate are all built on this assumption.

Common Mistakes That Break Teams and Exactly How to Fix Them

Even teams that know the SemVer rules consistently make a handful of mistakes that cause production incidents or dependency hell. Here are the ones worth ingraining as habits.

The first category is wrong-direction bumps — bumping PATCH when you should have bumped MINOR, or worse, bumping MINOR when you should have bumped MAJOR. This is the most dangerous mistake because it silently violates the backward compatibility promise. A downstream team upgrades what looks like a safe PATCH fix and their code breaks.

The second category is forgetting to reset. Releasing version 2.5.7 after a MAJOR breaking change instead of 3.0.0 is a hard-to-notice mistake because 2.5.7 looks reasonable. Always apply the reset rule: MAJOR bump resets MINOR and PATCH to zero.

The third is shipping unstable code as a stable version. Releasing 1.0.0 when the API is still changing weekly sends the wrong signal. Stay on 0.x.x until you're genuinely ready to make the backward-compatibility promise.

Using Conventional Commits and a tool like semantic-release eliminates all three categories by automating the decision. The commit message format determines the bump type, and the tool handles the reset math and publishing. Once you set it up, version numbers become a pipeline output — not a human decision where mistakes happen.

conventional-commits-examples.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
#!/bin/bash
# Conventional Commits format: <type>(<optional scope>): <short description>
#
# Tools like semantic-release scan these commit messages and
# automatically determine which version number to bump.
#
# FORMAT RULES:
# fix:   -> bumps PATCH  (2.4.5 -> 2.4.6)
# feat:  -> bumps MINOR  (2.4.5 -> 2.5.0)
# feat!: or a BREAKING CHANGE footer -> bumps MAJOR (2.4.5 -> 3.0.0)

echo "=== CORRECT Conventional Commit examples ==="
echo ""

# PATCH bump: a bug fix
git commit -m "fix(auth): resolve null pointer in token validation"
# Result: 2.4.5 -> 2.4.6

# PATCH bump: a documentation-only change (no version bump at all)
git commit -m "docs(readme): update installation instructions"
# Result: no version change

# MINOR bump: a new backwards-compatible feature
git commit -m "feat(users): add getUserProfile endpoint to UserService"
# Result: 2.4.5 -> 2.5.0

# MAJOR bump: exclamation mark after the type signals a breaking change
git commit -m "feat!(auth): rename authenticate() to login(), return AuthToken instead of bool"
# Result: 2.4.5 -> 3.0.0

# MAJOR bump alternative: use a BREAKING CHANGE footer in a multi-line commit
git commit -m "feat(auth): redesign authentication flow

BREAKING CHANGE: The authenticate() method has been removed.
Callers must migrate to login() which returns an AuthToken object."
# Result: 2.4.5 -> 3.0.0

echo "=== WRONG commits that would cause version confusion ==="

# WRONG: Using 'fix' for something that actually adds a feature
git commit -m "fix(users): add admin role support"  # Should be feat!

# WRONG: Using 'feat' for a breaking rename
git commit -m "feat(auth): rename authenticate to login"  # Should be feat! (MAJOR)

echo "Tip: if you're unsure between fix and feat, ask:"
echo "'Did I add something new?' -> feat"
echo "'Did I only repair existing behaviour?' -> fix"
▶ Output
=== CORRECT Conventional Commit examples ===

[main 3a1c4f2] fix(auth): resolve null pointer in token validation
-> semantic-release will bump PATCH: 2.4.5 -> 2.4.6

[main 9b2e1a0] docs(readme): update installation instructions
-> no version bump (docs changes are excluded by default)

[main 7d8f3c1] feat(users): add getUserProfile endpoint to UserService
-> semantic-release will bump MINOR: 2.4.5 -> 2.5.0

[main 4e9a2b7] feat!(auth): rename authenticate() to login()
-> semantic-release will bump MAJOR: 2.4.5 -> 3.0.0

=== WRONG commits that would cause version confusion ===
-> 'fix' used for a new feature — pipeline will only do PATCH bump
but consumers get new behaviour. Backward compat confusion!
-> 'feat' used for a breaking change — pipeline does MINOR bump
but code breaks for existing users. Production incident risk!

Tip: if you're unsure between fix and feat, ask:
'Did I add something new?' -> feat
'Did I only repair existing behaviour?' -> fix
⚠️
Watch Out:The single most common SemVer mistake in teams is labelling a breaking change as 'feat' instead of 'feat!'. The pipeline happily does a MINOR bump, existing users upgrade expecting safety, and their code breaks. Make 'is this breaking?' a required question in your PR template — it costs nothing and prevents incidents.
AspectMAJOR (x.0.0)MINOR (1.x.0)PATCH (1.0.x)
What changedBreaking API change — old code may crashNew feature added — old code still worksBug fixed — behaviour unchanged
Backward compatible?No — migration requiredYes — safe to upgradeYes — safe to upgrade
MINOR resets to 0?Yes — alwaysNo change neededNo change needed
PATCH resets to 0?Yes — alwaysYes — always after MINOR bumpNo — just increment
CI/CD auto-deploy safe?No — requires human approval gateUsually yes, after staging testsYes — typically auto-deployed
Real example2.4.5 → 3.0.0 (renamed core method)2.4.5 → 2.5.0 (new optional endpoint)2.4.5 → 2.4.6 (fixed login null pointer)
npm caret (^) allows it?No — caret pins the MAJOR versionYes — caret allows MINOR bumpsYes — caret allows PATCH bumps
Risk levelHigh — read migration guide firstLow — review changelogMinimal — nearly always safe

🎯 Key Takeaways

  • MAJOR.MINOR.PATCH is a three-part contract: PATCH = bug fixed, MINOR = feature added safely, MAJOR = something broke — read the migration guide before upgrading.
  • The reset rule is non-negotiable: bump MAJOR and both MINOR and PATCH return to zero; bump MINOR and PATCH returns to zero. Skip this and your version history becomes untrustworthy.
  • Version 0.x.x is a special wild-west phase with no backward-compatibility guarantee — MINOR bumps can be breaking. The promise doesn't start until 1.0.0.
  • Conventional Commits ('fix:', 'feat:', 'feat!:') let CI/CD tools like semantic-release determine the correct version bump automatically, eliminating human error from what is otherwise a surprisingly easy decision to get wrong under deadline pressure.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using 'fix' commits for new features — Symptom: semantic-release only bumps PATCH (e.g. 2.4.5 → 2.4.6) but you've added new behaviour, causing consumers to miss the feature or worse, get unexpected behaviour under what looks like a safe patch — Fix: ask one question before every commit: 'Am I adding something new that didn't exist before?' If yes, it's 'feat:', not 'fix:'. Add this as a checklist item in your PR template.
  • Mistake 2: Forgetting to reset lower numbers after a MAJOR bump — Symptom: you release '3.2.7' instead of '3.0.0' after a breaking change, which makes the version history confusing and breaks tooling that expects post-major resets — Fix: remember the reset rule as a rhyme: 'Bump MAJOR, wipe the rest. Bump MINOR, wipe the PATCH.' If you use semantic-release, it handles this automatically.
  • Mistake 3: Publishing 1.0.0 too early before the API is stable — Symptom: you're still renaming core methods weekly but you've already released 1.0.0, so every rename forces a MAJOR bump, and after a month you're on version 7.0.0 with no real breaking changes from a user perspective — Fix: stay on 0.x.x until you've used the library in at least one real project and are confident the API design won't fundamentally change. The 0.x.x phase exists precisely for this exploration period.

Interview Questions on This Topic

  • QYou're reviewing a pull request and the developer bumped the version from 2.3.1 to 2.4.0. Looking at the diff, they removed a public method from an existing class. What's wrong, and what should the version be?
  • QWhat's the difference between the caret (^) and tilde (~) version specifiers in a package.json, and which SemVer rule does each one encode?
  • QYour CI/CD pipeline is using Dependabot to auto-merge dependency updates. A junior engineer asks whether it's safe to auto-merge all updates from a package that's still on version 0.8.x. What do you tell them, and why?

Frequently Asked Questions

What is semantic versioning in simple terms?

Semantic versioning is a numbering system for software releases that uses three numbers — MAJOR.MINOR.PATCH — where each number carries a specific promise. PATCH means only a bug was fixed, MINOR means a new feature was added without breaking anything old, and MAJOR means something changed that might break existing code. It's a shared language so developers can instantly judge how risky an upgrade is just from the version number.

When should I increment the major version number?

Increment the MAJOR version any time you make a change that breaks backward compatibility — meaning existing code that works with the old version would fail or behave differently with the new version. Common examples include renaming or removing a public method, changing a function's parameter types, or restructuring how configuration files must be written. If someone upgrading your package would need to change their own code, that's a MAJOR bump.

Why does 1.0.0 matter so much — can't I just start at any version?

You can technically start anywhere, but 1.0.0 carries a specific meaning in SemVer: it signals that your public API is stable and you're committing to the backward-compatibility rules going forward. Anything below 1.0.0 (i.e. 0.x.x) is considered pre-stable, and MINOR bumps in that range are allowed to be breaking changes. Releasing 1.0.0 too early creates an implicit promise you may not be ready to keep, forcing frequent MAJOR bumps that signal instability to users.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousAlerting and On-call Best PracticesNext →Docker Networking Deep Dive
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged