Stop overthinking your releases
Version numbers and changelogs cause unnecessary anxiety. You finish a feature, stare at your terminal, and wonder: Is this a patch or a minor release? Should I install an automated changelog generator with 40 dependencies? Did I break someone’s build?
Most developers react in one of two ways. They either drown in complex release scripts with multi-page configuration files, or they invent random version names like v2-final-fixed-again and skip release notes entirely.
Neither extreme works. Version numbers communicate trust to anyone using your code—including teammates and your future self. Changelogs tell people whether upgrading will break their afternoon.
This guide strips away the enterprise ceremony. You will learn the exact rules of Semantic Versioning (SemVer), how to keep a crisp, readable changelog, and how to ship versions with plain Git commands you already know.
Part 1: The SemVer formula (MAJOR.MINOR.PATCH)
Semantic Versioning (defined at semver.org) uses a three-part number format:
MAJOR . MINOR . PATCH
1 . 4 . 2
Each segment answers a specific question for your users:
| Segment | When to increment | Example scenario | Will it break existing code? |
|---|---|---|---|
| MAJOR | You changed or removed existing functionality | Renaming a function, deleting an API route, changing a required parameter | Yes |
| MINOR | You added new functionality without breaking anything | Adding an optional parameter, creating a new helper function or endpoint | No |
| PATCH | You fixed a bug without adding or breaking functionality | Resolving a null-pointer error, fixing an edge-case calculation, patching a security bug | No |
The Golden Rule of SemVer
Increment the furthest segment to the left that applies, and reset everything to the right to zero:
- You are on
1.4.2and fix a typo in an error message? That is a bug fix: bump to1.4.3. - You are on
1.4.3and add a new dark mode toggle: bump to1.5.0(PATCH resets to0). - You are on
1.5.0and remove the legacy authentication endpoint: bump to2.0.0(both MINOR and PATCH reset to0).
What about 0.y.z?
When your project is at 0.x.x (for example, 0.1.0), SemVer treats your software as initial development. Anything can change at any time. The public API is not considered stable. Once users rely on your code in production, bump to 1.0.0 and start enforcing strict SemVer rules.
Part 2: Human-readable changelogs
A changelog is an ordered record of notable changes for each version.
A raw Git log (git log --oneline) is not a changelog. Commit messages like fix linting, WIP, or resolved merge conflict mean nothing to an engineer deciding whether to update your package.
The gold standard format comes from Keep a Changelog. It groups changes under six clear verbs:
- Added: New features.
- Changed: Changes in existing functionality.
- Deprecated: Features soon to be removed.
- Removed: Features removed in this release.
- Fixed: Bug fixes.
- Security: Vulnerability patches.
The anatomy of a clean CHANGELOG.md
Keep an [Unreleased] section at the top of your changelog file. When you complete a task during your work week, add one bullet point under the right heading immediately. When release day arrives, you simply rename [Unreleased] to your new version number and add the release date.
Here is a practical template:
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Export data to CSV format.
## [1.1.0] - 2025-03-10
### Added
- User profile avatar upload support.
### Fixed
- Session timeout occurring unexpectedly on Safari.
## [1.0.0] - 2025-02-15
### Added
- Initial stable release with email/password authentication.
Part 3: Git tagging without extra tools
You do not need heavy command-line utilities to tag releases. Plain Git handles this out of the box with annotated tags.
Always use annotated tags (-a) instead of lightweight tags for releases. Annotated tags store the author, date, and a release message directly inside the Git database:
git tag -a v1.2.0 -m "Release v1.2.0: Adds CSV export"
To push tags to your remote repository, remember that standard git push does not include tags automatically:
# Push your current branch and all new tags together
git push --follow-tags
# Or push tags explicitly
git push origin v1.2.0
Hands-on example: A real release workflow
Let’s walk through a complete, runnable exercise. Run these commands directly in your terminal to practice an end-to-end versioning flow.
Step 1: Initialize a repository
mkdir semver-demo
cd semver-demo
git init
Step 2: Establish base release (1.0.0)
Create a basic application script and a changelog:
# Create a dummy script
echo 'function greet(name) { return "Hello, " + name; }' > app.js
# Create the initial changelog
cat << 'EOF' > CHANGELOG.md
# Changelog
All notable changes will be documented in this file.
## [1.0.0] - 2025-03-01
### Added
- `greet()` helper function for generating welcome strings.
EOF
# Commit and tag v1.0.0
git add app.js CHANGELOG.md
git commit -m "chore: release version 1.0.0"
git tag -a v1.0.0 -m "Release 1.0.0"
Step 3: Implement a bug fix (PATCH release)
Imagine greet() fails when name is undefined. We apply a fix, update the changelog, and bump the PATCH segment (1.0.1).
Update app.js:
echo 'function greet(name) { return "Hello, " + (name || "friend"); }' > app.js
Update CHANGELOG.md to reflect the patch:
cat << 'EOF' > CHANGELOG.md
# Changelog
All notable changes will be documented in this file.
## [1.0.1] - 2025-03-15
### Fixed
- Prevented blank greetings when `name` is missing or undefined.
## [1.0.0] - 2025-03-01
### Added
- `greet()` helper function for generating welcome strings.
EOF
Commit the fix and tag the patch:
git add app.js CHANGELOG.md
git commit -m "fix: default name argument to avoid empty greeting"
git tag -a v1.0.1 -m "Release 1.0.1"
Step 4: Add backwards-compatible functionality (MINOR release)
Now, add a new goodbye function without altering greet(). This is a non-breaking addition, so we bump the MINOR segment (1.1.0).
Update app.js:
cat << 'EOF' > app.js
function greet(name) { return "Hello, " + (name || "friend"); }
function dismiss(name) { return "Goodbye, " + (name || "friend"); }
EOF
Update CHANGELOG.md:
cat << 'EOF' > CHANGELOG.md
# Changelog
All notable changes will be documented in this file.
## [1.1.0] - 2025-03-20
### Added
- `dismiss()` function for farewell messages.
## [1.0.1] - 2025-03-15
### Fixed
- Prevented blank greetings when `name` is missing or undefined.
## [1.0.0] - 2025-03-01
### Added
- `greet()` helper function for generating welcome strings.
EOF
Commit and tag the minor release:
git add app.js CHANGELOG.md
git commit -m "feat: add dismiss farewell helper"
git tag -a v1.1.0 -m "Release 1.1.0"
Step 5: Verify your tags
Check your tag history to confirm everything is cleanly tracked:
git tag -n
You will see:
v1.0.0 Release 1.0.0
v1.0.1 Release 1.0.1
v1.1.0 Release 1.1.0
Common mistakes and fixes
- Bumping MAJOR for code refactors that don’t change behavior:
- The Mistake: Rewriting an internal database query engine from scratch and releasing it as
2.0.0even though all inputs, outputs, and public functions stayed identical. - The Fix: If the public API did not change, it is not a major release. Internal improvements belong in a MINOR or PATCH release.
- The Mistake: Rewriting an internal database query engine from scratch and releasing it as
- Forgetting that Git doesn’t push tags by default:
- The Mistake: Creating a tag locally, running
git push origin main, and realizing the release tag is missing on GitHub or GitLab. - The Fix: Run
git push --follow-tagsso Git pushes both commits and annotated release tags together.
- The Mistake: Creating a tag locally, running
- Treating Git commits as a changelog:
- The Mistake: Generating release notes by copy-pasting raw commit messages containing merge commits, rebase artifacts, and obscure shorthand.
- The Fix: Write changelog items for end users, not yourself. Focus on what changed for them, not which line of code you touched.
- Bumping MINOR instead of PATCH for dependencies:
- The Mistake: Upgrading an internal dependency to fix a security issue and marking your own app up a MINOR version.
- The Fix: A dependency update that addresses vulnerabilities or bugs without introducing new APIs is a PATCH release under the Security or Fixed heading.
Try it yourself
Put the pattern to work inside your semver-demo folder:
- Modify
app.jsto change the signature ofgreet(name)to accept an object:greet({ name, language }). - Notice that this breaks any code calling
greet("Alice"). - Update
CHANGELOG.mdwith a## [2.0.0]block and a### Changedsection explaining the signature change. - Commit your changes and tag the release as
v2.0.0. - Run
git tag -landgit log --onelineto verify your repository state.
What’s next
- Conventional Commits: Learn how to write commits using the
feat:,fix:, andrefactor:prefixes so you can generate draft changelogs automatically. - Automated releases in GitHub Actions: In our upcoming guide, we will connect standard Git tags to automated GitHub Release pipelines that build binaries and publish packages automatically.
- Release candidates and pre-releases: How to publish versions like
2.0.0-beta.1for testing before moving production forward.
Bookmark this page to keep the SemVer cheat sheet handy the next time you prepare a release.