Conventional Commits all the way to a released version
Wiring commit-driven releases so the version number actually means something — the misconfiguration that made every release a minor bump for months, the separation between version calculator and changelog generator, and the CI permissions that block every first attempt
For the better part of a year my releases looked automated. Merge to main, and a pipeline
computed a version, wrote a changelog section, tagged the repository and published the artefact.
Nobody bumped a number by hand. Nobody wrote release notes. It felt solved.
Then someone asked why version 0.7.0 — a minor bump, implying new functionality — contained
nothing but a documentation change.
It happened because the version number was never actually derived from the commits. It was
derived from a single line of configuration that said “on main, increment the minor
component”, and it had said that since the day the pipeline was written. Every release for
months had been a minor bump: bug fixes, docs, refactors, everything. The numbers were
incrementing, but they carried no information at all.
That is the failure mode this post is really about. Wiring up commit-driven releases is a weekend’s work and every tool has a quickstart. Wiring them up so that the number means something — and confirming that it does — is the part that gets skipped.
The idea in one line
The commit message is the only input. Everything downstream is derived from it:
| What | Derived from |
|---|---|
| Whether the commit is allowed at all | the format |
| Which changelog section the entry lands in | the type (feat → Features, fix → Fixes) |
| The entry text and its scope prefix | the subject |
| The version number | the type as well — feat → minor, fix → patch, ! → major |
So a developer writing this:
feat(api): add heartbeat endpointhas, in that one line, requested a minor version bump and written their own release note. Nothing else in the pipeline needs a human decision.
The moving parts, and the one distinction that matters
Four files and a set of repository permissions:
| File | Role |
|---|---|
.githooks/commit-msg | Rejects a commit whose subject is not a Conventional Commit. Local only. |
| A version-calculator config | Computes the next SemVer from branch and tag history. |
| A changelog-generator config | Turns commits into a changelog section. |
CHANGELOG.md | The output. Manual notes on top, generated sections below. |
I used GitVersion for the number and git-cliff for the text, but the tools are interchangeable. What is not interchangeable is the separation between them:
The version calculator owns the number. The changelog generator owns the text. The generator is handed the number and never computes one itself.
Get this wrong — let both tools work out a version independently — and they will eventually
disagree, usually on the release where it matters. Pass the computed number in explicitly
(git-cliff --tag "$VERSION") and the question cannot arise.

