Semantic versioning and changelogs without the ceremony

Master Semantic Versioning (MAJOR.MINOR.PATCH) and write clean, readable changelogs using simple Git workflows without overcomplicated tooling.

By Asahluma Tyika

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.2 and fix a typo in an error message? That is a bug fix: bump to 1.4.3.
  • You are on 1.4.3 and add a new dark mode toggle: bump to 1.5.0 (PATCH resets to 0).
  • You are on 1.5.0 and remove the legacy authentication endpoint: bump to 2.0.0 (both MINOR and PATCH reset to 0).

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.0 even 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.
  • 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-tags so Git pushes both commits and annotated release tags together.
  • 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:

  1. Modify app.js to change the signature of greet(name) to accept an object: greet({ name, language }).
  2. Notice that this breaks any code calling greet("Alice").
  3. Update CHANGELOG.md with a ## [2.0.0] block and a ### Changed section explaining the signature change.
  4. Commit your changes and tag the release as v2.0.0.
  5. Run git tag -l and git log --oneline to verify your repository state.

What’s next

  • Conventional Commits: Learn how to write commits using the feat:, fix:, and refactor: 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.1 for testing before moving production forward.

Bookmark this page to keep the SemVer cheat sheet handy the next time you prepare a release.

Share: X (Twitter) Facebook LinkedIn