Skip to content
Merged
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
32 changes: 23 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<<EOF" >> $GITHUB_OUTPUT
cat changelog.txt >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
{
echo "COMMITS<<EOF"
cat changelog.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
Expand All @@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dev = [
"black>=26.0.0",
"isort>=7.0.0",
"mypy>=1.14.0",
"packaging>=24.0",
]

[project.urls]
Expand Down
35 changes: 35 additions & 0 deletions scripts/check_release.py
Original file line number Diff line number Diff line change
@@ -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()
81 changes: 81 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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
Loading