Skip to content

Commit 6b266d3

Browse files
committed
✨ Template: Add changelog generation script and hatch run bump
Generated packages had no mechanism for producing release notes. Developers had to write changelogs entirely by hand, or rely on external tools. Add `dev/update_changelog.py` that parses git history since the latest tag and sorts commits into emoji-based sections matching the project's commit conventions. User-facing changes (breaking, deps, deprecations, features, improvements, bugs) get top-level headings; developer-oriented changes (docs, refactor, tests, reverts, devops, cleanup) are grouped under a "Developer" section with subheadings. Commits without a recognized emoji land in a visible "Uncategorized" section at the top with per-commit warnings. PR numbers from squash-merges are stripped, and each entry links to its commit on GitHub (falling back to plain hashes if the remote is not GitHub). A new `hatch run bump <version>` command wraps `hatch version` and the changelog script into a single step, supporting all Hatch version syntax (`minor`, `patch`, `major,rc`, explicit versions, etc.).
1 parent 4c056a9 commit 6b266d3

File tree

5 files changed

+205
-18
lines changed

5 files changed

+205
-18
lines changed

docs/dev-standards.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,23 @@ Some advantages:
8383
The list in the table below is in order of priority, e.g. a backwards-incompatible change might improve an existing feature by breaking its API, but should _not_ be typed as an improvement (👌).
8484
Similarly, if a dependency is changed, it's convenient to quickly spot this, e.g. when updating a conda feedstock.
8585

86-
| Emoji | Meaning | Similar to [Angular type](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines) | In changelog summary? |
86+
| Emoji | Meaning | Similar to [Angular type](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines) | Changelog |
8787
| ----- | ---------------------------------------------------------------- | ----------------------------------------- | ------------------------- |
88-
| 💥 | introduce a backward-incompatible (breaking) change / remove deprecated code | `\<type>!` (use `!` + `BREAKING CHANGE:`) | **Yes** |
89-
| 📦 | add, update or change a dependency | `build` | **Yes** |
90-
|| introduce new features | `feat` | **Yes** |
91-
| 👌 | improve an existing code/feature (no breaking) | `perf`/`feat` | **Yes** |
92-
| 🐛 | fix a code bug | `fix` | **Yes** |
93-
|| mark code as deprecated (note removal version/replacement) | `refactor` | **Yes** |
94-
| 📚 | add or adapt documentation | `docs` | No |
95-
| 🔄 | refactor existing code with no behavior change | `refactor` | No |
96-
| 🧪 | add or adapt tests | `test` | No |
88+
| 💥 | introduce a backward-incompatible (breaking) change / remove deprecated code | `\<type>!` (use `!` + `BREAKING CHANGE:`) | Breaking changes |
89+
| 📦 | add, update or change a dependency | `build` | Dependency updates |
90+
|| mark code as deprecated (note removal version/replacement) | `refactor` | Deprecations |
91+
|| introduce new features | `feat` | New features |
92+
| 👌 | improve an existing code/feature (no breaking) | `perf`/`feat` | Improvements |
93+
| 🐛 | fix a code bug | `fix` | Bug fixes |
94+
| | **Developer-facing** | | |
95+
| 📚 | add or adapt documentation | `docs` | Developer |
96+
| 🔄 | refactor existing code with no behavior change | `refactor` | Developer |
97+
| 🧪 | add or adapt tests | `test` | Developer |
98+
|| revert a previous commit | `revert` | Developer |
99+
| 🔧 | devops-related changes (pre-commit, CI/CD, etc.) | `ci` | Developer |
100+
| 🧹 | clean up comments / small formatting | `style` | Developer |
101+
| | **Excluded** | | |
97102
| 🚀 | bump the package version for release | `chore` | No |
98-
| 🧹 | clean up comments / small formatting | `style` | No |
99-
|| revert a previous commit | `revert` | No |
100-
| 🔧 | devops-related changes (pre-commit, CI/CD, etc.) | `ci` | No |
101103
| 🐭 | minor changes (typos etc.; exclude from changelog) | `chore` | No |
102104
|| anything not covered above (last resort) | `chore` | No |
103105

