Generate the changelog with git-cliff and draft releases in CI (#1809)

Replaces `github_changelog_generator` and the manual release steps with a script and a workflow.
This commit is contained in:
Teemu R.
2026-10-24 16:24:08 +02:00
committed by GitHub
parent 30f5e4a1b9
commit c60f48df23
7 changed files with 535 additions and 315 deletions

58
.github/workflows/release-draft.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
---
name: Draft Release
# Runs when a release PR from a release/* branch (devtools/release.py prepare) is merged.
# If it fails, re-run it from the Actions tab.
on:
pull_request:
types: [closed]
branches:
- master
- patch
permissions: {}
jobs:
draft-release:
name: Create Draft Release
# The branch name is used to find the version as the title and labels can be edited.
if: >-
github.event.pull_request.merged
&& github.event.pull_request.head.repo.full_name == github.repository
&& startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Source Files
id: checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
# release.py declares its dependencies inline and is run by uv.
- name: Setup uv
id: setup-uv
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
version-file: uv.lock
# The draft has no tag yet, GitHub creates it at --target when the release is published.
# That in turn starts publish.yml.
- name: Create Draft Release
id: release
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
TARGET_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
version="${HEAD_REF#release/}"
if [[ ! $version =~ ^[0-9]+(\.[0-9]+){2,3}$ ]]; then
echo "::error::Invalid version: $version"
exit 1
fi
devtools/release.py extract CHANGELOG.md "$version" > notes.md
gh release create "$version" --draft --target "$TARGET_SHA" \
--title "$version" --notes-file notes.md --latest=true

View File

@@ -1,11 +0,0 @@
output=CHANGELOG.md
base=HISTORY.md
user=python-kasa
project=python-kasa
since-tag=0.3.5
release-branch=master
usernames-as-github-logins=true
breaking_labels=breaking change
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
exclude-labels=duplicate,question,invalid,wontfix,release-prep,stale
issues-wo-labels=false

View File

@@ -2500,4 +2500,4 @@ Other changes:
- Initial commit. [GadgetReactor]
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
\* *Entries up to 0.11.0.1 were generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator), later entries by [git-cliff](https://git-cliff.org)*

View File

@@ -1,320 +1,61 @@
# Releasing
- `devtools/release.py prepare` opens a release PR with the version bump and the changelog section generated by [git-cliff](https://git-cliff.org) (`cliff.toml`).
- Merging the PR runs the `Draft Release` workflow, which creates a draft GitHub release from `CHANGELOG.md`.
- Publishing the draft creates the tag and runs `Publish Packages`, which uploads to PyPI.
- Tests for the script: `uv run pytest devtools/release.py`.
## Requirements
* [github client](https://github.com/cli/cli#installation)
* [gitchub_changelog_generator](https://github.com/github-changelog-generator)
* [github access token](https://github.com/github-changelog-generator/github-changelog-generator#github-token)
## Export changelog token
- `git`, `uv` and the [github client](https://github.com/cli/cli#installation) (`gh auth login`).
- Push access to the `upstream` remote, set `REMOTE` to use another name.
- The `release-prep` label exists, PRs with it are left out of the changelog.
## Changelog entries
- Every PR merged since the previous release is an entry, titled like the PR.
- The label selects the section, first match wins: `breaking change`, `enhancement`, `bug`, `new device`, `documentation`, `maintenance`/`dependencies`.
- Other PRs go to `Other changes`, so fix the labels first.
- Left out: `duplicate`, `question`, `invalid`, `wontfix`, `release-prep`, `stale`.
- To fix an entry, relabel or retitle the PR and [prepare again](#preparing-again), or edit `CHANGELOG.md` in the release PR.
## Normal releases
```bash
export CHANGELOG_GITHUB_TOKEN=token
devtools/release.py prepare x.y.z # preview, nothing is pushed
devtools/release.py prepare --push x.y.z # push release/x.y.z and open the PR
```
## Set release information
- Optional, in a separate PR before releasing: `uv lock --upgrade` and `pre-commit autoupdate`.
- Add the release summary at the top of the new section in `CHANGELOG.md` on the PR branch.
- Squash-merge the PR once CI passes, promptly: PRs merged later are not in the section.
- Open the draft release linked in the `Draft Release` run, check it and click `Publish release`.
- If the workflow fails, re-run it from the `Actions` tab.
0.3.5 should always be the previous release as it's the last pyhs100 release in HISTORY.md which is the changelog prior to github release notes.
## Preparing again
```bash
export NEW_RELEASE=x.x.x.devx
```
## Normal releases from master
### Create a branch for the release
```bash
git checkout master
git fetch upstream master
git rebase upstream/master
git checkout -b release/$NEW_RELEASE
```
### Update the version number
```bash
sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml
```
### Update dependencies
```bash
uv sync --all-extras
uv lock --upgrade
uv sync --all-extras
```
### Update and run pre-commit and tests
```bash
pre-commit autoupdate
uv run pre-commit run --all-files
uv run pytest -n auto
```
### Create release summary (skip for dev releases)
Write a short and understandable summary for the release. Can include images.
#### Create $NEW_RELEASE milestone in github
If not already created
#### Create new issue linked to the milestone
```bash
gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "**Release summary:**"
```
You can exclude the --body option to get an interactive editor or go into the issue on github and edit there.
#### Close the issue
Either via github or:
```bash
gh issue close ISSUE_NUMBER
```
### Generate changelog
Configuration settings are in `.github_changelog_generator`
#### For pre-release
EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags.
Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match group negative matches on the current release and the second matches on releases ending with dev.
```bash
EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+"
echo "$EXCLUDE_TAGS"
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS"
```
#### For production
```bash
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$'
```
You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master:
```
Warning: PR 908 merge commit was not found in the release branch or tagged git history and no rebased SHA comment was found
```
### Export new release notes to variable
```bash
export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' )
echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary
```
### Commit and push the changed files
```bash
git commit --all --verbose -m "Prepare $NEW_RELEASE"
git push upstream release/$NEW_RELEASE -u
```
### Create a PR for the release, merge it, and re-fetch the master
#### Create the PR
```
gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master
```
To update the PR after refreshing the changelog:
```
gh pr edit --body "$RELEASE_NOTES"
```
#### Merge the PR once the CI passes
Create a squash commit and add the markdown from the PR description to the commit description.
```bash
gh pr merge --squash --body "$RELEASE_NOTES"
```
### Rebase local master
```bash
git checkout master
git fetch upstream master
git rebase upstream/master
```
### Create a release tag
Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft.
```bash
git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES"
git push upstream $NEW_RELEASE
```
### Create release
#### Pre-releases
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease
```
#### Production release
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true
```
### Manually publish the release
Go to the linked URL, verify the contents, and click "release" button to trigger the release CI.
- Close the release PR and delete the `release/x.y.z` branch on `upstream`.
- Run `prepare` again, then rewrite the summary.
## Patch releases
This requires git commit signing to be enabled.
https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification
### Create release branch
#### For the first patch release since a new release only
- Made from the `patch` branch, git commit signing must be enabled.
- Only the given commits are included, they are cherry-picked with `-x` and signed.
```bash
export NEW_RELEASE=x.x.x.x
export CURRENT_RELEASE=x.x.x
# First patch after a release only, the release must contain cliff.toml and the workflows
git fetch upstream --tags
git push upstream x.y.z:refs/heads/patch
devtools/release.py prepare --base patch x.y.z.n commitSHA1 commitSHA2 # preview
devtools/release.py prepare --base patch --push x.y.z.n commitSHA1 commitSHA2
```
```bash
git fetch upstream $CURRENT_RELEASE
git checkout patch
git fetch upstream patch
git rebase upstream/patch
git fetch upstream $CURRENT_RELEASE
git merge $CURRENT_RELEASE --ff-only
git push upstream patch -u
git checkout -b release/$NEW_RELEASE
```
#### For subsequent patch releases
```bash
export NEW_RELEASE=x.x.x.x
```
```bash
git checkout patch
git fetch upstream patch
git rebase upstream/patch
git checkout -b release/$NEW_RELEASE
```
### Cherry pick required commits
```bash
git cherry-pick commitSHA1 -S
git cherry-pick commitSHA2 -S
```
### Update the version number
```bash
sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml
```
### Manually edit the changelog
github_changlog generator_does not work with patch releases so manually add the section for the new release to CHANGELOG.md.
### Export new release notes to variable
```bash
export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' )
echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary
```
### Commit and push the changed files
```bash
git commit --all --verbose -m "Prepare $NEW_RELEASE" -S
git push upstream release/$NEW_RELEASE -u
```
### Create a PR for the release, merge it, and re-fetch patch
#### Create the PR
```
gh pr create --title "$NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base patch
```
#### Merge the PR once the CI passes
Create a **merge** commit and add the markdown from the PR description to the commit description.
```bash
gh pr merge --merge --body "$RELEASE_NOTES"
```
### Rebase local patch
```bash
git checkout patch
git fetch upstream patch
git rebase upstream/patch
```
### Create a release tag
```bash
git tag -s --annotate $NEW_RELEASE -m "$RELEASE_NOTES"
git push upstream $NEW_RELEASE
```
### Create release
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true
```
Then go into github, review and release
### Merge patch back to master
```bash
git checkout master
git fetch upstream master
git rebase upstream/master
git checkout -b janitor/merge_patch
git fetch upstream patch
git merge upstream/patch --no-commit
# If there are any merge conflicts run the following command which will simply make master win
# Do not run it if there are no conflicts as it will end up checking out upstream/master
git diff --name-only --diff-filter=U | xargs git checkout upstream/master
# Check the diff is as expected
git diff --staged
# The only diff should be the version in pyproject.toml and uv.lock, and CHANGELOG.md
# unless a change made on patch that was not part of a cherry-pick commit
# If there are any other unexpected diffs `git checkout upstream/master [thefilename]`
git commit -m "Merge patch into local master" -S
git push upstream janitor/merge_patch -u
gh pr create --title "Merge patch into master" --body '' --label release-prep --base master
```
#### Temporarily allow merge commits to master
1. Open [repository settings](https://github.com/python-kasa/python-kasa/settings)
2. From the left select `Rules` > `Rulesets`
3. Open `master` ruleset, under `Bypass list` select `+ Add bypass`
4. Check `Repository admin` > `Add selected`, select `Save changes`
#### Merge commit the PR
```bash
gh pr merge --merge --body ""
```
#### Revert allow merge commits
1. Under `Bypass list` select `...` next to `Repository admins`
2. `Delete bypass`, select `Save changes`
- If a commit conflicts, cherry-pick it on your own branch, resolve it and pass that commit.
- The entries have no PR information and all land in `Other changes`, sort them in the PR.
- Merge the PR with a **merge** commit, not a squash.
- Publish the draft as above.
- Patches are not merged back, so add the new section to `CHANGELOG.md` on master by hand:
- paste it above the release the patch is based on,
- open a PR labelled `release-prep`,
- skipping this only leaves the patch out of the changelog on master.

56
cliff.toml Normal file
View File

@@ -0,0 +1,56 @@
# git-cliff configuration, run by devtools/release.py (see RELEASING.md).
#
# The entries are the squash-merged PRs since the previous release, with title,
# number, labels and author read from the GitHub API.
# To fix an entry, retitle or relabel the PR before preparing the release,
# or edit the section in the release PR.
# Commits without a PR (e.g. cherry-picks on the patch branch) are listed with a commit link.
[changelog]
body = """
{% if version -%}
## [{{ version }}](https://github.com/python-kasa/python-kasa/tree/{{ version }}) ({{ timestamp | date(format="%Y-%m-%d") }})
{% if previous.version %}
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/{{ previous.version }}...{{ version }})
{% endif -%}
{% else -%}
## Unreleased
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
**{{ group | striptags | trim }}:**
{% for commit in commits -%}
{% if commit.remote.pr_number -%}
- {{ commit.remote.pr_title | trim }} [\\#{{ commit.remote.pr_number }}](https://github.com/python-kasa/python-kasa/pull/{{ commit.remote.pr_number }}) (@{{ commit.remote.pr_author | default(value=commit.remote.username) }})
{% else -%}
- {{ commit.message | split(pat="\n") | first | trim }} [{{ commit.id | truncate(length=7, end="") }}](https://github.com/python-kasa/python-kasa/commit/{{ commit.id }})
{% endif -%}
{% endfor -%}
{% endfor -%}
"""
trim = true
[git]
conventional_commits = false
filter_unconventional = false
# Old dev tags (x.y.z.devN) are skipped, their changes are part of the next real release.
tag_pattern = "[0-9]+\\.[0-9]+.*"
skip_tags = "\\.dev[0-9]+$"
topo_order_commits = true
sort_commits = "newest"
# The section is chosen by PR label, the first matching parser wins.
# git-cliff matches the pattern against each label separately, so anchor it.
# Groups are sorted alphabetically, the number prefix sets the order and the template strips it.
# PRs without any of these labels end up in the last group.
# The commits made by devtools/release.py on the release branch are not entries.
commit_parsers = [
{ message = "^Prepare [0-9]", skip = true },
{ field = "remote.pr_labels", pattern = "^(duplicate|question|invalid|wontfix|release-prep|stale)$", skip = true },
{ field = "remote.pr_labels", pattern = "^breaking change$", group = "<!-- 0 -->Breaking changes" },
{ field = "remote.pr_labels", pattern = "^enhancement$", group = "<!-- 1 -->Implemented enhancements" },
{ field = "remote.pr_labels", pattern = "^bug$", group = "<!-- 2 -->Fixed bugs" },
{ field = "remote.pr_labels", pattern = "^new device$", group = "<!-- 3 -->Added support for devices" },
{ field = "remote.pr_labels", pattern = "^documentation$", group = "<!-- 4 -->Documentation updates" },
{ field = "remote.pr_labels", pattern = "^(maintenance|dependencies)$", group = "<!-- 5 -->Project maintenance" },
{ message = ".*", group = "<!-- 6 -->Other changes" },
]

371
devtools/release.py Executable file
View File

@@ -0,0 +1,371 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "git-cliff==2.14.2",
# "packaging==26.3",
# ]
# ///
"""Release tooling, see RELEASING.md.
release.py prepare [--base master|patch] [--push] VERSION [COMMIT...]
Preview the release PR, or with --push open it.
release.py extract CHANGELOG.md VERSION
Print the notes of one release, used as the GitHub release notes.
"""
from __future__ import annotations
import argparse
import contextlib
import os
import re
import subprocess
import sys
import tempfile
from collections.abc import Iterator
from pathlib import Path
from packaging.version import InvalidVersion, Version
REPO = "python-kasa/python-kasa"
# The remote of python-kasa/python-kasa, override if it is named differently.
REMOTE = os.environ.get("REMOTE", "upstream")
# Sections of CHANGELOG.md start with a "## [version](...)" heading, newest first.
HEADING = re.compile(r"^## \[(?P<version>[^\]]+)\]", re.MULTILINE)
RELEASE_VERSION = re.compile(r"\d+(\.\d+){2,3}")
def _parse(version: str) -> Version:
"""Parse the version of a changelog heading."""
try:
return Version(version)
except InvalidVersion:
raise ValueError(f"Unsupported version in changelog: {version!r}") from None
def _span(text: str, version: str) -> tuple[int, int] | None:
"""Return the start and end offset of the section of ``version``."""
matches = list(HEADING.finditer(text))
for idx, match in enumerate(matches):
if match["version"] == version:
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
return match.start(), end
return None
def extract(text: str, version: str) -> str:
"""Return the notes of ``version``, without the heading."""
span = _span(text, version)
if span is None:
raise ValueError(f"No section for {version} found")
start = text.index("\n", span[0]) + 1
return text[start : span[1]].strip() + "\n"
def insert(text: str, section: str) -> str:
"""Insert a section above the first section with a lower version."""
section = section.strip() + "\n\n"
first = HEADING.match(section)
if not first:
raise ValueError("The section needs to start with a '## [version]' heading")
key = _parse(first["version"])
for match in HEADING.finditer(text):
existing = _parse(match["version"])
if existing == key:
raise ValueError(f"{first['version']} is already in the changelog")
if existing < key:
return text[: match.start()] + section + text[match.start() :]
raise ValueError(f"No older release than {first['version']} found")
def run(
*cmd: str,
capture: bool = False,
stdin: str | None = None,
env: dict[str, str] | None = None,
cwd: Path | None = None,
) -> str:
"""Run a command, echoing it, and raise if it fails."""
print("+", " ".join(cmd), file=sys.stderr)
result = subprocess.run( # noqa: S603
cmd,
check=True,
text=True,
capture_output=capture,
input=stdin,
env=env,
cwd=cwd,
)
return result.stdout if capture else ""
@contextlib.contextmanager
def worktree(rev: str) -> Iterator[Path]:
"""Check out ``rev`` in a temporary worktree, to leave the user's checkout alone."""
with tempfile.TemporaryDirectory(prefix="release-") as tmp:
path = Path(tmp) / "checkout"
run("git", "worktree", "add", "--quiet", "--detach", str(path), rev)
try:
yield path
finally:
run("git", "worktree", "remove", "--force", str(path))
def remote_branch_exists(branch: str) -> bool:
"""Return True if the branch exists on the remote."""
return bool(run("git", "ls-remote", REMOTE, f"refs/heads/{branch}", capture=True))
def generate_section(version: str, checkout: Path) -> str:
"""Generate the changelog section of ``version`` with git-cliff."""
token = run("gh", "auth", "token", capture=True).strip()
env = {**os.environ, "GITHUB_TOKEN": token}
cmd = ["git-cliff", "--github-repo", REPO, "--unreleased", "--strip", "header"]
cmd += ["--tag", version]
return run(*cmd, capture=True, env=env, cwd=checkout)
def update_changelog(checkout: Path, section: str) -> None:
"""Add the section to CHANGELOG.md of the checkout."""
path = checkout / "CHANGELOG.md"
changelog = insert(path.read_text(encoding="utf-8"), section)
path.write_text(changelog, encoding="utf-8")
def publish_pr(version: str, base: str, branch: str, section: str) -> None:
"""Open the release PR."""
create = ["gh", "pr", "create", "--repo", REPO, "--base", base, "--head", branch]
create += ["--title", f"Prepare {version}", "--label", "release-prep"]
run(*create, "--body-file", "-", stdin=section)
def commit_changes(
checkout: Path, message: str, branch: str, sign: list[str], *, push: bool
) -> None:
"""Commit the staged changes, then push them or show what would be pushed."""
# The hooks of the user's checkout also run in the worktree, CI lints the PR.
run("git", "commit", "--no-verify", "--message", message, *sign, cwd=checkout)
if push:
run("git", "push", REMOTE, f"HEAD:refs/heads/{branch}", cwd=checkout)
else:
run("git", "show", "--stat", "--format=%h %s", cwd=checkout)
def create_branch(
version: str,
base: str,
branch: str,
commits: list[str],
sign: list[str],
*,
push: bool,
) -> str:
"""Create the release branch on the remote and return the changelog section."""
for commit in commits:
try:
run("git", "rev-parse", "--verify", f"{commit}^{{commit}}", capture=True)
except subprocess.CalledProcessError:
raise ValueError(f"Unknown commit: {commit}") from None
with worktree(f"{REMOTE}/{base}") as checkout:
if commits:
try:
run("git", "cherry-pick", "-x", *sign, *commits, cwd=checkout)
except subprocess.CalledProcessError:
# There is nothing to abort if the failure was not a conflict
with contextlib.suppress(subprocess.CalledProcessError):
run("git", "cherry-pick", "--abort", cwd=checkout)
raise ValueError(
"Cherry-picking failed, if the commits conflict with "
f"{REMOTE}/{base} cherry-pick them on a branch of your own, "
"resolve the conflicts and pass the resulting commits instead"
) from None
run("uv", "version", version, "--no-sync", cwd=checkout)
section = generate_section(version, checkout)
update_changelog(checkout, section)
run("git", "add", "pyproject.toml", "uv.lock", "CHANGELOG.md", cwd=checkout)
commit_changes(checkout, f"Prepare {version}", branch, sign, push=push)
return section
def prepare(
version: str,
base: str,
commits: list[str],
*,
push: bool = False,
) -> None:
"""Create the release PR for ``version``."""
if commits and base != "patch":
raise ValueError("Commits can only be cherry-picked with --base patch")
# Patch releases require signed commits
sign = ["-S"] if base == "patch" else []
branch = f"release/{version}"
run("git", "fetch", REMOTE, base, "--tags")
# Without the config git-cliff silently falls back to its default template.
try:
run("git", "cat-file", "-e", f"{REMOTE}/{base}:cliff.toml", capture=True)
except subprocess.CalledProcessError:
raise ValueError(f"cliff.toml is missing from {REMOTE}/{base}") from None
if remote_branch_exists(branch):
raise ValueError(
f"{branch} exists on {REMOTE}, to prepare the release again "
"close its PR and delete the branch"
)
section = create_branch(version, base, branch, commits, sign, push=push)
if not push:
print(section)
print(f"Dry run, add --push to push {branch} and open the PR.", file=sys.stderr)
return
publish_pr(version, base, branch, section)
print("Review CHANGELOG.md in the PR, fix entries and add the summary there.")
print("Merge the PR once CI passes, a draft release is created automatically.")
def _release_version(value: str) -> str:
"""Accept x.y.z and x.y.z.n as release versions."""
if not RELEASE_VERSION.fullmatch(value) or str(Version(value)) != value:
raise argparse.ArgumentTypeError(f"invalid version: {value}")
return value
def main() -> int:
"""Run the command line interface."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
commands = parser.add_subparsers(dest="command", required=True)
prepare_parser = commands.add_parser("prepare", help="preview or open the PR")
prepare_parser.add_argument("--base", choices=["master", "patch"], default="master")
prepare_parser.add_argument(
"--push",
action="store_true",
help="push the branch and open the PR, by default only a preview",
)
prepare_parser.add_argument("version", type=_release_version)
prepare_parser.add_argument("commits", nargs="*", help="commits to cherry-pick")
extract_parser = commands.add_parser("extract", help="print one release section")
extract_parser.add_argument("file", type=Path)
extract_parser.add_argument("version")
args = parser.parse_args()
try:
if args.command == "prepare":
prepare(args.version, args.base, args.commits, push=args.push)
else:
text = args.file.read_text(encoding="utf-8")
sys.stdout.write(extract(text, args.version))
except (ValueError, subprocess.CalledProcessError) as ex:
print(f"error: {ex}", file=sys.stderr)
# Captured output (git-cliff, gh) is otherwise lost.
if isinstance(ex, subprocess.CalledProcessError) and ex.stderr:
print(ex.stderr, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
if "pytest" in sys.modules:
# Tests, run with: uv run pytest devtools/release.py
# pytest is only imported when it runs this file, the script does not need it.
import pytest
def _versions(changelog: str) -> list[str]:
return [match["version"] for match in HEADING.finditer(changelog)]
@pytest.fixture
def changelog() -> str:
"""Return a changelog with a release, a patch release and a legacy heading."""
return (
"# Changelog\n\n"
"## [0.12.0](https://example.com/0.12.0) (2026-10-12)\n\n- new\n\n"
"## [0.11.0.1](https://example.com/0.11.0.1) (2026-10-04)\n\n- fix\n\n"
"## [0.10.2](https://example.com/0.10.2) (2025-02-12)\n\n- old\n\n"
"## [0.4.0.pre0](https://example.com/0.4.0.pre0) (2020-05-27)\n\n- old\n"
)
@pytest.mark.parametrize(
("version", "notes"),
[("0.12.0", "- new\n"), ("0.11.0.1", "- fix\n"), ("0.4.0.pre0", "- old\n")],
)
def test_extract(changelog: str, version: str, notes: str) -> None:
"""The notes are the section without its heading, also for the last one."""
assert extract(changelog, version) == notes
def test_extract_unknown_version(changelog: str) -> None:
"""A release that is not in the changelog is an error."""
with pytest.raises(ValueError, match="No section for 0.13.0"):
extract(changelog, "0.13.0")
@pytest.mark.parametrize(
("version", "expected"),
[
("0.13.0", ["0.13.0", "0.12.0", "0.11.0.1", "0.10.2", "0.4.0.pre0"]),
("0.11.0.2", ["0.12.0", "0.11.0.2", "0.11.0.1", "0.10.2", "0.4.0.pre0"]),
("0.10.2.1", ["0.12.0", "0.11.0.1", "0.10.2.1", "0.10.2", "0.4.0.pre0"]),
("0.5.0", ["0.12.0", "0.11.0.1", "0.10.2", "0.5.0", "0.4.0.pre0"]),
],
)
def test_insert_orders_by_version(
changelog: str, version: str, expected: list[str]
) -> None:
"""Sections are inserted by version, a patch release lands below newer ones."""
section = f"## [{version}](https://example.com/{version})\n\n- entry\n"
assert _versions(insert(changelog, section)) == expected
def test_insert_existing_release(changelog: str) -> None:
"""Inserting a release that is in the changelog is an error."""
section = "## [0.11.0.1](https://example.com/0.11.0.1)\n\n- again\n"
with pytest.raises(ValueError, match="already in the changelog"):
insert(changelog, section)
@pytest.mark.parametrize(
("section", "message"),
[
("- no heading\n", "needs to start with"),
("## [0.1.0](https://example.com/0.1.0)\n", "No older release"),
],
)
def test_insert_errors(changelog: str, section: str, message: str) -> None:
"""A section without a heading or older than everything is not inserted."""
with pytest.raises(ValueError, match=message):
insert(changelog, section)
@pytest.mark.parametrize("value", ["0.12.0", "0.11.0.1"])
def test_release_version_accepts(value: str) -> None:
"""Releases and patch releases are accepted."""
assert _release_version(value) == value
@pytest.mark.parametrize(
"value",
["0.12", "0.12.0rc1", "0.12.0.dev1", "v0.12.0", "0.12.0.1.1", "0.12.00"],
)
def test_release_version_rejects(value: str) -> None:
"""Other version forms would not match the tag and branch names."""
with pytest.raises(argparse.ArgumentTypeError):
_release_version(value)
def test_prepare_rejects_cherry_pick_without_patch_base() -> None:
"""Cherry-picks are rejected before anything is run."""
with pytest.raises(ValueError, match="only be cherry-picked with --base patch"):
prepare("0.12.0", "master", ["abc"])

View File

@@ -107,6 +107,7 @@ exclude_lines = [
[tool.pytest.ini_options]
testpaths = [
"tests",
"devtools/release.py",
]
markers = [
"requires_dummy: test requires dummy data to pass, skipped on real devices",
@@ -177,6 +178,10 @@ convention = "pep257"
"devtools/*.py" = [
"ANN",
]
# The tests are at the end of the file
"devtools/release.py" = [
"S101",
]
[tool.mypy]