From f5b3e875aefc9846ff7be12c143a3609483d288e Mon Sep 17 00:00:00 2001 From: getzze Date: Mon, 16 Sep 2024 22:48:03 +0100 Subject: [PATCH] add release scripts, actions and documentation --- .github/workflows/prepare-release-pr.yaml | 53 ++++++ .github/workflows/publish.yaml | 123 +++++++------- .github/workflows/tag-release.yaml | 58 +++++++ MANIFEST.in | 2 + RELEASING.md | 93 +++++++++++ changelog.d/1186.change.rst | 1 + pyproject.toml | 2 +- scripts/.gitignore | 1 + scripts/prepare-release-pr.py | 194 ++++++++++++++++++++++ scripts/release.py | 148 +++++++++++++++++ tox.ini | 19 +++ 11 files changed, 631 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/prepare-release-pr.yaml create mode 100644 .github/workflows/tag-release.yaml create mode 100644 RELEASING.md create mode 100644 changelog.d/1186.change.rst create mode 100644 scripts/.gitignore create mode 100644 scripts/prepare-release-pr.py create mode 100644 scripts/release.py diff --git a/.github/workflows/prepare-release-pr.yaml b/.github/workflows/prepare-release-pr.yaml new file mode 100644 index 00000000..cf7490bb --- /dev/null +++ b/.github/workflows/prepare-release-pr.yaml @@ -0,0 +1,53 @@ +name: Prepare release PR + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to base the release from' + required: false + default: 'main' + bump: + description: | + 'Release type: major, minor or patch. ' + 'Leave empty for autommatic detection based on changelog segments.' + required: false + default: '' + prerelease: + description: 'Prerelease (ex: rc1). Leave empty if not a pre-release.' + required: false + default: '' + +env: + FORCE_COLOR: "1" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: .python-version-default + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install --upgrade setuptools tox + + - name: Prepare release PR + env: + BRANCH: ${{ github.event.inputs.branch }} + BUMP: ${{ github.event.inputs.bump }} + PRERELEASE: ${{ github.event.inputs.prerelease }} + run: | + tox -e prepare-release-pr -- ${BRANCH} ${{ github.token }} --bump='${BUMP}' --prerelease='${PRERELEASE}' diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 186faf2f..768d5a17 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -4,6 +4,10 @@ on: push: tags: - '*' + workflow_run: + workflows: ["Tag release"] + types: + - completed release: types: - published @@ -15,85 +19,80 @@ env: # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ jobs: - deploy: + # Always build & lint package. + build-package: + name: Build & verify package runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: + fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@v5 + + - uses: hynek/build-and-inspect-python-package@v2 + id: baipp + + outputs: + # Used to define the matrix for tests below. The value is based on + # packaging metadata (trove classifiers). + supported-python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} + + github-release: + name: Make a GitHub Release + needs: [build-package] + # only publish a Github release on push tag + if: | + github.repository == 'Diaoul/subliminal' + && ( + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + || github.event_name == 'workflow_run' + ) + runs-on: ubuntu-latest + + permissions: + # IMPORTANT: mandatory for making GitHub Releases + contents: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - uses: actions/upload-artifact@v4 + fetch-depth: 0 + + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v4 with: name: Packages - path: dist/* + path: dist + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + draft: true publish-to-pypi: - needs: [deploy] + name: Publish package to pypi. + needs: [build-package] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/subliminal permissions: + # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - runs-on: ubuntu-latest - # only publish to PyPI on tag pushes - if: startsWith(github.ref, 'refs/tags/') + # only publish to PyPI on Github release published + if: | + github.repository == 'Diaoul/subliminal' + && github.event_name == 'release' + && github.event.action == 'published' steps: - - uses: actions/download-artifact@v4 + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v4 with: name: Packages path: dist - - name: Publish package + - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - github-release: - name: >- - Sign the Python 🐍 distribution 📦 with Sigstore - and upload them to GitHub Release - needs: [publish-to-pypi] - runs-on: ubuntu-latest - - permissions: - contents: write # IMPORTANT: mandatory for making GitHub Releases - id-token: write # IMPORTANT: mandatory for sigstore - - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: Packages - path: dist/ - - name: Sign the dists with Sigstore - uses: sigstore/gh-action-sigstore-python@v2.1.1 - with: - inputs: >- - ./dist/*.tar.gz - ./dist/*.whl - - name: Create GitHub Release Draft - env: - GITHUB_TOKEN: ${{ github.token }} - VERSION: ${{ github.ref_name }} - run: >- - gh release create - '${VERSION}' - --repo '${{ github.repository }}' - --generate-notes - --notes "" - - name: Upload artifact signatures to GitHub Release - env: - GITHUB_TOKEN: ${{ github.token }} - VERSION: ${{ github.ref_name }} - # Upload to GitHub Release using the `gh` CLI. - # `dist/` contains the built packages, and the - # sigstore-produced signatures and certificates. - run: >- - gh release upload - '${VERSION}' dist/** - --repo '${{ github.repository }}' diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml new file mode 100644 index 00000000..96a8a9ea --- /dev/null +++ b/.github/workflows/tag-release.yaml @@ -0,0 +1,58 @@ +name: Tag release + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + version: + description: 'Release tag version.' + type: string + default: NONE + required: true + +env: + FORCE_COLOR: "1" + +# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ +jobs: + # Always build & lint package. + build-package: + name: Build & verify package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: hynek/build-and-inspect-python-package@v2 + + tag: + name: Tag a new release + # tag a release after a release PR was accepted + if: | + github.event_name == 'pull_request' + && github.event.action == 'closed' + && github.event.pull_request.merged == true + && startsWith(github.head_ref, 'release-') + needs: [build-package] + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Tag the commit + run: | + RELEASE_VERSION=${GITHUB_HEAD_REF#release-} + echo Release version: $RELEASE_VERSION + git config user.name 'subliminal bot' + git config user.email diaoulael@gmail.com + git tag --annotate --message="Release version $RELEASE_VERSION" $RELEASE_VERSION ${{ github.sha }} + git push origin $RELEASE_VERSION diff --git a/MANIFEST.in b/MANIFEST.in index 5e4af6bd..ce566f3a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE include HISTORY.rst include CONTRIBUTING.md +include RELEASING.md include Dockerfile include .dockerignore include .pre-commit-config.yaml @@ -10,6 +11,7 @@ include tox.ini graft docs graft tests +graft scripts prune changelog.d prune */__pycache__ diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..8e93c547 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,93 @@ +# Release procedure + +The git commands assume the following remotes are setup: + +* ``origin``: your own fork of the repository. +* ``upstream``: the ``Diaoul/subliminal`` official repository. + +## Preparing a new release: Manual method + +There are few steps to follow when making a new release: + +1. Lint the code, check types, test, check the coverage is high enough +and build and test the documentation. + +2. Bump the version number, wherever it is, and update ``HISTORY.rst`` +with the changelog fragments. + +3. Tag the new version with ``git``. + +4. Publish the source distribution and wheel to Pypi. + +Although this can all be done manually, there is an automated way, +to limit errors. + +## Preparing a new release: Automatic method + +We use an automated workflow for releases, that uses GitHub workflows and is triggered +by [manually running](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) +the [prepare-release-pr workflow](https://github.com/Diaoul/subliminal/actions/workflows/prepare-release-pr.yaml) +on GitHub Actions. + +1. The automation will decide the new version number based on the following criteria: + +- If there is any ``.breaking.rst`` files in the ``changelog.d`` directory, release a new major release + (e.g. 7.0.0 -> 8.0.0) +- If there are any ``.change.rst`` files in the + ``changelog.d`` directory, release a new minor release + (e.g. 7.0.0 -> 7.1.0) +- Otherwise, release a patch release + (e.g. 7.0.0 -> 7.0.1) +- If the "prerelease" input is set, append the string to the version number + (e.g. 7.0.0 -> 8.0.0rc1, if "major" is set, and "prerelease" is set to `rc1`) + +The choice of the bumped version can be bypassed by the "bump" input +(empty choice means automatic bumped version detection). + +2. Trigger the workflow with the following inputs: + + - branch: **main** + - bump: [**empty**, major, minor, patch] + - prerelease: empty + +Or via the commandline:: + + gh workflow run prepare-release-pr.yml -f branch=main -f bump=major -f prerelease= + +The automated workflow will publish a PR for a branch ``release-8.0.0``. + + +## Preparing a new release: Semi-automatic method + +To release a version ``MAJOR.MINOR.PATCH-PRERELEASE``, follow these steps: + +* Create a branch ``release-MAJOR.MINOR.PATCH-PRERELEASE`` from the ``upstream/main`` branch. + + Ensure your are updated and in a clean working tree. + +* Using ``tox``, generate docs, changelog, announcements:: + + $ tox -e release -- MAJOR.MINOR.PATCH-PRERELEASE + + This will generate a commit with all the changes ready for pushing. + +* Push the ``release-MAJOR.MINOR.PATCH-PRERELEASE`` local branch to the remote +``upstream/release-MAJOR.MINOR.PATCH-PRERELEASE`` + +* Open a PR for the ``release-MAJOR.MINOR.PATCH-PRERELEASE`` branch targeting ``upstream/main``. + + +## Releasing + +Both automatic and manual processes described above follow the same steps from this point onward. + +* After all tests pass and the PR has been approved, merge the PR. + Merging the PR will trigger the + [tag-release workflow](https://github.com/Diaoul/subliminal/actions/workflows/tag-release.yaml), that will add a release tag. + + This new tag will then trigger the + [publish workflow](https://github.com/Diaoul/subliminal/actions/workflows/publish.yaml), + using the ``release-MAJOR.MINOR.PATCH`` branch as source. + + This job will publish a draft for a Github release. + When the Github release draft is published, the same workflow will publish to PyPI. diff --git a/changelog.d/1186.change.rst b/changelog.d/1186.change.rst new file mode 100644 index 00000000..f3794a0a --- /dev/null +++ b/changelog.d/1186.change.rst @@ -0,0 +1 @@ +Add release scripts, documentation and Github Actions diff --git a/pyproject.toml b/pyproject.toml index 25061877..3f52cff9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,7 +238,7 @@ name = "subliminal" package = "subliminal" directory = "changelog.d" filename = "HISTORY.rst" -title_format = "`version `_ - {project_date}" +title_format = "`v{version} `_ ({project_date})" issue_format = "`#{issue} `_" underlines = ["^", "-", "~"] diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 00000000..50a75b62 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +latest-release-notes.md diff --git a/scripts/prepare-release-pr.py b/scripts/prepare-release-pr.py new file mode 100644 index 00000000..009f7763 --- /dev/null +++ b/scripts/prepare-release-pr.py @@ -0,0 +1,194 @@ +# mypy: disallow-untyped-defs +# ruff: noqa: S603, S607 +"""Prepare release pull-request. + +This script is part of the pytest release process which is triggered manually in the Actions +tab of the repository. + +The user will need to enter the base branch to start the release from (for example +``6.1.x`` or ``main``) and if it should be a major release. + +The appropriate version will be obtained based on the given branch automatically. + +After that, it will create a release using the `release` tox environment, and push a new PR. + +**Token**: currently the token from the GitHub Actions is used, pushed with +`pytest bot ` commit author. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path +from subprocess import check_call, check_output, run +from typing import TYPE_CHECKING + +from colorama import Fore, init + +if TYPE_CHECKING: + from github3.repos import Repository + + +class InvalidFeatureRelease(Exception): # noqa: D101 + pass + + +SLUG = 'Diaoul/subliminal' + +PR_BODY = """\ +Created by the [prepare release pr]\ +(https://github.com/Diaoul/subliminal/actions/workflows/prepare-release-pr.yaml) workflow. + +Once all builds pass and it has been **approved** by one or more maintainers and **merged**, +it will trigger the [publish]\ +(https://github.com/Diaoul/subliminat/actions/workflows/publish.yaml) workflow that will: + +* Tag the commit with version `{version}`. +* Create a Github release for version `{version}`. +* Upload version `{version}` to Pypi. + +This is all done automatically when this PR is merged. +""" + + +def login(token: str) -> Repository: + """Login to Github.""" + import github3 + + github = github3.login(token=token) + owner, repo = SLUG.split('/') + return github.repository(owner, repo) + + +def find_next_version(base_branch: str, *, is_major: bool, is_minor: bool, prerelease: str) -> str: + """Find the next version, being a major, minor or patch bump.""" + output = check_output(['git', 'tag'], encoding='UTF-8') + valid_versions: list[tuple[int, ...]] = [] + for v in output.splitlines(): + # Match 'major.minor.patch', do not match tags of pre-release versions + m = re.match(r'v?(\d+)\.(\d+)\.(\d+)$', v.strip()) + if m: + valid_versions.append(tuple(int(x) for x in m.groups())) + + valid_versions.sort() + last_version = valid_versions[-1] + + print(f'Current version from git tag: {Fore.CYAN}{last_version}') + bump_str = 'major' if is_major else 'minor' if is_minor else 'patch' + print(f'Bump {bump_str} version') + + if is_major: + return f'{last_version[0]+1}.0.0{prerelease}' + if is_minor: + return f'{last_version[0]}.{last_version[1] + 1}.0{prerelease}' + return f'{last_version[0]}.{last_version[1]}.{last_version[2] + 1}{prerelease}' + + +def prepare_release_pr(base_branch: str, bump: str, token: str, prerelease: str) -> None: + """Find the bumped version and make a release PR.""" + print() + print(f'Processing release for branch {Fore.CYAN}{base_branch}') + + check_call(['git', 'checkout', f'origin/{base_branch}']) + + changelog = Path('changelog.d') + + breaking = list(changelog.glob('*.breaking.rst')) + is_major = bool(breaking) or bool(bump == 'major') + features = list(changelog.glob('*.change.rst')) + is_minor = not is_major and (bool(features) or bool(bump == 'minor')) + + try: + version = find_next_version( + base_branch, + is_major=is_major, + is_minor=is_minor, + prerelease=prerelease, + ) + except InvalidFeatureRelease as e: + print(f'{Fore.RED}{e}') + raise SystemExit(1) from None + + print(f'Version: {Fore.CYAN}{version}') + + release_branch = f'release-{version}' + + run( + ['git', 'config', 'user.name', 'subliminal bot'], + check=True, + ) + run( + ['git', 'config', 'user.email', 'diaoulael@gmail.com'], + check=True, + ) + + run( + ['git', 'checkout', '-b', release_branch, f'origin/{base_branch}'], + check=True, + ) + + print(f'Branch {Fore.CYAN}{release_branch}{Fore.RESET} created.') + + if is_major: + template_name = 'release.major.rst' + elif prerelease: + template_name = 'release.pre.rst' + elif is_minor: + template_name = 'release.minor.rst' + else: + template_name = 'release.patch.rst' + + # important to use tox here because we have changed branches, so dependencies + # might have changed as well + cmdline = [ + 'tox', + '-e', + 'release', + '--', + version, + template_name, + release_branch, # doc_version + ] + print('Running', ' '.join(cmdline)) + run( + cmdline, + check=True, + ) + + oauth_url = f'https://{token}:x-oauth-basic@github.com/{SLUG}.git' + run( + ['git', 'push', oauth_url, f'HEAD:{release_branch}', '--force'], + check=True, + ) + print(f'Branch {Fore.CYAN}{release_branch}{Fore.RESET} pushed.') + + body = PR_BODY.format(version=version) + repo = login(token) + pr = repo.create_pull( + f'Prepare release {version}', + base=base_branch, + head=release_branch, + body=body, + ) + print(f'Pull request {Fore.CYAN}{pr.url}{Fore.RESET} created.') + + +def main() -> None: # noqa: D103 + init(autoreset=True) + parser = argparse.ArgumentParser() + parser.add_argument('base_branch') + parser.add_argument('token') + parser.add_argument('--bump', default='') + parser.add_argument('--prerelease', default='') + options = parser.parse_args() + prepare_release_pr( + base_branch=options.base_branch, + bump=options.bump, + token=options.token, + prerelease=options.prerelease, + ) + + +if __name__ == '__main__': + main() diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 00000000..e1670be8 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,148 @@ +# mypy: disallow-untyped-defs +# ruff: noqa: S603, S607 +"""Invoke development tasks.""" + +from __future__ import annotations + +import argparse +import os +import re +from pathlib import Path +from subprocess import check_call, check_output + +from colorama import Fore, init + +VERSION_REGEX = r'(__version__(?:\s*\:\s*str)?\s*=\s*(?P[\'"]))(?P\d+\.\d+\.\d+.*)((?P=quote))' +VERSION_FILE = 'subliminal/__init__.py' + + +def announce(version: str, template_name: str, doc_version: str) -> None: + """Generates a new release announcement entry in the docs.""" + # Get our list of authors + stdout = check_output(['git', 'describe', '--abbrev=0', '--tags'], encoding='UTF-8') + last_version = stdout.strip() + + stdout = check_output(['git', 'log', f'{last_version}..HEAD', '--format=%aN'], encoding='UTF-8') + + contributors = {name for name in stdout.splitlines() if not name.endswith('[bot]') and name != 'pytest bot'} + + template_text = Path(__file__).parent.joinpath(template_name).read_text(encoding='UTF-8') + + contributors_text = '\n'.join(f'* {name}' for name in sorted(contributors)) + '\n' + text = template_text.format(version=version, contributors=contributors_text, doc_version=doc_version) + + target = Path(__file__).parent.joinpath(f'../doc/en/announce/release-{version}.rst') + target.write_text(text, encoding='UTF-8') + print(f'{Fore.CYAN}[generate.announce] {Fore.RESET}Generated {target.name}') + + # Update index with the new release entry + index_path = Path(__file__).parent.joinpath('../doc/en/announce/index.rst') + lines = index_path.read_text(encoding='UTF-8').splitlines() + indent = ' ' + for index, line in enumerate(lines): + if line.startswith(f'{indent}release-'): + new_line = indent + target.stem + if line != new_line: + lines.insert(index, new_line) + index_path.write_text('\n'.join(lines) + '\n', encoding='UTF-8') + print(f'{Fore.CYAN}[generate.announce] {Fore.RESET}Updated {index_path.name}') + else: + print(f'{Fore.CYAN}[generate.announce] {Fore.RESET}Skip {index_path.name} (already contains release)') + break + + check_call(['git', 'add', str(target)]) + + +def regen(version: str) -> None: + """Call regendoc tool to update examples and pytest output in the docs.""" + print(f'{Fore.CYAN}[generate.regen] {Fore.RESET}Updating docs') + check_call( + ['tox', '-e', 'regen'], + env={**os.environ, 'SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST': version}, + ) + + +def fix_formatting() -> None: + """Runs pre-commit in all files to ensure they are formatted correctly.""" + print(f'{Fore.CYAN}[generate.fix_formatting] {Fore.RESET}Fixing formatting using pre-commit') + check_call(['tox', '-e', 'pre-commit']) + + +def check_docs() -> None: + """Runs sphinx-build to check docs.""" + print(f'{Fore.CYAN}[generate.check_docs] {Fore.RESET}Checking docs') + check_call(['tox', '-e', 'docs']) + + +def changelog(version: str, *, write_out: bool = False) -> None: + """Call towncrier to generate the changelog.""" + addopts = [] if write_out else ['--draft'] + check_call(['towncrier', 'build', '--yes', '--version', version, *addopts]) + + +def bump_version(version: str) -> None: + """Bump the version in the file.""" + print(f'{Fore.CYAN}[generate.bump_version] {Fore.RESET}Bump version to {version} in {VERSION_FILE}') + pattern = re.compile(VERSION_REGEX) + file = Path(__file__).parent / '..' / VERSION_FILE + + content = file.open().read() + repl = r'\g<1>' + version + r'\g<2>' + new_content, n_matches = re.subn(pattern, repl, content) + if n_matches == 0: + print() + print(f'No `__version__` definition was found in file {VERSION_FILE}') + print() + return + + # Update file content + file.open('w').write(new_content) + + +def pre_release(version: str, template_name: str, doc_version: str, *, skip_check_docs: bool) -> None: + """Generates new docs and update the version.""" + # announce(version, template_name, doc_version) + # regen(version) + changelog(version, write_out=True) + fix_formatting() + if not skip_check_docs: + check_docs() + bump_version(version) + + msg = f'Prepare release version {version}' + check_call(['git', 'commit', '-a', '-m', msg]) + + print() + print(f'{Fore.CYAN}[generate.pre_release] {Fore.GREEN}All done!') + print() + print('Please push your branch and open a PR.') + + +def main() -> None: # noqa: D103 + init(autoreset=True) + parser = argparse.ArgumentParser() + parser.add_argument('version', help='Release version') + parser.add_argument( + 'template_name', + nargs='?', + help='Name of template file to use for release announcement', + default='', + ) + parser.add_argument( + 'doc_version', + nargs='?', + help='For prereleases, the version to link to in the docs', + default='', + ) + parser.add_argument('--skip-check-docs', help='Skip doc tests', action='store_true', default=False) + options = parser.parse_args() + pre_release( + options.version, + options.template_name, + options.doc_version, + skip_check_docs=options.skip_check_docs, + ) + + +if __name__ == '__main__': + main() diff --git a/tox.ini b/tox.ini index c629f9fa..f397606e 100644 --- a/tox.ini +++ b/tox.ini @@ -102,3 +102,22 @@ commands = extras = docs allowlist_externals = towncrier commands = towncrier build --version main --draft + +[testenv:release] +description = do a release, required posarg of the version number +basepython = python3 +extras = docs +usedevelop = True +passenv = * +deps = + colorama + github3.py + wheel +commands = python scripts/release.py {posargs} + +[testenv:prepare-release-pr] +description = prepare a release PR from a manual trigger in GitHub actions +usedevelop = {[testenv:release]usedevelop} +passenv = {[testenv:release]passenv} +deps = {[testenv:release]deps} +commands = python scripts/prepare-release-pr.py {posargs}