From 6a3f844a94837fcf3d1beb2656070bb727f676cd Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:42:40 -0400 Subject: [PATCH] Add automated PyPI releases and CalVer release drafting workflows Automated package publishing to PyPI was missing from python-hilo, requiring manual intervention for releases. In addition, Release Drafter previously lacked CalVer version calculation, which prevented automated tag and release note drafting under the YYYY.M.patch scheme used across the project history. Following the release architecture established in python-hotspring and dvd-dev/hilo#1066: - Introduces scripts/release.py to calculate the next CalVer version and stamp version changes across pyproject.toml and pyhilo/const.py, backed by unit tests in tests/test_release.py. - Configures .github/workflows/release-drafter.yml to automatically calculate and apply the next CalVer version tag to GitHub release drafts on merge to main. - Adds .github/workflows/release.yaml to build distribution packages and publish them to PyPI via Trusted Publishing (OIDC) when a GitHub release is published. Maintainers must configure PyPI Trusted Publishing for the repository under the 'release' environment before the publishing workflow can upload packages. --- .github/release-drafter.yml | 6 ++ .github/workflows/release-drafter.yml | 36 +++++++- .github/workflows/release.yaml | 71 +++++++++++++++ scripts/release.py | 121 ++++++++++++++++++++++++++ tests/test_release.py | 111 +++++++++++++++++++++++ 5 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 scripts/release.py create mode 100644 tests/test_release.py diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 7a04410..95d0cb5 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,3 +1,9 @@ +--- +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" +change-template: "- $TITLE @$AUTHOR (#$NUMBER)" +sort-direction: ascending + categories: - title: ":boom: Breaking Changes" label: "breaking" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 999a42a..980626b 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,14 +1,42 @@ +--- name: Draft a release note + +# yamllint disable-line rule:truthy on: push: branches: - main + workflow_dispatch: + +permissions: + contents: read + jobs: draft_release: name: Release Drafter runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read steps: - - name: Run release-drafter - uses: release-drafter/release-drafter@v7.7.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: โคต๏ธ Check out code from GitHub + uses: actions/checkout@v7 + with: + # Full history so that all existing tags are available + fetch-depth: 0 + persist-credentials: false + + # Release Drafter's version resolver only understands SemVer, so the next + # CalVer version (YYYY.M.N) is computed by scripts/release.py and passed in. + - name: ๐Ÿ“… Calculate next CalVer version + id: calver + run: | + version="$(python3 scripts/release.py --next-version)" + echo "Calculated next CalVer: ${version}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: ๐Ÿš€ Run release-drafter + uses: release-drafter/release-drafter@v7 + with: + version: ${{ steps.calver.outputs.version }} + token: ${{ github.token }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3e3554e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,71 @@ +--- +name: Release + +# yamllint disable-line rule:truthy +on: + release: + types: + - published + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to publish (e.g. v2026.10.1)" + required: true + type: string + +env: + DEFAULT_PYTHON: "3.13" + +permissions: + contents: read + +jobs: + release: + name: Releasing to PyPI + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/python-hilo + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: โคต๏ธ Check out code from GitHub + uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name || inputs.tag_name }} + persist-credentials: false + + - name: ๐Ÿ— Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: ๐Ÿ— Install uv + uses: astral-sh/setup-uv@v10.2.0 + + - name: ๐Ÿงช Run tests + run: | + uv sync --group test + uv run pytest + + - name: ๐Ÿ— Set package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag_name }} + run: | + if [ -z "${RELEASE_TAG}" ]; then + echo "Error: No release tag found or provided." + exit 1 + fi + python3 scripts/release.py --set-version "${RELEASE_TAG}" + + - name: ๐Ÿ— Build package + run: uv build + + - name: ๐Ÿš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + print-hash: true diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..5207819 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Manage release versioning and packaging for python-hilo.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import re +import subprocess +import sys + +# Pattern for Calendar Versioning: YYYY.M.N (e.g. 2026.10.1) +# with an optional pre-release suffix (e.g. 2026.10.1b1). +VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{1,2}\.[0-9]+(b[0-9]+)?$") +ROOT = Path(__file__).resolve().parent.parent + + +def clean_version_from_tag(tag: str) -> str: + """Validate a release tag and strip the leading 'v' prefix. + + Raises ValueError if the tag does not follow the expected CalVer format. + """ + version = tag.removeprefix("v") + if not VERSION_PATTERN.match(version): + raise ValueError( + f"Release tag '{tag}' must match version format vYYYY.M.N " + "(e.g. v2026.10.1 or v2026.10.1b1)" + ) + return version + + +def get_next_version(reference_date: datetime | None = None) -> str: + """Calculate the next version name (YYYY.M.N) from existing git tags. + + Inspects git tags for the current UTC year and month. If tags exist, increments + the highest patch number; otherwise starts at patch 1 (e.g. 2026.10.1). + """ + now = reference_date or datetime.now(timezone.utc) + prefix = f"{now.year}.{now.month}" + + try: + output = subprocess.check_output( + ["git", "tag", "--list", f"v{prefix}.*", f"{prefix}.*"], + cwd=ROOT, + text=True, + ) + tags = output.splitlines() + except (subprocess.SubprocessError, FileNotFoundError): + tags = [] + + # Only match final/stable releases so pre-releases (e.g. v2026.10.1b1) + # do not consume the stable patch number for draft releases. + pattern = re.compile(rf"^v?{re.escape(prefix)}\.([0-9]+)$") + patches = [int(m.group(1)) for tag in tags if (m := pattern.match(tag.strip()))] + next_patch = max(patches, default=0) + 1 + return f"{prefix}.{next_patch}" + + +def set_package_version(tag_or_version: str, root_dir: Path = ROOT) -> str: + """Validate and update the version in pyproject.toml and pyhilo/const.py.""" + version = clean_version_from_tag(tag_or_version) + + pyproject_path = root_dir / "pyproject.toml" + if not pyproject_path.is_file(): + raise FileNotFoundError(f"Missing {pyproject_path}") + content = pyproject_path.read_text(encoding="utf-8") + new_content, count = re.subn( + r'(?m)^version = "[^"]*"', + f'version = "{version}"', + content, + count=1, + ) + if count == 0: + raise ValueError(f"Could not find 'version' field in {pyproject_path}") + pyproject_path.write_text(new_content, encoding="utf-8") + + const_path = root_dir / "pyhilo" / "const.py" + if not const_path.is_file(): + raise FileNotFoundError(f"Missing {const_path}") + content = const_path.read_text(encoding="utf-8") + new_content, count = re.subn( + r'PYHILO_VERSION: Final = "[^"]*"', + f'PYHILO_VERSION: Final = "{version}"', + content, + ) + if count == 0: + raise ValueError(f"Could not find 'PYHILO_VERSION' constant in {const_path}") + const_path.write_text(new_content, encoding="utf-8") + + return version + + +def main(argv: list[str] | None = None) -> None: + """CLI entrypoint for release automation.""" + parser = argparse.ArgumentParser( + description="Manage release versions for python-hilo." + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--next-version", + action="store_true", + help="Calculate the next CalVer version from git tags", + ) + group.add_argument( + "--set-version", + metavar="TAG", + help="Set package version in pyproject.toml and pyhilo/const.py", + ) + + args = parser.parse_args(argv) + + if args.next_version: + sys.stdout.write(f"{get_next_version()}\n") + elif args.set_version: + version = set_package_version(args.set_version) + sys.stdout.write(f"Updated package version to {version}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..412618e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,111 @@ +"""Unit tests for scripts/release.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest + +from scripts.release import ( + clean_version_from_tag, + get_next_version, + main, + set_package_version, +) + + +@pytest.mark.parametrize( + ("tag", "expected"), + [ + ("v2026.10.1", "2026.10.1"), + ("2026.10.1", "2026.10.1"), + ("v2026.10.1b2", "2026.10.1b2"), + ("v2025.5.12", "2025.5.12"), + ], +) +def test_clean_version_from_tag_valid(tag: str, expected: str) -> None: + """Test valid version tags are parsed correctly.""" + assert clean_version_from_tag(tag) == expected + + +@pytest.mark.parametrize( + "invalid_tag", + [ + "v1.0.0", + "invalid", + "v2026.10", + "v2026.10.", + "v2026.10.1rc1", + "v2026.10.1-beta", + ], +) +def test_clean_version_from_tag_invalid(invalid_tag: str) -> None: + """Test invalid version tags raise ValueError.""" + with pytest.raises(ValueError, match="must match version format"): + clean_version_from_tag(invalid_tag) + + +@pytest.mark.parametrize( + ("existing_tags", "expected_version"), + [ + ("", "2026.10.1"), + ("v2026.10.1\n", "2026.10.2"), + ("v2026.10.1\nv2026.10.2\n", "2026.10.3"), + ("v2026.10.1b1\n", "2026.10.1"), + ("v2026.10.1\nv2026.10.2b1\n", "2026.10.2"), + ("v2026.9.5\n", "2026.10.1"), + ], +) +def test_get_next_version(existing_tags: str, expected_version: str) -> None: + """Test next version calculation with various existing tag states.""" + ref_date = datetime(2026, 10, 15, tzinfo=timezone.utc) + with patch("subprocess.check_output", return_value=existing_tags): + assert get_next_version(reference_date=ref_date) == expected_version + + +def test_set_package_version_success(tmp_path: Path) -> None: + """Test successfully updating pyproject.toml and pyhilo/const.py versions.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "python-hilo"\nversion = "2026.9.1"\n', encoding="utf-8" + ) + + const_dir = tmp_path / "pyhilo" + const_dir.mkdir() + const_file = const_dir / "const.py" + const_file.write_text('PYHILO_VERSION: Final = "2026.9.01"\n', encoding="utf-8") + + version = set_package_version("v2026.10.1", root_dir=tmp_path) + assert version == "2026.10.1" + assert 'version = "2026.10.1"' in pyproject.read_text(encoding="utf-8") + assert 'PYHILO_VERSION: Final = "2026.10.1"' in const_file.read_text( + encoding="utf-8" + ) + + +def test_set_package_version_missing_files(tmp_path: Path) -> None: + """Test that missing target files raise FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="Missing"): + set_package_version("v2026.10.1", root_dir=tmp_path) + + +def test_set_package_version_missing_pattern(tmp_path: Path) -> None: + """Test that unmatched version patterns raise ValueError.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "python-hilo"\n', encoding="utf-8") + + with pytest.raises(ValueError, match="Could not find 'version' field"): + set_package_version("v2026.10.1", root_dir=tmp_path) + + +def test_main_cli(capsys: pytest.CaptureFixture[str]) -> None: + """Test main CLI entrypoint flags.""" + with patch("scripts.release.get_next_version", return_value="2026.10.1"): + main(["--next-version"]) + assert capsys.readouterr().out == "2026.10.1\n" + + with patch("scripts.release.set_package_version", return_value="2026.10.1"): + main(["--set-version", "v2026.10.1"]) + assert capsys.readouterr().out == "Updated package version to 2026.10.1\n"