diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 563f1ee..6c8b50d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,14 @@ on: - "v*" permissions: - contents: write - id-token: write + contents: read jobs: quality-checks: name: Quality Checks runs-on: ubuntu-latest + outputs: + prerelease: ${{ steps.release.outputs.prerelease }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,6 +26,12 @@ jobs: - name: Install dependencies run: make install-dev + - name: Validate release tag + id: release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: venv/bin/python scripts/check_release.py "$RELEASE_TAG" >> "$GITHUB_OUTPUT" + - name: Run linting run: make lint @@ -38,6 +45,9 @@ jobs: name: Build and Publish to PyPI needs: quality-checks runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -58,8 +68,10 @@ jobs: github-release: name: Create GitHub Release - needs: build-and-publish + needs: [quality-checks, build-and-publish] runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -68,7 +80,7 @@ jobs: - name: Extract tag name id: tag - run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Generate changelog id: changelog @@ -78,13 +90,15 @@ jobs: if [ -z "$PREV_TAG" ]; then COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges) else - COMMITS=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) + COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) fi echo "$COMMITS" > changelog.txt - echo "COMMITS<> $GITHUB_OUTPUT - cat changelog.txt >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + { + echo "COMMITS<> "$GITHUB_OUTPUT" - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -110,5 +124,5 @@ jobs: - [PyPI](https://pypi.org/project/energy-tracker-api/) - [Documentation](https://github.com/energy-tracker/api-client-python#readme) draft: false - prerelease: false + prerelease: ${{ needs.quality-checks.outputs.prerelease == 'true' }} generate_release_notes: false diff --git a/pyproject.toml b/pyproject.toml index 2b95d0a..c57ffc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "black>=26.0.0", "isort>=7.0.0", "mypy>=1.14.0", + "packaging>=24.0", ] [project.urls] diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..6582b67 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,35 @@ +"""Validate the release tag against package metadata and report prerelease status.""" + +import argparse +import tomllib +from pathlib import Path + +from packaging.version import Version + + +def validate_release(tag: str, project_file: Path) -> Version: + with project_file.open("rb") as file: + package_version = tomllib.load(file)["project"]["version"] + version = Version(package_version) + expected_tag = f"v{package_version}" + if tag != expected_tag: + raise ValueError( + f"Release tag {tag!r} does not match package version: expected {expected_tag!r}" + ) + return version + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag", help="Release tag, e.g. v2.0.0") + args = parser.parse_args() + project_file = Path(__file__).resolve().parents[1] / "pyproject.toml" + try: + version = validate_release(args.tag, project_file) + except ValueError as error: + parser.error(str(error)) + print(f"prerelease={str(version.is_prerelease).lower()}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..d8f3823 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,81 @@ +"""Release version checks used before publishing distribution artifacts.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.check_release import validate_release + + +@pytest.mark.parametrize( + "version,prerelease", + [ + ("2.0.0", False), + ("2.0.1.post1", False), + ("2.1.0a1", True), + ("2.1.0b2", True), + ("2.1.0rc1", True), + ("2.1.0.dev1", True), + ], +) +def test_release_classifies_pep440_versions(tmp_path, version, prerelease): + project = tmp_path / "pyproject.toml" + project.write_text(f'[project]\nversion = "{version}"\n') + + result = validate_release(f"v{version}", project) + + assert result.is_prerelease is prerelease + + +@pytest.mark.parametrize("tag", ["v2.0.1", "v2.0.0rc1", "2.0.0", "v", ""]) +def test_release_rejects_tags_that_do_not_match_package(tmp_path, tag): + project = tmp_path / "pyproject.toml" + project.write_text('[project]\nversion = "2.0.0"\n') + + with pytest.raises(ValueError, match="does not match package version"): + validate_release(tag, project) + + +def test_release_rejects_invalid_package_version(tmp_path): + project = tmp_path / "pyproject.toml" + project.write_text('[project]\nversion = "invalid"\n') + + with pytest.raises(ValueError, match="Invalid version"): + validate_release("vinvalid", project) + + +@pytest.mark.parametrize("version,prerelease", [("2.0.0", "false"), ("2.1.0rc1", "true")]) +def test_release_command_outputs_github_metadata_outside_checkout(tmp_path, version, prerelease): + root = Path(__file__).resolve().parents[1] + project = tmp_path / "project" + scripts = project / "scripts" + scripts.mkdir(parents=True) + script = scripts / "check_release.py" + script.write_text((root / "scripts/check_release.py").read_text()) + (project / "pyproject.toml").write_text(f'[project]\nversion = "{version}"\n') + + result = subprocess.run( + [sys.executable, str(script), f"v{version}"], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + + assert result.stdout == f"prerelease={prerelease}\n" + + +def test_release_command_fails_without_outputs_for_wrong_tag(tmp_path): + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, str(root / "scripts/check_release.py"), "v-not-the-package-version"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert result.stdout == "" + assert "does not match package version" in result.stderr