mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-10-26 14:42:56 +00:00
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:
58
.github/workflows/release-draft.yml
vendored
Normal file
58
.github/workflows/release-draft.yml
vendored
Normal 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
|
||||||
@@ -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
|
|
||||||
@@ -2500,4 +2500,4 @@ Other changes:
|
|||||||
- Initial commit. [GadgetReactor]
|
- 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)*
|
||||||
|
|||||||
347
RELEASING.md
347
RELEASING.md
@@ -1,320 +1,61 @@
|
|||||||
# Releasing
|
# 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
|
## 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
|
```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
|
- Close the release PR and delete the `release/x.y.z` branch on `upstream`.
|
||||||
export NEW_RELEASE=x.x.x.devx
|
- Run `prepare` again, then rewrite the summary.
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Patch releases
|
## Patch releases
|
||||||
|
|
||||||
This requires git commit signing to be enabled.
|
- 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.
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export NEW_RELEASE=x.x.x.x
|
# First patch after a release only, the release must contain cliff.toml and the workflows
|
||||||
export CURRENT_RELEASE=x.x.x
|
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
|
- If a commit conflicts, cherry-pick it on your own branch, resolve it and pass that commit.
|
||||||
git fetch upstream $CURRENT_RELEASE
|
- The entries have no PR information and all land in `Other changes`, sort them in the PR.
|
||||||
git checkout patch
|
- Merge the PR with a **merge** commit, not a squash.
|
||||||
git fetch upstream patch
|
- Publish the draft as above.
|
||||||
git rebase upstream/patch
|
- Patches are not merged back, so add the new section to `CHANGELOG.md` on master by hand:
|
||||||
git fetch upstream $CURRENT_RELEASE
|
- paste it above the release the patch is based on,
|
||||||
git merge $CURRENT_RELEASE --ff-only
|
- open a PR labelled `release-prep`,
|
||||||
git push upstream patch -u
|
- skipping this only leaves the patch out of the changelog on master.
|
||||||
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`
|
|
||||||
|
|||||||
56
cliff.toml
Normal file
56
cliff.toml
Normal 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
371
devtools/release.py
Executable 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"])
|
||||||
@@ -107,6 +107,7 @@ exclude_lines = [
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"tests",
|
"tests",
|
||||||
|
"devtools/release.py",
|
||||||
]
|
]
|
||||||
markers = [
|
markers = [
|
||||||
"requires_dummy: test requires dummy data to pass, skipped on real devices",
|
"requires_dummy: test requires dummy data to pass, skipped on real devices",
|
||||||
@@ -177,6 +178,10 @@ convention = "pep257"
|
|||||||
"devtools/*.py" = [
|
"devtools/*.py" = [
|
||||||
"ANN",
|
"ANN",
|
||||||
]
|
]
|
||||||
|
# The tests are at the end of the file
|
||||||
|
"devtools/release.py" = [
|
||||||
|
"S101",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|||||||
Reference in New Issue
Block a user