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"