Stop Manually Updating Your Changelog: Let Conventional Commits Orchestrate Your Entire Release Cycle
There’s a specific kind of Friday afternoon dread that kicks in right when you realize you have to manually summarize two weeks of commits into a readable CHANGELOG.md. You’re staring at a screen of "fixed bug" and "minor update" commit messages, trying to remember if that one refactor actually changed the public API or just cleaned up some technical debt. It’s tedious, it’s prone to human error, and honestly, it’s a waste of the brainpower you have left.
We’ve all been there: arguing over whether a release should be a "minor" or a "patch" while someone else accidentally overwrites the version number in package.json. There’s a better way to live. By adopting a strict commit convention, you can turn your git history into the source of truth that drives your entire release pipeline—from version bumping to changelog generation and deployment.
The Secret Sauce: Conventional Commits
Before you can automate anything, you need a machine-readable language for your history. Conventional Commits is the industry standard for this. It’s a lightweight convention on top of commit messages that provides a set of rules for creating an explicit commit history.
The structure is dead simple:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]Most of your day-to-day will look like this:
* feat: add dark mode support (This triggers a minor version bump)
* fix: resolve memory leak in image processing (This triggers a patch version bump)
* feat!: complete rewrite of the auth module (The ! or a BREAKING CHANGE footer triggers a major version bump)
I’ve found that even if you don't automate the release, just using this format makes git log actually readable for humans. You can instantly see what changed without opening a single PR.
Stop Guessing, Start Automating
Once your commits follow a pattern, you can introduce a tool like semantic-release. This isn't just a changelog generator; it’s a full-on release manager. It lives in your CI pipeline and follows a strict logic:
1. It analyzes your commits since the last tag.
2. It determines the next version number based on those commits.
3. It generates a CHANGELOG.md.
4. It publishes the package (to npm, PyPI, Docker Hub, etc.).
5. It creates a Git tag and a GitHub/GitLab release.
Here is a basic .releaserc configuration (JSON) to get you started:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}Wiring it into GitHub Actions
You don't want to run this on your local machine. Releases should happen in a clean environment where credentials are secure. Here’s a lean GitHub Action workflow that triggers a release every time you push to the main branch:
name: Release
on:
push:
branches:
- main
jobs:
release:
name: release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Important: semantic-release needs the full history
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm install
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-releaseWait, what about the "fetch-depth"?
I've seen many devs get stuck here. By default, most CI providers do a "shallow clone" (only the latest commit). If semantic-release can't see your previous tags and history, it won't know where the last release ended, and it might try to version your entire project history from scratch. Always fetch the full history.
The "Squash" Gotcha
If you’re a fan of the "Squash and Merge" button on GitHub (and you should be, it keeps history clean), you need to be careful. If you have five commits in a branch like wip, fixing bug, almost there, and you squash them into a single commit on main with the title update UI, your automation will fail to bump the version correctly.
The trick is to enforce conventional titles on Pull Requests. GitHub allows you to set a regex check on PR titles. If the PR title is feat: add user dashboard, that's what becomes the final commit message on main after the squash. Your CI then reads that one clean message and knows exactly what to do.
Is this overkill?
I used to think so. I thought, "I can just type npm version patch and write a few bullet points." But as a project grows, or as you move between five different repositories, the cognitive load adds up.
When you automate this, you remove the "Release Manager" role from your team's responsibilities. No one has to remember the rules because the rules are baked into the workflow. If the code doesn't have a feat or fix commit, no release happens. If it has a !, the version jumps. It’s objective, it’s fast, and it lets you get back to actually writing code.