Benefits of Conventional Commits for Automated Releases
Release day on a cloud team usually means one person scrolling git log, guessing whether this is a minor or a patch, and typing a changelog from memory. Conventional commits fix that by giving every commit message a small machine-readable shape. Tools can then pick the version, write the changelog and open the release for you. Here is what that buys you, with the config to prove it.
What conventional commits look like
The Conventional Commits spec asks for <type>[optional scope]: <description>, plus an optional body and footers. A fix maps to a patch, a feat maps to a minor, and anything breaking maps to a major. You mark a breaking change with ! before the colon or a BREAKING CHANGE: footer. Types like docs, ci and chore have no version effect on their own.
fix(api): retry on 503 from the model gateway
feat(deploy): add a canary stage to the rollout pipeline
feat(config)!: rename MODEL_ENDPOINT to LLM_ENDPOINT
BREAKING CHANGE: deployments must set LLM_ENDPOINT. MODEL_ENDPOINT is no longer read.
That is three commits: a patch, a minor and a major.
Benefits of conventional commits for automated releases
1. Version bumps nobody argues about
Semantic versioning automation only works if a tool can tell a bug fix from a breaking change. With the format above, it can. The three commits above add up to a major release, because the single breaking commit outranks the rest. Release Please applies exactly this mapping, so the number on your container image or module tag comes from the history instead of a hallway debate.
2. An automated changelog you did not write
Release Please groups commits into a CHANGELOG.md. It does not release on every merge. It keeps a Release PR open and updates it as commits land on main.
# .github/workflows/release-please.yml
name: release-please
on:
push:
branches:
- main
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: simple
The simple type maintains a version.txt and CHANGELOG.md. Swap in node, python, go or rust to match your repo. You may also need to enable "Allow GitHub Actions to create and approve pull requests" in the repository settings.
3. Release notes you review before they ship
The Release PR is your checkpoint. Reviewers read the proposed version and changelog as a normal diff, then merge when ready, and the merge creates the tag and the GitHub release. Gate your build on it with the release_created output, added to the same job:
- uses: actions/checkout@v4
if: ${{ steps.release.outputs.release_created }}
- name: Build and push image
if: ${{ steps.release.outputs.release_created }}
env:
TAG: ${{ steps.release.outputs.tag_name }}
run: |
docker build -t <your-registry>/<your-app>:"$TAG" .
docker push <your-registry>/<your-app>:"$TAG"
Registry login is omitted here, so add your own. PRs created with the default GITHUB_TOKEN do not trigger other workflows, so pass a personal access token through the action's token input if you want CI to run on the Release PR.
4. Bad messages bounce before they reach main
commitlint checks messages against the convention. The config-conventional preset allows build, chore, ci, docs, feat, fix, perf, refactor, revert, style and test. It caps the header at 100 characters and rejects capitalized subjects.
npm install -D @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
On Windows, create commitlint.config.js by hand with the same contents. Then run it on every pull request:
# .github/workflows/commitlint.yml
name: commitlint
on:
pull_request:
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
The full history (fetch-depth: 0) is needed so commitlint can see the whole PR range. The npm ci step assumes you committed the lockfile after the install above.
5. Commit history that AI coding agents can write and parse
Coding agents produce a lot of commits quickly. A fixed grammar is easy for an agent to follow and easy for commitlint to check when it slips. Put the rule in the instruction file your agent reads, such as CLAUDE.md or AGENTS.md:
## Commits
- Use Conventional Commits: `<type>(<scope>): <description>`.
- Types: feat, fix, docs, refactor, perf, test, build, ci, chore.
- Mark breaking changes with `!` and a `BREAKING CHANGE:` footer.
- One logical change per commit. Lowercase imperative subject, no trailing period.
The same grammar makes the history queryable. An agent or a script can list what changed since the last release:
git log <last-tag>..HEAD --format=%s | grep -E '^(feat|fix)[(!:]'
When conventional commits are not worth it
A personal script or internal tool with one maintainer and a release every few months will not repay the ceremony. The same goes for a service that deploys continuously while nobody reads the version number.
The common failure is squash-merge. If the merged commit takes a free-form PR title, the history stops being conventional and release tools see nothing to act on. Check your squash-merge default so the PR title becomes the commit subject, and lint the title:
- run: echo "$PR_TITLE" | npx commitlint --verbose
env:
PR_TITLE: ${{ github.event.pull_request.title }}
The title goes through an environment variable instead of being pasted into the script, so a hostile PR title cannot run as shell.
When prompts, models and infrastructure change every week, a version and changelog generated from commit history tell you exactly what shipped and what to roll back to.