template/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Update `CHANGELOG.md` based on commits since the latest release tag.
2+
3+
Commit type conventions: https://mbercx.github.io/python-copier/dev-standards/#commit-messages
4+
"""
5+
6+
# ruff: noqa: S603, S607
7+
8+
import re
9+
import subprocess
10+
from pathlib import Path
11+
12+
from {{ package_name.lower().replace('-', '_') }}.__about__ import __version__
13+
14+
ROOT = Path(__file__).resolve().parent.parent
15+
GIT_REMOTE = "origin"
16+
17+
CHANGELOG_SECTIONS: dict[str, str] = {
18+
"💥": "Breaking changes",
19+
"📦": "Dependency updates",
20+
"❌": "Deprecations",
21+
"✨": "New features",
22+
"👌": "Improvements",
23+
"🐛": "Bug fixes",
24+
}
25+
26+
DEVELOPER_SECTIONS: dict[str, str] = {
27+
"📚": "Documentation",
28+
"🔄": "Refactor",
29+
"🧪": "Tests",
30+
"⏪": "Reverts",
31+
"🔧": "DevOps",
32+
"🧹": "Cleanup",
33+
}
34+
35+
ALL_SECTIONS = CHANGELOG_SECTIONS | DEVELOPER_SECTIONS
36+
37+
EXCLUDED_EMOJIS: set[str] = {"🚀", "🐭", "❓"}
38+
39+
40+
def get_github_url() -> str | None:
41+
"""Derive `https://github.com/org/repo` from the git remote origin, or `None`."""
42+
try:
43+
url = subprocess.run(
44+
["git", "remote", "get-url", GIT_REMOTE],
45+
capture_output=True, check=True, encoding="utf-8", cwd=ROOT,
46+
).stdout.strip()
47+
except subprocess.CalledProcessError:
48+
return None
49+
50+
match = re.match(r"(?:https://github\.com/|git@github\.com:)(.+?)(?:\.git)?$", url)
51+
return f"https://github.com/{match.group(1)}" if match else None
52+
53+
54+
def get_latest_tag() -> str | None:
55+
"""Return the latest `vX.Y.Z` tag, or `None` if no tags exist."""
56+
result = subprocess.run(
57+
["git", "tag", "--sort=v:refname"],
58+
capture_output=True, check=True, encoding="utf-8", cwd=ROOT,
59+
)
60+
tags = [t for t in result.stdout.splitlines() if re.fullmatch(r"v\d+\.\d+\.\d+\S*", t)]
61+
return tags[-1] if tags else None
62+
63+
64+
def get_commits(since_tag: str | None) -> str:
65+
"""Return the `git log` output since the given tag, or all commits if `None`."""
66+
cmd = ["git", "log", "--pretty=format:%h|%H|%s"]
67+
if since_tag:
68+
cmd.append(f"{since_tag}..HEAD")
69+
return subprocess.run(
70+
cmd, capture_output=True, check=True, encoding="utf-8", cwd=ROOT,
71+
).stdout
72+
73+
74+
def classify_commit(message: str) -> tuple[str | None, str]:
75+
"""Return `(emoji, stripped_message)` or `(None, message)` if not a changelog type."""
76+
for emoji in ALL_SECTIONS:
77+
if message.startswith(emoji):
78+
return emoji, message[len(emoji):].lstrip()
79+
return None, message
80+
81+
82+
def update_changelog() -> None:
83+
"""Update `CHANGELOG.md` for a first draft of the release."""
84+
version = __version__
85+
86+
changelog_path = ROOT / "CHANGELOG.md"
87+
current = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else ""
88+
89+
if f"## v{version}" in current:
90+
print(f"🔄 Version v{version} already in CHANGELOG.md. Skipping.")
91+
return
92+
93+
github_url = get_github_url()
94+
if github_url is None:
95+
print(f"⚠️ Could not derive GitHub URL from remote '{GIT_REMOTE}'. Commit links will use plain hashes.")
96+
97+
latest_tag = get_latest_tag()
98+
commits_raw = get_commits(latest_tag)
99+
100+
if not commits_raw.strip():
101+
print("🤷 No commits found since last tag. Skipping.")
102+
return
103+
104+
pr_pattern = re.compile(r"\s*\(#\d+\)$")
105+
106+
sections: dict[str, list[str]] = {emoji: [] for emoji in ALL_SECTIONS}
107+
uncategorized: list[str] = []
108+
109+
for line in commits_raw.splitlines():
110+
if not line:
111+
continue
112+
hash_short, hash_long, message = line.split("|", maxsplit=2)
113+
114+
# Strip PR number from the message
115+
message = pr_pattern.sub("", message)
116+
117+
# Classify by leading emoji
118+
emoji, stripped_msg = classify_commit(message)
119+
120+
if emoji is None and any(message.startswith(e) for e in EXCLUDED_EMOJIS):
121+
continue
122+
123+
if github_url:
124+
entry = f"* {stripped_msg} [[{hash_short}]({github_url}/commit/{hash_long})]"
125+
else:
126+
entry = f"* {stripped_msg} [{hash_short}]"
127+
128+
if emoji is None:
129+
uncategorized.append(entry)
130+
print(f"⚠️ Uncategorized commit: {hash_short} {message}")
131+
else:
132+
sections[emoji].append(entry)
133+
134+
# Build changelog: uncategorized first to improve visibility
135+
section_text = ""
136+
if uncategorized:
137+
section_text += "\n### ❓ Uncategorized\n\n"
138+
section_text += "\n".join(uncategorized) + "\n"
139+
140+
# Main changelog sections -> User oriented
141+
for emoji, section_name in CHANGELOG_SECTIONS.items():
142+
if sections[emoji]:
143+
section_text += f"\n### {emoji} {section_name}\n\n"
144+
section_text += "\n".join(sections[emoji]) + "\n"
145+
146+
# Developer section with H4 subsections
147+
dev_text = ""
148+
for emoji, section_name in DEVELOPER_SECTIONS.items():
149+
if sections[emoji]:
150+
dev_text += f"\n#### {emoji} {section_name}\n\n"
151+
dev_text += "\n".join(sections[emoji]) + "\n"
152+
153+
if dev_text:
154+
section_text += f"\n### Developer\n{dev_text}"
155+
156+
header = "# Changelog\n\n"
157+
body = current.removeprefix("# Changelog").lstrip("\n")
158+
new_entry = f"## v{version}\n{section_text}"
159+
changelog_path.write_text(header + new_entry + "\n" + body, encoding="utf-8")
160+
print(f"✨ Updated CHANGELOG.md for v{version}.")
161+
162+
163+
if __name__ == "__main__":
164+
update_changelog()

template/docs/developer.md.jinja

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,13 @@ And the following rules for the files in the `tests` directory:
356356
| `INP001` | [implicit-namespace-package](https://docs.astral.sh/ruff/rules/implicit-namespace-package/) | When tests are not part of the package, there is no need for `__init__.py` files. |
357357
| `S101` | [assert](https://docs.astral.sh/ruff/rules/assert/) | Asserts should not be used in production environments, but are fine for tests. |
358358

359+
And the following rules for the files in the `dev` directory:
360+
361+
| Code | Rule | Rationale / Note |
362+
| --------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
363+
| `INP001` | [implicit-namespace-package](https://docs.astral.sh/ruff/rules/implicit-namespace-package/) | Dev scripts are not part of the package, so there is no need for `__init__.py` files. |
364+
| `T201` | [print](https://docs.astral.sh/ruff/rules/print/) | Dev scripts use `print()` for user-facing output, which is fine outside of library code. |
365+
359366
## Release
360367

361368
{% if docs == 'myst' -%}
@@ -373,19 +380,27 @@ See the [`python-copier` first-publication guide](https://mbercx.github.io/pytho
373380
Releases of `{{ package_name }}` are cut by pushing a `vX.Y.Z` tag to GitHub.
374381
The `cd` workflow under `.github/workflows/cd.yaml` then builds an sdist and wheel with Hatch and publishes them to PyPI.
375382

376-
1. Bump the version in `src/{{ package_name }}/__about__.py` to the new release version.
377-
The easiest way is:
383+
1. Bump the version and generate the changelog draft:
378384

379-
hatch version <new-version>
385+
hatch run bump <new-version>
380386

381-
Commit the bump on `main` (e.g. via a PR).
387+
This runs `hatch version` to update `src/{{ package_name }}/__about__.py`, then runs `dev/update_changelog.py` to prepend a new section to `CHANGELOG.md` with commits sorted by type.
388+
Review the generated changelog, make any edits, and commit the bump on `main` (typically via a PR).
382389

383390
2. Tag the bump commit and push the tag:
384391

385392
git tag -a v<new-version> -m '🚀 Release v<new-version>'
386393
git push origin v<new-version>
387394

388-
3. The `cd` workflow picks up the tag, builds the distributions, and publishes them to PyPI.
395+
3. The `cd.yaml` workflow picks up the tag, builds the distributions, and publishes them to PyPI.
389396

390397
The git tag and the version in `__about__.py` must agree.
391398
PyPI only sees the version baked into the built distribution, so a mismatch will silently publish under the wrong version (or be rejected as a duplicate of an existing release), and re-tagging after the fact is awkward.
399+
400+
## Commit messages
401+
402+
We use a leading emoji to indicate the type of change in each commit.
403+
The changelog script (`dev/update_changelog.py`) uses these emojis to automatically sort commits into the right sections.
404+
This means that the sorting in types happens at commit time, when the changes are still fresh in memory.
405+
406+
For the full specification and emoji table, see the [commit message conventions](https://mbercx.github.io/python-copier/dev-standards/#commit-messages).

template/pyproject.toml.jinja

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ path = "src/{{ package_name.lower().replace('-', '_') }}/__about__.py"
5252

5353
[tool.hatch.envs.default]
5454
installer = 'uv'
55+
scripts.bump = [
56+
"hatch version {args}",
57+
"python dev/update_changelog.py",
58+
]
5559

5660
[tool.hatch.envs.docs]
5761
features = ["docs"]
@@ -90,6 +94,7 @@ lint.ignore = [
9094
]
9195
[tool.ruff.lint.per-file-ignores]
9296
"tests/**/*.py" = ["INP001", "S101"]
97+
"dev/**/*.py" = ["INP001", "T201"]
9398
{%- if type_check == 'loose' %}
9499

95100
[tool.mypy]

0 commit comments

Comments
 (0)