For years my commit messages were a private diary nobody read: “wip”, “fixes”, “final fix for real this time”. On a recent project I finally committed to Conventional Commits and let Google’s release-please turn that history into versions and changelogs. It changed how I think about commits entirely.
This is why commits are worth the effort, what a well-structured history lets you do, and how the scope part, the one I used to skip, turned out to be the most useful piece.
The short version
Write commit messages in a format machines can read, and you get versioning, changelogs and releases for free. Add scopes, and you also get a history you can actually search.
Why commits matter
A commit is the smallest unit of intent in your project. The diff says what changed; the message is the only place that records why. Six months later the code is still there, but the reasoning is gone unless you wrote it down.
Good commits pay off in moments you can’t plan for:
- Debugging.
git bisectandgit blameonly help if the commits they land on are small and self-explanatory. - Code review. A pull request split into logical commits reviews far faster than one giant blob.
- Reverting. A commit that does one thing can be undone cleanly. A commit that does five things cannot.
- Onboarding. The history is documentation nobody had to remember to write.
All of that is true without any tooling. The tooling is what turns a readable history into something that also does work for you.
Conventional Commits in one minute
Conventional Commits is a small specification for the shape of a commit message:
commit message format
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
A few real examples:
examples
feat(booking): allow recurring reservations
fix(auth): refresh expired tokens before retrying
docs(readme): document the env variables
refactor(notifications): extract the mail builder
feat(billing)!: drop support for legacy invoices
The type says what kind of change it is. The exclamation mark marks a breaking change. And the part in parentheses, the scope, says which area of the project it touches.
What this unlocks: automated releases
Once your messages follow a format, a tool can read them. release-please is Google’s open-source take on this, and it runs as a GitHub Action.
It doesn’t release on every merge, which is the part I like. Instead it keeps a release pull request open and up to date as commits land on your main branch. That PR contains the new version number and the generated changelog. When you’re ready to ship, you merge it, and release-please tags the commit and creates the GitHub release.
The version bump comes straight from your commit types:
| Commit | Release |
|---|---|
fix: … | patch — 1.2.3 → 1.2.4 |
feat: … | minor — 1.2.3 → 1.3.0 |
feat!: … or a BREAKING CHANGE: footer | major — 1.2.3 → 2.0.0 |
Setting it up is a single workflow file:
.github/workflows/release-please.yml
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: node
That’s the whole thing. From then on, every merge to main updates the pending release PR, and semantic versioning stops being a judgement call I make at the end of a long day.
Releases become boring
The best part isn’t the automation, it’s that releasing stops being an event. There’s no checklist, no manual changelog, no debating whether this is a minor or a patch. You merge a PR.
The part I finally used: scopes
I’d written conventional commits before, but always as feat: and fix: without the parentheses. Adding scopes, organised by feature area, is what made the history genuinely useful.
I keep the list short and tied to how I think about the product, not how the folders happen to be arranged:
commit history, with scopes
feat(booking): add waiting list for full days
fix(booking): prevent double reservations at midnight
feat(auth): add password reset flow
fix(notifications): stop sending reminders to inactive users
chore(deps): bump the test runner
Three things got better immediately.
The changelog organises itself
Entries carry their scope, so a release note is no longer a flat list of thirty lines. You can see at a glance that this release was mostly booking work with one auth fix, without reading every line.
History becomes searchable
This is the one I use almost daily:
terminal
# everything that ever touched booking
git log --oneline --grep="(booking)"
# only the features, this release cycle
git log --oneline --grep="^feat" v1.4.0..HEAD
Finding every change to one feature area used to mean guessing at file paths. Now it’s one command, and it still works after the files have moved.
It makes commits smaller
This surprised me. When you have to pick a single scope, a commit touching booking and auth suddenly feels wrong, because it is. The format quietly pushes you toward commits that do one thing, which is what you wanted anyway.
Don’t over-engineer your scopes
Start with three to five scopes that match how you talk about the product. A scope list nobody can remember is a scope list people will ignore.
Getting started without a big migration
- Agree on the types first.
feat,fix,chore,docs,refactorandtestcover almost everything. - Add a handful of scopes. Name feature areas, not folders, and write them down in the README.
- Enforce it in CI. A commit linter catches mistakes while they’re still cheap to fix.
- Use squash merges. Then the pull request title becomes the commit message, and only that one message has to be right.
- Add release-please last. Once a few weeks of history look clean, let it start producing releases.
You don’t need to rewrite old history. Everything before the switch simply doesn’t show up in generated changelogs, and that’s fine.
Frequently asked questions
What are conventional commits?
Conventional Commits is a small specification for commit messages. Each message starts with a type such as feat or fix, an optional scope in parentheses, and a short description: feat(booking): allow recurring reservations. Because the format is predictable, tools can read your history and decide what changed.
Does release-please work outside Google projects?
Yes. It is an open-source GitHub Action that works on any repository that follows Conventional Commits. It supports many languages and can release multiple packages from one repository through a manifest.
What if I write a bad commit message?
Fix it before it lands. Rewrite the message while the branch is still yours, or use a squash merge and write a proper message in the merge box. A linter such as commitlint can reject malformed messages in CI.
Do I still need to write a changelog by hand?
No. release-please builds the changelog from your commit messages and keeps it in an open release pull request. Your job moves from writing the changelog to writing commit messages worth reading.
Write for the next person
The reason commits matter isn’t that tools can parse them. It’s that a commit message is a note to whoever shows up next, usually you, with no memory of today. Conventional Commits just gives that note a shape, and once it has a shape, machines can handle the versioning, the changelog and the release while you get on with the work.
