Shipping the release pipeline: git-cliff in GitHub Actions across ten repos
A follow-up to the Conventional Commits post — rolling out automated semantic versioning, changelog updates and GitHub Releases with git-cliff in GitHub Actions across ten repositories, and the gotchas found on the way: release commits polluting the changelog, GITHUB_TOKEN loop protection, and a branch-protected main
The previous post ended with a recommendation: close the loop by regenerating the changelog in CI, so no human is ever trusted to remember. At the time that step was the one piece I hadn’t actually built. This post is the report from building it — the same evening, across all ten repositories in my workspace — and the three or four wrinkles that only showed up once the workflow met real repos.
The goal, precisely: on every push or merge to main, compute the next semantic version from the Conventional Commit history, regenerate CHANGELOG.md for that version, tag it, and publish a GitHub Release whose notes are that version’s changelog section. Version first, changelog second — the version number is derived from the commits, and the changelog is written under it.
The workflow
Nine of the ten repos commit directly to an unprotected main, and they all share one workflow file. The core of it:
name: Release
on:
push:
branches: [main]
permissions:
contents: write
jobs:
release:
# Belt and braces: never react to our own release commit.
if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
uses: taiki-e/install-action@v2
with:
tool: git-cliff
- name: Compute next version
id: version
run: |
next="$(git cliff --bumped-version)"
latest="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
echo "next=$next" >> "$GITHUB_OUTPUT"
if [ -n "$next" ] && [ "$next" != "$latest" ]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
fi
- name: Update changelog, tag and release
if: steps.version.outputs.release == 'true'
env:
VERSION: ${{ steps.version.outputs.next }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git cliff --bump --output CHANGELOG.md
git cliff --bump --unreleased --strip all --output RELEASE_NOTES.md
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "chore(release): ${VERSION#v}"
git tag -a "$VERSION" -m "$VERSION"
git push origin HEAD:main
git push origin "$VERSION"
gh release create "$VERSION" --title "$VERSION" --notes-file RELEASE_NOTES.mdgit cliff --bumped-version does the semver arithmetic: it reads the conventional commits since the last tag and applies the standard rules — a fix implies a patch bump, a feat a minor, a ! (breaking change) a major. Comparing the result against the latest existing tag gives a clean no-op path: if nothing releasable has landed, the job exits without touching anything. The second git cliff invocation, with --unreleased --strip all, emits just the new version’s section — no Keep a Changelog header, no link footer — which is exactly what you want as GitHub Release notes.
Everything runs on the built-in GITHUB_TOKEN. No personal access tokens, no secrets to create or rotate in any of the ten repos.
Gotcha one: your own release commits poison the changelog
The first dry run produced a changelog with a mysterious bullet reading, in its entirety, - 0.1.0. That is the previous release commit — chore(release): 0.1.0 — being swept up by the catch-all parser and rendered under Changed with its type prefix stripped. Harmless once; with automation cutting a release on every merge, the changelog would steadily fill with bullets that just say version numbers.
The fix is two more skip parsers at the top of cliff.toml, alongside the existing one for merge commits:
commit_parsers = [
{ message = "^Merge ", skip = true },
{ message = '^chore\(release\)', skip = true },
{ message = '^chore\(changelog\)', skip = true },
# ... the grouping parsers follow
]The general lesson: any commit the pipeline itself generates must be invisible to the pipeline’s parser, or the machinery’s own bookkeeping leaks into the document it maintains.
Gotcha two: the infinite loop that never happens
The obvious worry with a workflow that pushes a commit to main on push to main is recursion. The workflow’s if: guard — skip when the head commit is a chore(release): — addresses it, but it turns out to be belt and braces rather than load-bearing: pushes made with the built-in GITHUB_TOKEN deliberately do not trigger workflows. GitHub designed exactly this footgun away. The release commit lands on main and nothing fires.
That protection has a flip side worth knowing about. If a repo has other workflows on push to main — a Cloudflare Pages deploy, say — those don’t fire for the release commit either. The updated CHANGELOG.md won’t be deployed to the site until the next ordinary push. For a changelog that is a non-issue; if the pipeline ever touched something user-facing, it would matter, and the fix would be a deploy key or app token in place of GITHUB_TOKEN.
Gotcha three: the branch-protected repo
Nine repos take the workflow above unchanged. The tenth — this blog — has a protected main that only accepts changes through a reviewed PR from dev, with four passing checks. A workflow cannot push a release commit to that main, and I didn’t want to weaken the protection or mint a bypass token just for a changelog.
The answer is to split the job in two along the existing flow:
changelog.ymlruns on push todev. It regeneratesCHANGELOG.mdwith the next computed version (git cliff --bump) and commits it back todevif it changed. The changelog update then rides intomaininside the same auto-created PR as the work it describes — reviewed, checked, and merged like everything else.release.ymlruns on push tomain— which, on this repo, means “when the PR merges”. It never pushes a commit. It recomputes the bumped version, and if that tag doesn’t exist yet, creates the annotated tag and publishes the GitHub Release. A tag is its own ref; branch protection onmaindoesn’t stand in its way.
The division of labour falls out naturally: the document travels through the front door with the code, and only the immutable markers — tag and Release — are created on the far side. No secrets, no bypass, and the protection stays intact.
Permissions, quietly
One repo had its Actions default token permission set to read-only rather than write. I expected that to be a manual settings fix, but it wasn’t: a workflow-level permissions: contents: write block requests the elevated scope explicitly, and GitHub grants it regardless of the repo default. Declaring permissions in the workflow — which you should do anyway, as documentation of intent — also makes the workflow portable across repos with different defaults. All ten repos run the pipeline with zero settings changes.
Canary first
Rolling an untested workflow to ten repositories simultaneously is how you get ten identical failures. I applied the pipeline to one repo first — the smallest, least consequential one — watched the Actions run complete, and verified all three artefacts: the chore(release): 0.1.1 commit on main, the v0.1.1 tag, and the GitHub Release with correctly grouped notes. Only then did the other nine get the same files.
The first automated run across the fleet:
| Repository | First automated release |
|---|---|
| eac_archive_directory (canary) | v0.1.1 |
| codespace | v0.1.1 |
| academycollection-qa-playwright | v1.0.1 |
| academycollection-qa-cypress | v1.0.1 |
| armenian-institute-publishing | v0.1.1 |
| scatterpub-toolchain | v0.1.1 |
| scatterpub-toolchain-example | v0.1.1 |
| ocr-pipeline-demo | v0.1.1 |
| velostevie | v2.0.1 |
| scattercode.dev | v1.1.0, via the dev → main PR |
Every repo had accumulated a few conventional commits since its baseline tag, so every repo cut a patch release on its first run — a nice end-to-end proof that the whole chain works, from commit message grammar to published Release notes.
Teaching your AI assistant to maintain it
Here is the part I have started doing for every convention I adopt, and it deserves its own habit: write the convention into the AI assistant’s project instructions. This entire rollout was done in a Claude Code session, and the reason it could be is that each repo’s CLAUDE.md already documented the commit format and the hooks — the assistant read the rules and worked within them, writing every one of the rollout’s own commits as well-formed Conventional Commits. The release pipeline itself is the newest convention, so it gets the same treatment.
An automated release pipeline changes what a contributor — human or machine — must not do: hand-edit the changelog, or tag versions manually. Unless you say so, an eager assistant asked to “cut a release” will happily do both. So the project instructions need a section like this — CLAUDE.md for Claude Code, .github/copilot-instructions.md for GitHub Copilot, same content either way:
## Releases and changelog
Releases are fully automated — do not perform them by hand.
- Every push to `main` runs `.github/workflows/release.yml`: git-cliff
computes the next semantic version from the Conventional Commit
history, regenerates `CHANGELOG.md`, tags the version, and publishes
a GitHub Release.
- Never edit `CHANGELOG.md` manually — it is generated by git-cliff
from `cliff.toml`. To change what appears in it, fix the commit
messages or the `commit_parsers` in `cliff.toml`.
- Never create version tags or GitHub Releases manually.
- Version bumps follow the commit types: `fix` → patch, `feat` → minor,
a `!` breaking change → major. Choose commit types accordingly.
- Any commit generated by tooling must use `chore(release):` or
`chore(changelog):` so the changelog parser skips it.And if you are starting from a repo that doesn’t have the pipeline yet, the ask is a single prompt: “Add automated releases with git-cliff: on push to main, compute the next semver from conventional commits, regenerate CHANGELOG.md, tag, and publish a GitHub Release. Add skip parsers to cliff.toml so release commits stay out of the changelog, and use the built-in GITHUB_TOKEN so the release commit doesn’t retrigger workflows.” Every constraint in that sentence is one of this post’s gotchas — which is really the point: the prompt encodes the experience, and the instructions file keeps it encoded for every future session.
The last sentence of the previous post said the discipline should live in the tooling, because that is the only place discipline reliably survives. Instruction files are the same move one level up: the knowledge of how the tooling works also shouldn’t live in anyone’s head — not mine, and not in whatever the assistant happens to infer. Write it down where the machines read it.