A reference implementation for automated changelog and release workflows using Conventional Commits.
- PR title prefixes (e.g.,
feat:,fix:) validated against Conventional Commits - Breaking changes marked with
!(e.g.,feat!:) - PR labels assigned automatically based on the prefix
- GitHub Releases published automatically after a
u-prefixed version tag is pushed - Releases cut from any chosen commit, not only
main's head - Release notes and
CHANGELOG.mdgenerated from PR titles - A rolling
latesttag that always points at the newest release
Setting up assumes a GitHub repository with Actions enabled; only the
version-bump step is hatch-specific β any tool that commits a version
bump and pushes a u<version> tag (e.g., u1.2.3) can drive the rest of
the pipeline.
- User copies the five workflow files in
.github/workflows/and the release-notes configuration.github/release.yml. - User rewrites the import smoke test in
ci.ymlβ the module name and thelegendary_octo_happiness.hello()demo call β for their own package, and sets--pythonto theirrequires-pythonfloor. The mypy targetsrcneeds no change for a src-layout package.
Stock hatch only rewrites the version number; the hatch-regex-commit
plugin makes hatch version also create the bump commit and the u tag
that triggers the pipeline. User installs hatch locally and adds to
pyproject.toml:
[build-system]
requires = ["hatchling", "hatch-regex-commit"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "regex_commit"
path = "src/your_package/__about__.py"
tag_name = "u{new_version}"
tag_sign = false
check_dirty = falseUser replaces your_package with the package's import name and creates
the version file with an initial version (e.g., __version__ = "0.1.0");
[project] must list version under dynamic, as pyproject.toml in
this repository does.
- User sets the merge methods under
Settings>General>Pull Requests:- Disable "Allow merge commits"
- Enable "Allow squash merging", with "Default commit message" set to "Pull request title and description"
- Disable "Allow rebase merging"
- User documents the PR title convention for contributors (the allowed
type prefixes and the
!breaking-change marker); this repository's CONTRIBUTING.md serves as a template. - User creates no labels: a label missing when the labeler first applies
it is created automatically, with a gray default color. The twelve
labels β
breaking,feature,fix,documentation,style,refactor,performance,test,build,ci,chore, andrevertβ match the categories in.github/release.yml; that file and the labeler workflowconventional-label.ymlmust stay consistent if either changes. - Optional: User requires the "Conventional Commits" status check through
a branch ruleset β by default it reports a status that nothing
enforces. Any rule must still allow the pipeline's direct pushes to
main(the merge-back that lands eligible releases onmain), or releases will fail; a rule requiring linear history would also block the merge-commit case (a release cut from an older commit already onmain).
How a release is cut, and the design behind it.
- User checks out the commit to release:
git switch main && git pullfor the newest state, orgit switch --detach <commit>(e.g.,git switch --detach 1a2b3c4) for an older one. The chosen commit must already contain the pipeline's workflow files β a tag push runs the workflow files as of the tagged commit. - User bumps the version:
hatch version <rule>, where<rule>ispatch,minor, ormajor(e.g.,hatch version minor). This updatessrc/legendary_octo_happiness/__about__.py, creates the bump commit, and tags itu<version>(e.g.,u1.2.3). - User pushes only the tag, reading its name from hatch's output or
git tag:git push origin u1.2.3. (Frommain, pushing the branch and the tag together βgit push origin main u1.2.3β behaves the same.) - GitHub Actions ("Generate changelog") creates the branch
release/<version>(e.g.,release/1.2.3) at the tagged commit, commits the updatedCHANGELOG.mdon it, and tags that commitv<version>(e.g.,v1.2.3). - GitHub Actions ("Generate changelog") merges the release branch into
mainand deletes it β a fast-forward when cut frommain's head, a merge commit otherwise β provided the chosen commit is onmainand the newest existing release by version order is already part of its history; otherwise the release is a backport: GitHub Actions flags the run with a warning, the branch remains as the maintenance line, andmainis untouched. - GitHub Actions ("Release a new version") creates a GitHub Release from
the
vtag; when the new version is the newest by version order, the release is marked as the latest release and thelatesttag moves to it. - User confirms the new version on the Releases page; when the release is marked as the latest release, the GitHub Release badge above updates as well.
- User checks the repository's branches: a
release/<version>branch remains until its release merges intomain; once merged, it is deleted. After a merge-back, User returns tomainif needed (git switch main) and pulls the changelog commit and the new tags:git pull --tags --force origin main(--forcelets the movedlatesttag update; without it the fetch is rejected). Otherwise,mainhas nothing new;git fetch --tags --force originretrieves the release branch and the tags.
The automated steps appear as workflow runs on the Actions tab; the Changelog and Release badges above link to each workflow's runs.
If the "Generate changelog" run fails, a "Release a new version" run
still appears, but its job is skipped and no release is created. User
deletes the trigger tag on GitHub (git push origin --delete u1.2.3),
deletes the release/1.2.3 branch if the failed run left one behind
(git push origin --delete release/1.2.3), and, if the failed run had
already created the v tag (visible under the repository's tags),
deletes that tag too (git push origin --delete v1.2.3); then User fixes
the cause and pushes the trigger tag again (git push origin u1.2.3).
If the chosen commit predates the pipeline (the step 1 prerequisite), pushing the tag starts no workflow run at all β nothing appears on the Actions tab. User confirms the commit carries the workflow files, then bumps and tags again from a commit that does.
- One prefix, three outputs. Squash-only merging lands every PR as a
single commit whose message is the PR title, a validated Conventional
Commits line. That one line becomes three outputs: the commit message on
main, theCHANGELOG.mdsection git-cliff builds from it, and the release-notes category GitHub resolves through the label assigned from the same prefix (categories in.github/release.yml). - Two tags, one trigger. Pushing the
utag is the only manual step that triggers the pipeline. The changelog commit and thevtag are pushed with the workflow token, and GitHub starts no workflows for pushes made with that token β a guard against recursive workflows. The Release workflow therefore chains on the "Generate changelog" run completing (workflow_run) rather than on thevtag, and its job runs only if that run succeeded. - One release at a time. The pipeline assumes one release in progress
at a time; the next
utag is pushed only after the previous release has appeared. - Every release gets its own branch. When the
utag is pushed, GitHub Actions createsrelease/<version>at the tagged commit and builds the release there: the changelog commit and thevtag sit directly on top of the tagged commit. A release therefore contains exactly the commit that was tagged plus the changelog commit β a PR merged after the tag push is not included; it reaches the release line throughmainand the next release. mainfollows through the merge-back. GitHub Actions merges the release branch intomainand deletes it, provided the chosen commit is onmainand the newest existing release by version order is already part of its history β a fast-forward when the release was cut frommain's head, a merge commit otherwise. The merge-back pushes directly tomain, so branch-protection rules that block direct pushes would break it.- Backports. When the merge-back precondition fails β the chosen
commit is not on
main, or the newest existing release by version order is not part of its history β the merge-back is skipped and GitHub Actions flags the run with a warning:release/<version>remains as the maintenance line, andmain(including itsCHANGELOG.md) is untouched. The next release on that line is cut from the branch'sv-tagged commit by the same procedure; its changelog lives only on that branch βmain'sCHANGELOG.mdnever lists backports. - Permanent and rolling tags.
vtags are permanent; the GitHub Release is marked as the latest release and thelatesttag moves only when the new version is the newest by version order.
Cutting a release from main's own current head C β the simplest
case:
---o---o---C <- main
\
u1.2.3---v1.2.3 <- release/1.2.3
---o---o---C---u1.2.3---v1.2.3 <- main
Since C is already main's head, the merge-back is a fast-forward:
main simply extends through the branch's two commits, and
release/1.2.3 is deleted with no merge commit.
Cutting from an old commit C β one that already has commits after it
on main β needs a real merge instead:
---o---o---C---o---o <- main
\
u1.3.0---v1.3.0 <- release/1.3.0
---o---o---C---o---o-------M <- main
\ /
u1.3.0---v1.3.0 <- release/1.3.0 (deleted after merge)
A backport looks the same up to the branch, but the merge-back does not happen:
---o---C---o---u1.3.0---v1.3.0---o <- main
\
u1.2.4---v1.2.4 <- release/1.2.4 (kept, unmerged)
1.2.4 is cut from C, but 1.3.0 already landed after C β the newest
release (v1.3.0) is not an ancestor of C, so the merge-back is
skipped. release/1.2.4 stays as the 1.2.x maintenance line.
The next 1.2.x release branches off v1.2.4's commit as its own new
branch, not a continuation of the old one:
---o---C---o---u1.3.0---v1.3.0---o <- main
\
u1.2.4---v1.2.4 <- release/1.2.4 (kept)
|
+---u1.2.5---v1.2.5 <- release/1.2.5 (new branch)
release/1.2.5 forks from release/1.2.4's tip; release/1.2.4 itself
is untouched. release/1.2.5 is cut from a commit that is not on main
either, so it is itself a backport β its own merge-back is skipped by
the same rule, for the same reason.
Two releases cut from the same commit C around the same time (1.3.0
a minor bump, 2.0.0 a major bump β both reachable from the same
starting version, unlike two minor bumps would be):
+---u1.3.0---v1.3.0 <- release/1.3.0
/
---o---o---C <- main
\
+---u2.0.0---v2.0.0 <- release/2.0.0
Both branches are created and both build validly. Whichever merge-back
completes first β say 1.3.0 β advances main past C:
---o---o---C---u1.3.0---v1.3.0 <- main (1.3.0 merged first)
\
+---u2.0.0---v2.0.0 <- release/2.0.0 (unmerged)
2.0.0's precondition is now checked against this new state: is the
newest release (v1.3.0, which just landed) an ancestor of C β that
is, already part of C's history? No β so 2.0.0's merge-back is skipped
and flagged with a warning, exactly like a backport, even though it was
an ordinary release. This is why the pipeline assumes one release in
progress at a time: releasing 2.0.0 only after 1.3.0 has appeared means
its checkout starts from the already-advanced main, not the stale C.
2.0.0 is still the newest release by version order, so it is marked
latest regardless β leaving latest on a commit main never absorbs,
until someone resolves the situation manually.
The following workflows run on GitHub Actions:
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
PR, push to main |
Build sdist and wheel, import from the wheel, type-check with mypy |
pr-title.yml |
PR opened/edited/synchronize |
Validate the PR title against Conventional Commits |
conventional-label.yml |
PR opened/edited |
Label the PR from its title prefix |
changelog.yml |
u*.*.* tag pushed (e.g., u1.2.3) |
Generate CHANGELOG.md, create the v tag, merge back to main |
release.yml |
"Generate changelog" completed | Create the GitHub Release, move latest |
The changelog trigger requires the full three-part pattern: a u1.2 tag
starts nothing. The two PR-triggered workflows run on
pull_request_target, so labeling works on PRs from forks; neither checks
out the PR's code. The changelog and release workflows run with
contents: write; the labeler runs with pull-requests: write. The
workflows assume squash merging is the only enabled merge method, with
"Default commit message" set to "Pull request title and description".
Every action the workflows use is pinned to a commit SHA, with a comment recording the corresponding release version. TaiSakuma/changelog-commit and TaiSakuma/checkout-version-tag are composite actions developed for this project.
| Action | Used by | Purpose |
|---|---|---|
| TaiSakuma/changelog-commit | changelog.yml |
Generate the changelog, commit it, create the release tag |
| TaiSakuma/checkout-version-tag | release.yml |
Derive and check out the release tag from the trigger tag |
| amannn/action-semantic-pull-request | pr-title.yml |
Validate the PR title |
| bcoe/conventional-release-labels | conventional-label.yml |
Label the PR from its title prefix |
| EndBug/latest-tag | release.yml |
Move the latest tag |
| actions/checkout | ci.yml |
Check out the repository |
| astral-sh/setup-uv | ci.yml |
Install uv |