Definition
- Semantic Versioning (SemVer) -- MAJOR.MINOR.PATCH where MAJOR is backward-incompatible changes, MINOR is backward-compatible new features, PATCH is backward-compatible bug fixes.
- Immutable artifact -- the versioned binary (JAR, Docker image) is built once and promoted through environments, never rebuilt.
- Conventional commits -- commit message format (feat:, fix:, BREAKING CHANGE:) that lets tooling compute the version bump.
- Release pipeline -- CI/CD workflow that builds, tests, versions, publishes, and promotes the artifact through environments.
- Promotion -- moving the same artifact from dev to staging to production, gated by automated checks and human approval.
- Rollback -- re-pointing the deployment at the previous version tag.
Think of how National Bank of India numbers its cheque books. A whole new design that old scanners cannot read is a major change. Adding a new UPI reference field on the same design is a minor feature that old cheques still work alongside. Fixing a typo in the fine print is a patch. The bank does not reprint cheques at each branch -- it prints one official batch, stamps it with a serial, and ships that exact batch everywhere. SemVer is the serial on the software, and the release process is the controlled shipping of that one official batch.
Understanding the Concepts
What is Semantic Versioning?
Semantic Versioning gives three numbers meaning so anyone reading a version can predict the risk of upgrading. MAJOR is incremented for backward-incompatible changes. MINOR is incremented for backward-compatible new features. PATCH is incremented for backward-compatible bug fixes.
Version: 2.4.1
MAJOR = 2 (breaking changes from v1)
MINOR = 4 (four feature additions since v2.0)
PATCH = 1 (one bug fix since v2.4.0)
Pre-release: 2.5.0-rc.1 (release candidate)
Build metadata: 2.4.1+build.1842
package com.bank.payments;
/**
* Transfer Service API
*
* v1.0.0: Initial release -- NEFT transfers only
* v1.1.0: MINOR -- added RTGS support (backward-compatible)
* v1.2.0: MINOR -- added UPI support (backward-compatible)
* v1.2.1: PATCH -- fixed rounding in fee calculation
* v2.0.0: MAJOR -- changed response format (BREAKING)
* v2.1.0: MINOR -- added daily transfer limit
*/
public class TransferService {
public static final String VERSION = "2.1.0";
public String getVersion() {
return VERSION;
}
}
# Health endpoint reports version
curl https://transfer.nbi.com/actuator/info
{"version":"2.1.0","commit":"abc1234"}
Why must the artifact be immutable?
You build the artifact once and promote the same binary through environments. Never rebuild per environment. This means what you tested in staging is bit-for-bit what runs in production. Environment differences come from injected configuration, not from the code.
# Build once
mvn clean package -DskipTests
docker build -t nbi/transfer-service:2.1.0 .
docker push nbi/transfer-service:2.1.0
# Promote the SAME image through environments
kubectl --context=staging set image deployment/transfer-service \
transfer-service=nbi/transfer-service:2.1.0
kubectl --context=production set image deployment/transfer-service \
transfer-service=nbi/transfer-service:2.1.0
# WRONG -- different build for each environment
image: nbi/transfer-service:latest # ambiguous, unreproducible
# CORRECT -- explicit version
image: nbi/transfer-service:2.1.0 # immutable, traceable
How do conventional commits automate version bumps?
Conventional commit messages follow a format that lets tooling compute the correct version bump. A feat: commit forces a MINOR bump. A fix: commit forces a PATCH bump. A BREAKING CHANGE: footer forces a MAJOR bump. The highest-impact commit since the last release wins.
# Commit history since last release (v2.0.0)
fix: correct rounding in UPI fee calculation -> PATCH
feat: add daily transfer limit for SAV accounts -> MINOR
feat: add RTGS batch processing -> MINOR
# Highest impact: MINOR -> next version: 2.1.0
# Tool computes version and generates changelog
npx standard-version
Bumping version from 2.0.0 to 2.1.0
Changelog:
## [2.1.0] - 2024-01-15
### Features
- add daily transfer limit for SAV accounts
- add RTGS batch processing
### Bug Fixes
- correct rounding in UPI fee calculation
How does the release pipeline work?
When a PR merges to main, the pipeline runs tests, static analysis, and security scans. If everything passes, it builds one versioned artifact, tags the commit, pushes the image, and promotes through environments with approval gates.
# .github/workflows/release.yml
name: Release Pipeline
on:
push:
branches: [main]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
java-version: '21'
- run: mvn clean verify
- run: mvn sonar:sonar -Dsonar.qualitygate.wait=true
release:
needs: build-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: version
run: echo "version=$(npx standard-version --dry-run | grep 'to' | awk '{print $NF}')" >> $GITHUB_OUTPUT
- run: |
docker build -t nbi/transfer-service:${{ steps.version.outputs.version }} .
docker push nbi/transfer-service:${{ steps.version.outputs.version }}
- run: |
git tag v${{ steps.version.outputs.version }}
git push origin v${{ steps.version.outputs.version }}
deploy-staging:
needs: release
runs-on: ubuntu-latest
steps:
- run: |
kubectl --context=staging set image deployment/transfer-service \
transfer-service=nbi/transfer-service:${{ needs.release.outputs.version }}
kubectl --context=staging rollout status deployment/transfer-service
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # requires manual approval
steps:
- run: |
kubectl --context=production set image deployment/transfer-service \
transfer-service=nbi/transfer-service:${{ needs.release.outputs.version }}
kubectl --context=production rollout status deployment/transfer-service
How does rollback work?
Because artifacts are immutable and tagged with explicit versions, rollback is re-pointing the deployment at the previous version tag. No rebuilding, no guessing.
# Rollback to previous version
kubectl rollout undo deployment/transfer-service
# OR: explicitly set the previous version
kubectl set image deployment/transfer-service \
transfer-service=nbi/transfer-service:2.0.0
# Verify
kubectl rollout status deployment/transfer-service
curl https://transfer.nbi.com/actuator/info
{"version":"2.0.0","commit":"def5678"}
How does the version tie the whole toolchain together?
The version is the join key across your entire toolchain. A running pod reports version 2.1.0 on its health endpoint. That maps to a git tag, which maps to a commit, which maps to a PR, which maps to a changelog entry and build log.
Running pod: version 2.1.0
↓
Git tag: v2.1.0
↓
Commit: abc1234
↓
Pull Request: #42 (feat: add daily transfer limit)
↓
Changelog: [2.1.0] - 2024-01-15
↓
Build log: CI run #187 (all tests passed, sonar passed)
package com.bank.payments;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class VersionInfoContributor implements InfoContributor {
@Value("${app.version:unknown}")
private String version;
@Value("${app.commit:unknown}")
private String commit;
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("version", version)
.withDetail("commit", commit);
}
}
GET /actuator/info
{"version":"2.1.0","commit":"abc1234"}
What are common mistakes?
The most common mistakes are deploying with the 'latest' tag, rebuilding per environment instead of promoting one artifact, manually deciding version bumps instead of using conventional commits, and not tying the version back to a commit.
# WRONG -- latest tag
image: nbi/transfer-service:latest
# Which version is this? When was it built? From which commit?
# Rollback is impossible to reason about.
# CORRECT -- explicit version
image: nbi/transfer-service:2.1.0
# Traceable to commit abc1234, PR #42, changelog entry.
# Rollback: just switch to 2.0.0.
What should a senior engineer know?
A senior engineer knows that the version number alone communicates upgrade risk. They enforce that released versions are immutable and never overwritten. They use conventional commits for deterministic version bumps and automated changelogs. They know that the version is the thread that ties incident response together -- when an alert fires, the first question is 'what version is running?' and from there you trace to the exact commit, PR, and build.
Visual Overview
Semantic Versioning
───────────────────
MAJOR . MINOR . PATCH
2 . 4 . 1
│ │ │
│ │ └── Bug fix (backward-compatible)
│ └────────── New feature (backward-compatible)
└────────────────── Breaking change (incompatible)
Release Pipeline:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Merge │───▶│ Build + │───▶│ Tag + │───▶│ Promote │
│ to main │ │ Test + │ │ Push │ │ through │
│ │ │ Analyze │ │ Image │ │ envs │
└──────────┘ └──────────┘ └──────────┘ └──────┬───┘
│
┌────────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ Dev │ │ Staging │ │Production│
│ (auto) │ │ (auto) │ │(approval)│
└────────┘ └──────────┘ └──────────┘
SAME image
Version as Join Key:
Pod: 2.1.0 → Tag: v2.1.0 → Commit: abc1234 → PR: #42 → Changelog → Build Log
Key Takeaway
Semantic Versioning uses MAJOR.MINOR.PATCH where each position communicates upgrade risk. MAJOR is a breaking change that requires migration notes. MINOR is a safe new feature. PATCH is a low-risk bug fix. Conventional commit messages let tooling compute the correct bump automatically, so version decisions are deterministic rather than debated.
The release process builds one immutable artifact -- a Docker image tagged with the version -- and promotes that same binary through dev, staging, and production. Environment differences come from injected configuration, not from rebuilding. Never deploy by the 'latest' tag because it makes rollback impossible to reason about.
Rollback is trivial when artifacts are immutable and explicitly versioned: re-point the deployment at the previous version tag. The version is the join key that ties a running pod back to a commit, a PR, a changelog entry, and a build log, which is what makes incident response fast and auditable.
Remember one sentence: build one versioned artifact, promote it through environments, never rebuild per environment, and the version number ties every running pod back to its exact commit for traceable rollback and auditing.
---