From 40bc57026dac97fccc610be2421cfe3cca2b1347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 30 May 2026 14:18:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=91=B7=20Add=20GitHub=20Actions=20pre?= =?UTF-8?q?pare=20release=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/prepare-release.yml | 78 ++++++++++++ scripts/prepare_release.py | 155 +++++++++++++++++++++++ tests/test_prepare_release.py | 171 ++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts/prepare_release.py create mode 100644 tests/test_prepare_release.py diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000000..87754be29b --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,78 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + bump: + description: Release bump + required: true + type: choice + options: + - major + - minor + - patch + date: + description: Release date in YYYY-MM-DD format. Defaults to today. + required: false + type: string + +permissions: {} + +jobs: + prepare-release: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write + env: + PREPARE_RELEASE_VERSION_FILE: typer/__init__.py + PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/release-notes.md + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: true + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: ".python-version" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" + - name: Prepare release + id: prepare-release + env: + PREPARE_RELEASE_BUMP: ${{ inputs.bump }} + PREPARE_RELEASE_DATE: ${{ inputs.date }} + run: | + uv run python scripts/prepare_release.py prepare + version="$(uv run python scripts/prepare_release.py current-version)" + echo "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Create release pull request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.prepare-release.outputs.version }} + run: | + set -euo pipefail + branch="release-${VERSION}-${GITHUB_RUN_ID}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add $PREPARE_RELEASE_VERSION_FILE $PREPARE_RELEASE_RELEASE_NOTES_FILE + git commit -m "🔖 Release version ${VERSION}" + git push --set-upstream origin "$branch" + gh pr create \ + --base master \ + --head "$branch" \ + --title "🔖 Release version ${VERSION}" \ + --body "Prepare release ${VERSION}." \ + --label release diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000000..8202514493 --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,155 @@ +"""Prepare a release by updating the package version and release notes.""" + +import re +from datetime import date +from pathlib import Path +from typing import Annotated, Literal + +import typer + +VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$') +RELEASE_NOTES_HEADER = "# Release Notes\n\n" +LATEST_CHANGES_HEADER = "## Latest Changes" +BumpType = Literal["major", "minor", "patch"] + +app = typer.Typer() + + +def parse_version(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"\d+\.\d+\.\d+", version) + if not match: + raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z") + major, minor, patch = version.split(".") + return int(major), int(minor), int(patch) + + +def get_current_version(content: str, version_file: Path) -> str: + matches = list(VERSION_PATTERN.finditer(content)) + if len(matches) != 1: + raise RuntimeError( + f"Expected exactly one __version__ assignment in {version_file}, " + f"found {len(matches)}" + ) + return matches[0].group(1) + + +def bump_version(version: str, bump: BumpType) -> str: + major, minor, patch = parse_version(version) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def update_version_file(content: str, version: str, version_file: Path) -> str: + current_version = get_current_version(content, version_file) + if parse_version(version) <= parse_version(current_version): + raise RuntimeError( + f"New version {version} must be greater than current version {current_version}" + ) + return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1) + + +def update_release_notes( + content: str, version: str, release_date: date, release_notes_file: Path +) -> str: + if not content.startswith(RELEASE_NOTES_HEADER): + raise RuntimeError( + f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}" + ) + if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M): + raise RuntimeError(f"Release notes already contain a section for {version}") + + latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n" + if not content.startswith(latest_header): + raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}") + + release_header = f"## {version} ({release_date.isoformat()})" + return content.replace( + latest_header, + f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n", + 1, + ) + + +@app.command() +def prepare( + bump: Annotated[ + BumpType, + typer.Argument( + envvar="PREPARE_RELEASE_BUMP", + help="The release bump to make: major, minor, or patch.", + ), + ], + version_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_VERSION_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + writable=True, + help="Path to the Python file containing the __version__ assignment.", + ), + ], + release_notes_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + writable=True, + help="Path to the release notes Markdown file.", + ), + ], + release_date: Annotated[ + str, + typer.Option( + envvar="PREPARE_RELEASE_DATE", + help="Release date in YYYY-MM-DD format. Defaults to today.", + ), + ] = date.today().isoformat(), +) -> None: + parsed_release_date = date.fromisoformat(release_date or date.today().isoformat()) + + version_file_content = version_file.read_text() + release_notes_content = release_notes_file.read_text() + version = bump_version( + get_current_version(version_file_content, version_file), bump + ) + + version_file.write_text( + update_version_file(version_file_content, version, version_file) + ) + release_notes_file.write_text( + update_release_notes( + release_notes_content, version, parsed_release_date, release_notes_file + ) + ) + + typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})") + + +@app.command() +def current_version( + version_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_VERSION_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the Python file containing the __version__ assignment.", + ), + ], +) -> None: + typer.echo(get_current_version(version_file.read_text(), version_file)) + + +if __name__ == "__main__": + app() diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py new file mode 100644 index 0000000000..c8922a2c62 --- /dev/null +++ b/tests/test_prepare_release.py @@ -0,0 +1,171 @@ +from datetime import date +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from scripts.prepare_release import ( + BumpType, + app, + bump_version, + update_release_notes, + update_version_file, +) + +runner = CliRunner() + + +@pytest.mark.parametrize( + ("current_version", "bump", "new_version"), + [ + ("0.26.2", "major", "1.0.0"), + ("0.26.2", "minor", "0.27.0"), + ("0.26.2", "patch", "0.26.3"), + ], +) +def test_bump_version(current_version: str, bump: BumpType, new_version: str) -> None: + assert bump_version(current_version, bump) == new_version + + +def test_update_version_file() -> None: + content = '"""Typer."""\n\n__version__ = "0.26.2"\n' + + new_content = update_version_file(content, "0.26.3", Path("typer/__init__.py")) + + assert new_content == '"""Typer."""\n\n__version__ = "0.26.3"\n' + + +def test_update_version_file_requires_newer_version() -> None: + content = '__version__ = "0.26.2"\n' + + with pytest.raises(RuntimeError, match="must be greater"): + update_version_file(content, "0.26.2", Path("typer/__init__.py")) + + +def test_update_release_notes() -> None: + content = """# Release Notes + +## Latest Changes + +### Fixes + +* Fix something. + +## 0.26.2 (2026-05-27) + +### Fixes + +* Previous fix. +""" + + new_content = update_release_notes( + content, "0.26.3", date(2026, 5, 28), Path("docs/release-notes.md") + ) + + assert ( + new_content + == """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) + +### Fixes + +* Fix something. + +## 0.26.2 (2026-05-27) + +### Fixes + +* Previous fix. +""" + ) + + +def test_update_release_notes_rejects_existing_version() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) +""" + + with pytest.raises(RuntimeError, match="already contain"): + update_release_notes( + content, "0.26.3", date(2026, 5, 28), Path("docs/release-notes.md") + ) + + +def test_cli_updates_configured_files(tmp_path: Path) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + release_notes_file = tmp_path / "release-notes.md" + release_notes_file.write_text( + """# Release Notes + +## Latest Changes + +### Fixes + +* Fix something. +""" + ) + + result = runner.invoke( + app, + [ + "prepare", + "patch", + "--version-file", + str(version_file), + "--release-notes-file", + str(release_notes_file), + "--date", + "2026-05-28", + ], + ) + + assert result.exit_code == 0, result.output + assert "Prepared release 0.26.3 (2026-05-28)" in result.output + assert version_file.read_text() == '__version__ = "0.26.3"\n' + assert "## 0.26.3 (2026-05-28)" in release_notes_file.read_text() + + +def test_cli_accepts_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + release_notes_file = tmp_path / "docs" / "release-notes.md" + release_notes_file.parent.mkdir() + release_notes_file.write_text("# Release Notes\n\n## Latest Changes\n") + monkeypatch.setenv("PREPARE_RELEASE_BUMP", "minor") + monkeypatch.setenv("PREPARE_RELEASE_VERSION_FILE", str(version_file)) + monkeypatch.setenv("PREPARE_RELEASE_RELEASE_NOTES_FILE", str(release_notes_file)) + monkeypatch.setenv("PREPARE_RELEASE_DATE", "2026-05-28") + + result = runner.invoke(app, ["prepare"]) + + assert result.exit_code == 0, result.output + assert "Prepared release 0.27.0 (2026-05-28)" in result.output + assert version_file.read_text() == '__version__ = "0.27.0"\n' + assert "## 0.27.0 (2026-05-28)" in release_notes_file.read_text() + + +def test_cli_prints_current_version(tmp_path: Path) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + + result = runner.invoke( + app, + [ + "current-version", + "--version-file", + str(version_file), + ], + ) + + assert result.exit_code == 0, result.output + assert result.output == "0.26.2\n" From 031ba365e9dd0c4eb2e63b8e543a4e2659087fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 30 May 2026 14:54:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20CLI=20optio?= =?UTF-8?q?n=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prepare_release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 8202514493..b55764daca 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -109,6 +109,7 @@ def prepare( release_date: Annotated[ str, typer.Option( + "--date", envvar="PREPARE_RELEASE_DATE", help="Release date in YYYY-MM-DD format. Defaults to today.", ),