Skip to content

Commit 75b0ebb

Browse files
feat: generate CHANGELOG.md and GitHub release notes with git-cliff
- `ap init cliff` sets up afterpython/cliff.toml, `ap changelog` previews it - stable `ap bump` adds the release section to CHANGELOG.md and opens it in git's editor before committing (`--no-edit` to skip) - the release workflow writes the GitHub release notes with git-cliff - versions link to their GitHub compare page, (#123) links to the pull request Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 9b04c61 commit 75b0ebb

17 files changed

Lines changed: 890 additions & 11 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
steps:
2424
- name: Checkout code
2525
uses: actions/checkout@v7
26+
with:
27+
fetch-depth: 0 # full history and tags, for git-cliff's release notes
2628

2729
- name: Install uv
2830
uses: astral-sh/[email protected]
@@ -61,11 +63,45 @@ jobs:
6163
echo "prerelease=false" >> $GITHUB_OUTPUT
6264
fi
6365
66+
- name: Determine release notes range
67+
id: notes_range
68+
if: hashFiles('afterpython/cliff.toml') != ''
69+
run: |
70+
if [ "${{ steps.check_prerelease.outputs.prerelease }}" = "true" ]; then
71+
# pre-release: what's new since the previous tag of any kind (e.g. rc0 -> rc1)
72+
PREVIOUS="$(git describe --tags --abbrev=0 --match 'v*' "$GITHUB_REF_NAME^" 2>/dev/null || true)"
73+
ARGS=""
74+
else
75+
# stable: everything since the previous stable tag, pre-release tags in between are ignored
76+
# (the same section `ap bump` wrote to CHANGELOG.md)
77+
PREVIOUS="$(git describe --tags --abbrev=0 --match 'v*' \
78+
--exclude '*a[0-9]*' --exclude '*b[0-9]*' --exclude '*rc[0-9]*' --exclude '*dev[0-9]*' \
79+
"$GITHUB_REF_NAME^" 2>/dev/null || true)"
80+
ARGS="--ignore-tags '(a|b|rc|dev)[0-9]+'"
81+
fi
82+
# without a previous tag, the whole history up to this tag is this release
83+
if [ -n "$PREVIOUS" ]; then
84+
ARGS="$ARGS $PREVIOUS..$GITHUB_REF_NAME"
85+
fi
86+
echo "args=$ARGS" >> $GITHUB_OUTPUT
87+
88+
- name: Generate release notes
89+
id: git-cliff
90+
if: hashFiles('afterpython/cliff.toml') != ''
91+
uses: orhun/git-cliff-action@v4
92+
with:
93+
config: afterpython/cliff.toml
94+
args: --strip header ${{ steps.notes_range.outputs.args }}
95+
env:
96+
# the tag is checked out without a branch, so git-cliff needs the repo for its links
97+
GITHUB_REPO: ${{ github.repository }}
98+
6499
- name: Create GitHub Release
65100
uses: softprops/action-gh-release@v3
66101
with:
67102
tag_name: ${{ github.ref_name }}
68103
name: ${{ github.ref_name }}
104+
body: ${{ steps.git-cliff.outputs.content }} # empty without afterpython/cliff.toml
69105
generate_release_notes: false
70106
prerelease: ${{ steps.check_prerelease.outputs.prerelease }}
71107
files: dist/* # Attach built packages (wheel + sdist)

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
[uv]: https://docs.astral.sh/uv/
2727
[ruff]: https://docs.astral.sh/ruff/
2828
[ty]: https://docs.astral.sh/ty/
29-
29+
[git-cliff]: https://git-cliff.org/
3030

3131
## Problem
3232
Going from **writing Python code to publishing and maintaining a package** is **time-consuming**.
@@ -91,7 +91,8 @@ ap tui
9191
- [pdoc]
9292
- [uv]
9393
- [ruff]
94+
- [ty]
9495
- [pagefind]
9596
- [WebLLM]
96-
- [ty]
97+
- [git-cliff]
9798
<!-- - [pixi] -->

‎afterpython/cliff.toml‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# git-cliff configuration (changelog generation)
2+
# See More: https://git-cliff.org/docs/configuration
3+
4+
5+
[changelog]
6+
# Used by `--prepend` to remove the previous header.
7+
header_marker = "<!-- git-cliff: end of header -->"
8+
# A Tera template to be rendered for each release in the changelog.
9+
# See https://keats.github.io/tera/#getting-started
10+
# With a GitHub remote (git remote origin), versions link to their compare page
11+
# and (#123) in commit messages links to the pull request.
12+
body = """
13+
{% set repo = "https://github.com/" ~ remote.github.owner ~ "/" ~ remote.github.repo %}\
14+
{% if version %}\
15+
## [{{ version | trim_start_matches(pat="v") }}]\
16+
{% if previous.version and remote.github.repo %}({{ repo }}/compare/{{ previous.version }}...{{ version }}){% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
17+
{% else %}\
18+
## [unreleased]
19+
{% endif %}\
20+
{% for entry in commits | commit_groups(groups=commit_parsers_groups) %}
21+
### {{ entry.group | striptags | trim | upper_first }}
22+
{% for commit in entry.commits %}
23+
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
24+
{% if remote.github.repo %}\
25+
{{ commit.message | upper_first | replace_regex(from="\\(#([0-9]+)\\)", to="([#$1](" ~ repo ~ "/pull/$1))") }}\
26+
{% else %}\
27+
{{ commit.message | upper_first }}\
28+
{% endif %}\
29+
{% endfor %}
30+
{% endfor %}
31+
"""
32+
# Remove leading and trailing whitespaces from the changelog's body.
33+
trim = true
34+
# Render body even when there are no releases to process.
35+
render_always = true
36+
# An array of regex based postprocessors to modify the changelog.
37+
postprocessors = [
38+
# End each release with a blank line, so prepended releases are separated.
39+
{ pattern = '\n*\z', replace = "\n\n" },
40+
# Replace the placeholder <REPO> with a URL.
41+
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
42+
]
43+
# output file path
44+
# output = "test.md"
45+
46+
[git]
47+
# Parse commits according to the conventional commits specification.
48+
# See https://www.conventionalcommits.org
49+
conventional_commits = true
50+
# Exclude commits that do not match the conventional commits specification.
51+
filter_unconventional = true
52+
# Require all commits to be conventional.
53+
# Takes precedence over filter_unconventional.
54+
require_conventional = false
55+
# Split commits on newlines, treating each line as an individual commit.
56+
split_commits = false
57+
# An array of regex based parsers to modify commit messages prior to further processing.
58+
commit_preprocessors = [
59+
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
60+
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
61+
# Check spelling of the commit message using https://github.com/crate-ci/typos.
62+
# If the spelling is incorrect, it will be fixed automatically.
63+
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
64+
]
65+
# Prevent commits that are breaking from being excluded by commit parsers.
66+
protect_breaking_commits = true
67+
# An array of regex based parsers for extracting data from the commit message.
68+
# Assigns commits to groups.
69+
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
70+
commit_parsers = [
71+
# first, so breaking changes of any type get their own group
72+
{ field = "breaking", pattern = "true", group = "<!-- -1 -->💥 Breaking Changes" },
73+
{ message = "^bump", skip = true },
74+
{ message = "^build", skip = true },
75+
{ message = "^wip", skip = true },
76+
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
77+
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
78+
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
79+
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
80+
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
81+
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
82+
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
83+
{ message = "^chore\\(release\\): prepare for", skip = true },
84+
{ message = "^chore\\(deps.*\\)", skip = true },
85+
{ message = "^chore\\(pr\\)", skip = true },
86+
{ message = "^chore\\(pull\\)", skip = true },
87+
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
88+
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
89+
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
90+
{ message = ".*", group = "<!-- 10 -->💼 Other" },
91+
]
92+
# Exclude commits that are not matched by any commit parser.
93+
filter_commits = false
94+
# Fail on a commit that is not matched by any commit parser.
95+
fail_on_unmatched_commit = false
96+
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
97+
link_parsers = []
98+
# Leave out releases before 0.4.0 (and their commits), the changelog starts at 0.4.0.
99+
skip_tags = "v0\\.[0-3]\\..*"
100+
# Include only the tags that belong to the current branch.
101+
use_branch_tags = false
102+
# Order releases topologically instead of chronologically.
103+
topo_order = false
104+
# Order commits topologically instead of chronologically.
105+
topo_order_commits = true
106+
# Order of commits in each group/release within the changelog.
107+
# Allowed values: newest, oldest
108+
sort_commits = "oldest"
109+
# Process submodules commits
110+
recurse_submodules = false
111+
112+
[remote]
113+
# Don't fetch commits and pull requests from the GitHub API, the links only need
114+
# the repo (from the current branch's upstream, or $GITHUB_REPO in the release workflow).
115+
offline = true

‎afterpython/doc/package_maintenance/package_releases.md‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
[SemVer]: https://semver.org
33
[Python Versioning]: https://packaging.python.org/en/latest/discussions/versioning/
44
[PyPI]: https://pypi.org/
5+
[git-cliff]: https://git-cliff.org/
56

67

78
# Package Releases
89

910
## Version Bumping
1011
`ap bump` increments your project version using [uv]'s `uv version --bump` under the hood, then commits the change (`bump: version A → B`) and tags it (e.g. `v0.1.0`).
12+
Bumps to a stable version also add the new release's section to `CHANGELOG.md` in the same commit, see [Changelog](#changelog).
1113

1214
### Commands
1315
- `ap bump` — bump version within the current release phase
@@ -27,6 +29,7 @@ See [SemVer] and [Python Versioning] to learn more about versioning in Python pa
2729
---
2830
## PyPI and GitHub Releases
2931
If you agreed to create the release workflow during `ap init`, `afterpython` creates a GitHub Actions workflow (`.github/workflows/release.yml`) that publishes your package to [PyPI] and creates GitHub releases.
32+
The GitHub releases' notes are generated by [git-cliff], see [Release Notes](#release-notes).
3033

3134
### PyPI Setup
3235
To enable trusted publishing on PyPI:
@@ -56,4 +59,39 @@ To use an API token instead (e.g., `UV_PUBLISH_TOKEN`), you'll need to modify th
5659

5760

5861
---
59-
## Changlog 🚧
62+
## Changelog
63+
`afterpython` generates `CHANGELOG.md` from your commit messages with [git-cliff], configured in `afterpython/cliff.toml`.
64+
Commits are grouped by their [commit type](./commit_workflow.md#commit-types) (e.g. Features, Bug Fixes), and commits that don't follow the Conventional Commits format are left out.
65+
If your branch tracks a GitHub remote, each version links to its compare page on GitHub (e.g. `v0.3.19...v0.4.0`) and `(#123)` in a commit message links to the pull request.
66+
67+
`ap init` creates `afterpython/cliff.toml` from git-cliff's default configuration if you agree to it, or run `ap init cliff` later.
68+
Edit it to change what the changelog looks like, see git-cliff's [configuration](https://git-cliff.org/docs/configuration) docs.
69+
70+
Keep all your git-cliff settings in `afterpython/cliff.toml`, not in `pyproject.toml` (`[tool.git-cliff]`) or a `cliff.toml` at the project root.
71+
If `ap init` finds an existing git-cliff config there, it skips the git-cliff setup; remove it and run `ap init cliff` to set it up again.
72+
73+
### Commands
74+
- `ap changelog` — preview the unreleased changes, i.e. the section the next stable `ap bump` will add to `CHANGELOG.md`
75+
- `ap changelog <args>` — run git-cliff with `afterpython/cliff.toml` and your arguments instead, e.g.
76+
- `ap changelog --latest` — show the latest release
77+
- `ap changelog -o CHANGELOG.md` — regenerate the whole changelog from git history (**overwrites manual edits**)
78+
79+
### Updating the Changelog
80+
Every `ap bump` to a stable version (e.g. `0.3.19` → `0.3.20`, `0.4.0rc1` → `0.4.0`) adds the new release's section to the top of `CHANGELOG.md` and commits it together with the version change, so the tagged commit already contains its own release notes.
81+
- The first time, `CHANGELOG.md` is created from your whole git history.
82+
- After that, only the new section is added, so you can freely edit `CHANGELOG.md` (e.g. remove or reword small commits) and commit your edits; they are kept.
83+
- Before committing, `ap bump` opens `CHANGELOG.md` in git's editor (the one `git commit` uses) so you can edit the new section. Save and close it to commit the bump; to abort, press Ctrl+C or exit the editor with an error (e.g. `:cq` in vim), which undoes the whole bump. Use `ap bump --no-edit` to skip the editor.
84+
- GUI editors need to wait for the file to be closed, e.g. `git config --global core.editor "zed --wait"`.
85+
- Dev and pre-release bumps (e.g. `0.4.0rc0`) don't change `CHANGELOG.md`. Their commits are included in the next stable release's section, so `0.4.0`'s section lists everything since `0.3.19`.
86+
- Commit or stash your own edits to `CHANGELOG.md` before running `ap bump`.
87+
88+
### Release Notes
89+
The release workflow uses git-cliff to write the notes of each GitHub release:
90+
- stable releases (e.g. `v0.4.0`): everything since the previous stable release, the same as their `CHANGELOG.md` section
91+
- dev and pre-releases (e.g. `v0.4.0rc1`): what's new since the previous release (e.g. `v0.4.0rc0`)
92+
93+
Release notes are generated from git history, not from `CHANGELOG.md`, so manual edits to `CHANGELOG.md` don't show up in them.
94+
95+
:::{note}
96+
`CHANGELOG.md`, `ap changelog` and the release notes all need `afterpython/cliff.toml`; without it, `ap bump` doesn't touch `CHANGELOG.md` and GitHub releases have no notes.
97+
:::

‎afterpython/doc/references/roadmap.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@ This roadmap is tentative and subject to change
66

77
- AI chatbot like kapa.ai using WebLLM
88
- incremental build, only build changed content (for `ap dev`)
9-
- integrate with `git-cliff` for changelog generation
109
- supports docs built by different engines? e.g. Sphix, MkDocs
1110
- support google analytics
1211
- update `afterpython` itself using `ap update afterpython`
1312
- it merges the new defaults in a newer version of `afterpython` into your project
1413
- very difficult, need to create an interactive UX to show the diffs and let the user choose to merge or not
15-
- support python 3.14
1614
- integrate with `pixi`, supports `conda install`
1715
- support testing across multiple os in ci.yml based on `platforms` set in `pixi.toml`?

0 commit comments

Comments
 (0)