Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
36 changes: 32 additions & 4 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -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 }}
71 changes: 71 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -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()
111 changes: 111 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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"
Loading