Git Tagging & Semantic Versioning Guide
Learn how to implement consistent, automated versioning for your projects using semantic versioning, conventional commits, and git tags.
What is Semantic Versioning?
Semantic Versioning (SemVer) is a versioning scheme that uses a three-part number: MAJOR.MINOR.PATCH
Version Format: MAJOR.MINOR.PATCH
v2.3.1
│ │ └─ PATCH: Bug fixes (backward compatible)
│ └─── MINOR: New features (backward compatible)
└───── MAJOR: Breaking changes (not backward compatible)
Version Increment Rules
| Change Type | Increment | Example |
|---|---|---|
| Breaking changes | MAJOR | v1.5.3 → v2.0.0 |
| New features (backward compatible) | MINOR | v1.5.3 → v1.6.0 |
| Bug fixes (backward compatible) | PATCH | v1.5.3 → v1.5.4 |
Understanding Version Numbers
MAJOR Version (Breaking Changes)
Increment when you make incompatible API changes that will break existing implementations.
Examples:
- Removing a public API endpoint
- Changing function signatures (adding required parameters)
- Removing support for a major dependency
- Renaming classes or modules that users import
// v1.x.x
function login(username: string): User;
// v2.0.0 - BREAKING CHANGE
function login(username: string, password: string): Promise<User>;
Real-world scenarios:
v1.5.3→v2.0.0: Migrated from REST API to GraphQLv2.8.1→v3.0.0: Removed deprecated authentication methodsv3.2.0→v4.0.0: Changed database schema, requires migration
MINOR Version (New Features)
Increment when you add new functionality in a backward-compatible manner.
Examples:
- Adding new API endpoints
- Adding optional parameters to functions
- Introducing new classes or modules
- Adding new configuration options
// v1.5.x
interface UserConfig {
theme: "light" | "dark";
}
// v1.6.0 - New feature (backward compatible)
interface UserConfig {
theme: "light" | "dark";
language?: string; // Optional - won't break existing code
}
Real-world scenarios:
v1.5.3→v1.6.0: Added export to PDF featurev2.3.0→v2.4.0: Added OAuth2 authentication alongside existing authv1.8.2→v1.9.0: Introduced real-time notifications
PATCH Version (Bug Fixes)
Increment when you make backward-compatible bug fixes.
Examples:
- Fixing incorrect calculations
- Resolving memory leaks
- Correcting typos in error messages
- Fixing edge cases that caused crashes
// v1.5.3 - Bug: crashes on null input
function formatName(name: string): string {
return name.toUpperCase(); // Crashes if name is null
}
// v1.5.4 - Fixed null handling
function formatName(name: string): string {
return (name || "").toUpperCase(); // Safe null handling
}
Real-world scenarios:
v1.5.3→v1.5.4: Fixed null pointer exception in user profilev2.3.0→v2.3.1: Corrected timezone calculation bugv1.8.9→v1.8.10: Fixed memory leak in background workers
Pre-release Versions
Use pre-release identifiers for testing before stable releases:
v1.0.0-alpha.1 → Early testing (not feature complete)
v1.0.0-beta.1 → Feature complete, testing for bugs
v1.0.0-rc.1 → Release candidate (final testing)
v1.0.0 → Stable release
Development versions:
v0.x.xindicates pre-1.0 (unstable API, breaking changes allowed in MINOR)v1.0.0is your first stable, production-ready release
Conventional Commits
To automate semantic versioning, use Conventional Commits to structure commit messages.
Commit Message Format
<type>[optional scope]: <description>
[optional body]
[optional footer]
Commit Types
| Type | Description | Version Impact |
|---|---|---|
feat | New feature | MINOR bump |
fix | Bug fix | PATCH bump |
feat! or fix! | Breaking change | MAJOR bump |
docs | Documentation only | No bump |
style | Code formatting | No bump |
refactor | Code restructuring | No bump |
perf | Performance improvement | PATCH bump |
test | Adding tests | No bump |
chore | Maintenance tasks | No bump |
ci | CI/CD changes | No bump |
build | Build system changes | No bump |
Examples
Feature (MINOR Bump)
git commit -m "feat(auth): add OAuth2 authentication
Implemented OAuth2 flow with GitHub and Google providers.
Includes token refresh and scope management.
Closes #123"
Result: v1.5.3 → v1.6.0
Bug Fix (PATCH Bump)
git commit -m "fix(api): handle null values in user response
Previously crashed when user.name was null.
Now returns empty string as fallback."
Result: v1.6.0 → v1.6.1
Breaking Change (MAJOR Bump)
git commit -m "feat!: refactor authentication module
BREAKING CHANGE: login() now returns Promise<User> instead of User.
All authentication methods now require async/await.
Migration guide: https://docs.example.com/migration/v2"
Result: v1.6.1 → v2.0.0
Multiple Commits
When analyzing multiple commits, the highest priority change determines the bump:
# Since last tag v1.5.3:
git commit -m "docs: update API documentation"
git commit -m "fix(ui): correct button alignment"
git commit -m "feat(dashboard): add export to CSV"
git commit -m "test: add integration tests"
Result: v1.5.3 → v1.6.0 (MINOR wins because of feat)
Creating Git Tags
Annotated Tags (Recommended)
Always use annotated tags for releases. They include metadata and are stored as full objects in Git.
# Create annotated tag
git tag -a v1.2.3 -m "Release v1.2.3: Add user profile management"
# Push tag to remote
git push origin v1.2.3
Tag Message Format
Release v{version}: {brief summary}
{Optional: Key features or fixes}
{Optional: Breaking changes warning}
Example:
git tag -a v2.0.0 -m "Release v2.0.0: GraphQL API
- Migrated from REST to GraphQL
- Improved query performance
- BREAKING: All REST endpoints removed"
Lightweight Tags (Not Recommended)
Lightweight tags are just pointers to commits without metadata:
# ❌ Avoid for releases
git tag v1.2.3
# ✅ Use annotated instead
git tag -a v1.2.3 -m "Release v1.2.3"
Manual Tagging Process
Step 1: Review Changes
# Checkout main branch
git checkout main
git pull origin main
# View commits since last tag
git log $(git describe --tags --abbrev=0)..HEAD --oneline
Step 2: Determine Version Bump
Analyze commit messages:
- Any
feat!orBREAKING CHANGE:? → MAJOR - Any
feat:? → MINOR - Only
fix:,perf:? → PATCH - Only
docs:,chore:,test:? → No bump
Step 3: Create Tag
# Determine new version based on step 2
# Current: v1.5.3
# Example: MINOR bump
git tag -a v1.6.0 -m "Release v1.6.0: Add dashboard widgets"
Step 4: Push Tag
# Push single tag
git push origin v1.6.0
# Or push all tags
git push --tags
Step 5: Verify
- Check GitHub Releases or Tags section
- Tag should appear with full annotation
Automated Tagging
Option 1: VS Code Extension
Install: Conventional Commits Extension
Features:
- Commit message templates
- Real-time validation
- Type and scope suggestions
Usage:
- Stage your changes
- Open Source Control panel
- Click "Conventional Commits" icon
- Select commit type, scope, and description
- Extension formats message automatically
Option 2: Cursor Rules (AI-Assisted)
Add to your .cursor/rules:
## Commit Message Format
Always structure commits as:
- `feat(scope): description` for new features
- `fix(scope): description` for bug fixes
- `docs(scope): description` for documentation
- Add `!` for breaking changes: `feat!:`
- Add `BREAKING CHANGE:` footer for details
Cursor will help format your commit messages correctly.
Option 3: Git Hooks with Husky
Install dependencies:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
Setup Husky:
# Initialize Husky
npx husky install
# Add commit-msg hook
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
Create commitlint.config.js:
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build"],
],
"subject-case": [2, "never", ["upper-case"]],
},
};
Result: Commits are automatically validated before accepting.
Option 4: Semantic Release (Full Automation)
Install:
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git
Create .releaserc.json:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}
Add to package.json:
{
"scripts": {
"release": "semantic-release"
}
}
Setup GitHub Actions (.github/workflows/release.yml):
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release
What this does:
- Analyzes commits on every push to
main - Determines next version based on commit types
- Generates changelog
- Creates git tag
- Publishes GitHub release
- Updates
package.jsonversion
Best Practices
✅ Do
- Always use annotated tags for releases
- Follow semantic versioning strictly
- Write meaningful commit messages following Conventional Commits
- Review commits before creating tags
- Tag only on main branch after tests pass
- Push tags immediately after creation
- Document breaking changes clearly in commit footer
- Use pre-release versions for testing
❌ Don't
- Create tags on feature branches
- Use lightweight tags for releases
- Skip tag annotations
- Bump versions arbitrarily without commits
- Create tags for work-in-progress code
- Delete pushed tags unless absolutely necessary
- Use inconsistent versioning schemes
Common Scenarios
Scenario 1: Hotfix in Production
# Current production: v1.5.3
# Critical bug found
# Create hotfix branch
git checkout -b hotfix/auth-crash main
# Fix the bug
git commit -m "fix(auth): prevent crash on invalid token"
# Merge to main
git checkout main
git merge hotfix/auth-crash
# Create patch tag
git tag -a v1.5.4 -m "Release v1.5.4: Fix auth crash on invalid token"
git push origin v1.5.4
# Result: v1.5.3 → v1.5.4
Scenario 2: Multiple Features in Sprint
# Current: v1.5.4
# Sprint includes 3 features
git commit -m "feat(dashboard): add real-time updates"
git commit -m "feat(reports): add CSV export"
git commit -m "feat(users): add bulk actions"
git commit -m "fix(ui): correct button styles"
git commit -m "docs: update API documentation"
# After sprint, merge to main
# Create minor version tag
git tag -a v1.6.0 -m "Release v1.6.0: Dashboard updates and user management
- Add real-time dashboard updates
- Add CSV export for reports
- Add bulk user actions
- Fix button styling issues"
git push origin v1.6.0
# Result: v1.5.4 → v1.6.0
Scenario 3: Major Refactor
# Current: v1.6.0
# Complete API rewrite
git commit -m "feat!: migrate to GraphQL API
BREAKING CHANGE: All REST endpoints have been removed.
Use GraphQL queries and mutations instead.
Migration guide: https://docs.example.com/migration/v2
Deprecation notice was in v1.5.0 (6 months ago)"
# Create major version tag
git tag -a v2.0.0 -m "Release v2.0.0: GraphQL API
BREAKING: Migrated from REST to GraphQL.
See migration guide for upgrade instructions."
git push origin v2.0.0
# Result: v1.6.0 → v2.0.0
Integration with ReleaseRay
ReleaseRay uses git tags to generate intelligent release notes:
How ReleaseRay Uses Tags
- Define Release Ranges: Select
v1.0.0→v1.1.0to analyze that range - Fetch Commits: Retrieves all commits between tags
- Analyze Changes: Categorizes changes by type (features, fixes, breaking)
- Generate Notes: Creates persona-specific release notes (engineer, customer, internal)
Best Practices for ReleaseRay
- Tag frequently: Tag every significant release or sprint
- Use clear messages: Tag annotations appear in release notes
- Follow conventions: Conventional commits improve AI analysis
- Push tags immediately: ReleaseRay needs tags on GitHub
- Maintain consistency: Consistent tagging = better release notes
Troubleshooting
Problem: "Commit message invalid"
Error:
⧗ input: updated some stuff
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
Solution: Use conventional commit format:
git commit -m "fix(ui): update button styles"
Problem: Tag Already Exists
Error:
fatal: tag 'v1.2.3' already exists
Solution: Delete and recreate:
# Delete locally
git tag -d v1.2.3
# Delete remotely (if already pushed)
git push --delete origin v1.2.3
# Recreate with correct version
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3
Problem: Wrong Version Number
Example: Accidentally created v1.7.0 instead of v1.6.0
Solution:
# Delete wrong tag
git tag -d v1.7.0
git push --delete origin v1.7.0
# Create correct tag
git tag -a v1.6.0 -m "Release v1.6.0"
git push origin v1.6.0
Problem: Forgot to Annotate
Created:
git tag v1.2.3 # Lightweight tag (bad)
Solution:
# Delete lightweight tag
git tag -d v1.2.3
# Recreate as annotated
git tag -a v1.2.3 -m "Release v1.2.3: Add user management"
git push origin v1.2.3
Version History Cheat Sheet
v0.1.0 → First development version
v0.2.0 → Added features (pre-stable)
v0.9.0 → Feature freeze, preparing for v1.0
v0.9.1 → Bug fixes before v1.0
v1.0.0 → First stable release! 🎉
v1.0.1 → Patch: Bug fixes
v1.1.0 → Minor: New features
v1.1.1 → Patch: More bug fixes
v2.0.0 → Major: Breaking changes
v2.0.0-rc.1 → Release candidate testing
v2.0.0 → Stable v2 release
Further Reading
- Semantic Versioning Specification - Official SemVer spec
- Conventional Commits - Commit message convention
- Git Tagging - Official Git documentation
- semantic-release - Automated release tool
- commitlint - Commit message linting
- Husky - Git hooks made easy
Quick Reference Card
# Create annotated tag
git tag -a v1.2.3 -m "Release message"
# Push tag
git push origin v1.2.3
# List all tags
git tag -l
# View tag details
git show v1.2.3
# Delete local tag
git tag -d v1.2.3
# Delete remote tag
git push --delete origin v1.2.3
# View commits since last tag
git log $(git describe --tags --abbrev=0)..HEAD
# Commit with conventional format
git commit -m "feat(scope): description"
git commit -m "fix(scope): description"
git commit -m "feat!: breaking change"
Need help with versioning? Contact us or check out ReleaseRay for automated release note generation from your git tags.