diff --git a/.github/workflows/release-draft.yml b/.github/workflows/release-draft.yml new file mode 100644 index 00000000..a076bdf4 --- /dev/null +++ b/.github/workflows/release-draft.yml @@ -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 diff --git a/.github_changelog_generator b/.github_changelog_generator deleted file mode 100644 index 538e6ec1..00000000 --- a/.github_changelog_generator +++ /dev/null @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c379d0..c6bf9275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)* diff --git a/RELEASING.md b/RELEASING.md index b5587d60..3be19941 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..5ff13baf --- /dev/null +++ b/cliff.toml @@ -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 = "Breaking changes" }, + { field = "remote.pr_labels", pattern = "^enhancement$", group = "Implemented enhancements" }, + { field = "remote.pr_labels", pattern = "^bug$", group = "Fixed bugs" }, + { field = "remote.pr_labels", pattern = "^new device$", group = "Added support for devices" }, + { field = "remote.pr_labels", pattern = "^documentation$", group = "Documentation updates" }, + { field = "remote.pr_labels", pattern = "^(maintenance|dependencies)$", group = "Project maintenance" }, + { message = ".*", group = "Other changes" }, +] diff --git a/devtools/release.py b/devtools/release.py new file mode 100755 index 00000000..f15c9f50 --- /dev/null +++ b/devtools/release.py @@ -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[^\]]+)\]", 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"]) diff --git a/pyproject.toml b/pyproject.toml index a9c0f58a..2fbcf1e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]