Step one: enforce the format, and know what your enforcement is worth
A commit-msg hook is about thirty lines of shell. It takes the first non-blank, non-comment
line and tests it against a single regex:
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9._/-]+\))?!?: .+'Two details are worth copying. First, let machine-generated subjects through untouched — you did not author them, and failing them makes rebasing miserable:
if printf '%s' "$subject" | grep -qE '^(Merge |Revert "|fixup!|squash!|amend!)'; then
exit 0
fiSecond, make the failure message teach the convention rather than merely refusing. Print the expected shape, the allowed types and three worked examples. A contributor who hits this hook once should not need to look anything up.
Keep the hook in a version-controlled directory rather than .git/hooks, so it is reviewed
like any other file. The cost is that git does not enable it automatically — every
contributor must run git config core.hooksPath .githooks once per clone, and you must put
that line in CONTRIBUTING.md.
And then accept that the hook is advisory, not a gate. It is per-clone local configuration. A contributor who never runs that command, uses a GUI client that bypasses hooks, or commits through the web editor will push non-conventional subjects — which then silently vanish from your changelog. If you want real enforcement, add a CI job on merge requests that validates the commits in the MR range server-side, and make it blocking. The hook is for fast feedback; the CI job is for correctness.
Step two: make the type drive the number
This is the step I got wrong, so it gets the most space.
Out of the box, GitVersion knows nothing about Conventional Commits. Its built-in bump triggers look for an explicit trailer that essentially nobody writes:
| Bump | Default trigger |
|---|---|
| Major | +semver: breaking or +semver: major |
| Minor | +semver: feature or +semver: minor |
| Patch | +semver: fix or +semver: patch |
If your config omits the bump-message patterns, no commit ever matches, every release falls
through to the branch’s default increment:, and the number degrades into a counter. Nothing
fails. No test catches it. The pipeline stays green for months.
The fix is three lines:
major-version-bump-message: '(^|\n)\w+(\([^)]*\))?!:|(^|\n)BREAKING[ -]CHANGE:'
minor-version-bump-message: '(^|\n)feat(\([^)]*\))?:'
patch-version-bump-message: '(^|\n)(fix|perf|refactor)(\([^)]*\))?:'The tool tests every commit since the last tag against these in order and applies the highest match found across the whole set.
Two regex subtleties cost me time:
- The patterns are matched against the entire commit message as one string, not line by
line. A bare
^therefore only ever reaches the subject — which is fine for the type, but means aBREAKING CHANGE:footer in the body is never seen. Hence(^|\n). - The
\([^)]*\)scope group must be optional and non-greedy about parentheses, or a scoped commit likefeat(api):fails to match while the unscopedfeat:succeeds. That produces the maddening symptom of the bump working only sometimes.
Types you deliberately leave unmatched — docs, chore, ci, style, test, build,
revert — fall through to the branch default, which should be Patch.
Do not add a “no bump” pattern for them. If every merge to main cuts a release, a
no-bump result recomputes the existing version and the tag push fails with “already
exists”. A docs-only release should be a patch, not a paradox.
Verify it against your own changelog, not against the docs
Here is the two-minute check that would have saved me months. Open your changelog. Take the last five releases. For each one, look at the commit types that went into it and confirm the version component that moved is the one you would expect.
Mine failed immediately and obviously: a fix-only release that cut a minor, two docs-only
releases that also cut minors. The evidence had been sitting in the repository the whole time,
formatted as a table, and nobody had read it as a test result.
Do this check after any change to the version configuration, and once as an audit even if you have not changed anything.
Step three: make the type drive the text
The same commits, grouped differently. The changelog config maps each type to a section:
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^docs", group = "<!-- 4 -->Documentation" },
# ...
]The <!-- N --> prefixes are a sort key — the template strips the comment before rendering,
so sections appear in a deliberate order rather than alphabetically or by first occurrence.
It looks like a hack because it is one, but it is the documented one.
Note filter_unconventional = true: anything that does not parse is dropped. Combined with an
advisory-only hook, that means a badly-formed commit does not break the release, it just
quietly does not appear in it. Silent omission is the characteristic failure of this whole
setup, and it is worth saying out loud to your team.
Splice, do not regenerate
The obvious invocation regenerates the entire changelog file from history — destroying every hand-written note in it. Render only the new section and splice it in:
git-cliff --config cliff.toml --tag "$VERSION" --unreleased --strip all > new_section.md| Flag | Why |
|---|---|
--tag "$VERSION" | Stamps the calculated number on. The generator never invents one. |
--unreleased | Only commits since the last tag. |
--strip all | Drops header and footer, leaving a section body ready to insert. |
Then insert it above the first versioned heading rather than below the ## [Unreleased]
heading — anchoring on the version pattern means the same script works on a repository that
has no releases yet.
Decide explicitly what happens to the manual [Unreleased] block on release. Some of our
repositories leave it alone, so notes accumulate until someone prunes them; others reset it to
a placeholder. Either is defensible. Not deciding means contributors do not know whether their
hand-written note will survive the next release, so they stop writing them.
Step four: the release job, and its four load-bearing lines
The job stamps the version into the source, regenerates the changelog, commits, tags and
pushes. Most of it is unremarkable sed. Four lines are not, and all four are easy to lose
when copying between projects:
| Line | Why it is essential |
|---|---|
git fetch --tags --force | The CI clone usually has no tags. Without them the generator cannot find the previous release, and --unreleased yields your entire history as one section. |
git checkout -b "$BRANCH" | CI checks out a detached HEAD at a commit SHA. You cannot push a detached HEAD to a branch. |
git remote set-url origin https://… | The default remote uses the job’s own token, which is never permitted to push. Swapping in the bot credential is what makes the push possible at all. |
-o ci.skip on both pushes | Otherwise the release commit and the tag each trigger a fresh pipeline, which tags again — an infinite release loop. |
A fifth, if your runner image is Alpine or otherwise musl-based: installing git-cliff via pip
tries to compile the Rust crate from source and fails, because there is no musllinux
wheel. Download the prebuilt static binary for the architecture instead. This wastes an
afternoon exactly once.
Step five: the permissions, which are the actual blocker
Everything above is files you can copy. This part is not in any file, and it is what will break your first attempt.
A CI job pushing to a protected main has to clear two independent server-side gates, and
fixing the first only reveals the second. Configure both up front.
Gate one — protected branch push permission. main is protected, so by default nobody may
push to it, including your bot. Add the bot to the branch’s allowed-to-push list. If you also
protect tags, add it to allowed-to-create there too, or the branch push will succeed and the
tag push will fail a second later.
Here is the thing that cost me the most time, across two separate projects, months apart:
The CI variable holding your token is not a username. I had a variable called something like
RELEASE_BOT, and searching for that string in the member picker returns nothing, because no such user exists. A group access token authenticates as an auto-generated bot user namedgroup_<groupId>_bot_<hash>; a project token usesproject_<projectId>_bot_<hash>. Search the member list forbot.
Prefer adding that specific bot user over granting a whole role. Granting Maintainers the
right to push to main solves your pipeline problem by removing your branch protection, which
is not the trade you meant to make.
Gate two — push rules. A “reject unverified users” rule requires the committer email on every pushed commit to be a verified address of the pushing user. My job committed under a friendly vanity identity but pushed as the token bot, whose only verified address is its own no-reply one. Rejected.
If you cannot turn that rule off, derive the bot’s real address at runtime instead of hardcoding one — it changes when the token is rotated:
BOT_EMAIL=$(curl -sf --header "PRIVATE-TOKEN: ${TOKEN}" "${API_URL}/user" \
| sed -n 's/.*"email":"\([^"]*\)".*/\1/p')
git config user.email "${BOT_EMAIL:[email protected]}"Push rules are typically inherited from a parent group, so check there as well as on the project. A rule set one level up is the usual explanation for why one repository releases cleanly and its sibling does not with identical pipeline configuration.
Read the error to tell the gates apart
The messages are similar enough to conflate, and each points somewhere different:
| Error | Meaning |
|---|---|
not allowed to push code to protected branches | The bot is a member but is not in the allowed-to-push list → gate one |
not allowed to push code to this project | The bot is a member but has too low a role |
The project you were looking for could not be found | The bot is not a member at all |
Authentication failed / HTTP Basic: Access denied | The token is missing, wrong or expired |
You cannot push commits for '<email>' | Permissions are fine — a push rule is rejecting the identity → gate two |
Three more things that will bite
Squash merges rewrite the subject. If you squash on merge, the platform builds the squashed subject from the merge request title, not from your carefully-typed commits. An MR titled “Fix the thing” produces a non-conventional subject that gets filtered out of the changelog — and, now that the type also drives the number, quietly comes out as a patch when it should have been a minor. One bad title destroys both signals at once. Either enforce Conventional Commit syntax on MR titles, or turn squashing off and rely on the commits.
Hand-edits to generated sections are lost. Only the manual block is yours. A generated
release section is regenerated from commit messages, so editing it achieves nothing durable —
amend the commit message instead, before it reaches main.
Token rotation breaks every project simultaneously. Rotating a group access token mints a new bot user with a different hash. The old entry in the allowed-to-push list still names the dead bot, so every release-automated project in the group starts failing at once with a permissions error and no code change to explain it. When you rotate, note the new bot username and update every project’s protected-branch and protected-tag lists.
When SemVer is not the right key
One of our repositories is not software. It holds a single schema-validated architecture document, and a change to it is reviewed under a governance ticket rather than shipped as a version. “Version 2.4.0 of the design document” would be meaningless to every human involved.
The interesting outcome is that almost all of the machinery still applied — I changed the key, not the pipeline. The branch is named after the governance ticket, the document’s version field is stamped with that same ticket ID, and the changelog section is headed with it too. Commit types still drive which group each entry lands in. The one thing that goes away is the version calculator, because the ticket ID replaces the computed number.
Two adaptations were worth the effort and would transfer to any repository:
- Generate locally, verify in CI. Instead of a job that commits the changelog back to the
protected branch, a contributor runs the generator locally and a CI job re-runs it with a
--checkflag and fails if the result differs. That deletes the entire permissions problem from step five — no bot, no protected-branch exception, noci.skip, no release loop. If you do not need a version stamped into published artefacts, this is the better design, and I would now reach for it first. - Regenerating replaces, rather than appends. Re-running on the same branch removes the existing section for that key before splicing the new one in, so an amended commit produces a corrected section instead of a duplicate.
I also made both scripts degrade gracefully: if git or the generator is unavailable, they skip with a zero exit rather than failing. A tooling gap should never falsely block a pipeline that is otherwise fine.
The checklist
- Copy the hook, the version config and the changelog config to the repository root.
- Keep the bump-message patterns. Without them the types do nothing, and nothing tells you.
- Create
CHANGELOG.mdwith a preamble and a manual[Unreleased]heading, and decide what happens to that block on release. - Add the pipeline jobs. Preserve the four load-bearing lines.
- Add the bot user to protected-branch push and protected-tag create.
- Check push rules on the project and the parent group.
- Document
git config core.hooksPath .githooksinCONTRIBUTING.md, and add a blocking CI job that validates commit format server-side. - Merge a
feat:commit and confirm a minor bump. Then merge adocs:commit and confirm a patch. Confirm the tag, the changelog section and the published artefact all carry the same number.
Step eight is the one to insist on. Everything else can be green while the system is quietly meaningless — and a version number that means nothing is worse than no version number at all, because people trust it.