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
16 changes: 16 additions & 0 deletions .github/actions/release-to-pypi-uv/scripts/check_github_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ def main(
token: str = TOKEN_OPTION,
repo: str = REPO_OPTION,
) -> None:
"""Check that the GitHub release for ``tag`` is published.

Parameters
----------
tag : str
Release tag to validate.
token : str
Token used to authenticate the GitHub API request.
repo : str
Repository slug in ``owner/name`` form where the release should exist.

Raises
------
typer.Exit
Raised when the release is missing or not ready for publication.
"""
try:
data = _fetch_release(repo, tag, token)
name = _validate_release(tag, data)
Expand Down
14 changes: 14 additions & 0 deletions .github/actions/release-to-pypi-uv/scripts/confirm_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@


def main(expected: str = EXPECTED_OPTION, confirm: str = CONFIRM_OPTION) -> None:
"""Validate that the provided confirmation string matches ``expected``.

Parameters
----------
expected : str
Confirmation phrase that must be entered to proceed.
confirm : str
User-supplied confirmation string collected from workflow input.

Raises
------
typer.Exit
Raised when the supplied confirmation does not match ``expected``.
"""
if confirm != expected:
typer.echo(
f"::error::Confirmation failed. Set the 'confirm' input to: {expected}",
Expand Down
16 changes: 16 additions & 0 deletions .github/actions/release-to-pypi-uv/scripts/determine_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None:


def main(tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION) -> None:
"""Resolve the release tag and write outputs for downstream steps.

Parameters
----------
tag : str | None
Tag supplied via workflow input when the workflow is not running on a
tag reference.
github_output : Path
Path to the ``GITHUB_OUTPUT`` file that receives the resolved values.

Raises
------
typer.Exit
Raised when no tag can be determined or the tag is not SemVer
compliant.
"""
ref_type = os.getenv("GITHUB_REF_TYPE", "")
ref_name = os.getenv("GITHUB_REF_NAME", "")

Expand Down
7 changes: 7 additions & 0 deletions .github/actions/release-to-pypi-uv/scripts/publish_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ def _extend_sys_path() -> None:


def main(index: str = INDEX_OPTION) -> None:
"""Publish the built distributions with uv.

Parameters
----------
index : str
Optional package index name or URL to pass to ``uv publish``.
"""
if index:
typer.echo(f"Publishing with uv to index '{index}'")
run_cmd(["uv", "publish", "--index", index])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ def main(
pattern: str = PATTERN_OPTION,
fail_on_dynamic: str = FAIL_ON_DYNAMIC_OPTION,
) -> None:
"""Confirm that project versions in TOML files match the release version.

Parameters
----------
version : str
Semantic version resolved for the release tag.
pattern : str
Glob pattern used to discover ``pyproject.toml`` files to inspect.
fail_on_dynamic : str
String flag that controls whether dynamic versions should raise an
error.

Raises
------
typer.Exit
Raised when TOML files cannot be read or contain mismatched versions.
"""
files = list(_iter_files(pattern))
if not files:
typer.echo(f"::warning::No TOML files matched pattern {pattern}")
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/release-to-pypi-uv/scripts/write_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ def main(
environment_name: str = ENV_OPTION,
summary_path: Path = SUMMARY_OPTION,
) -> None:
"""Append release details to the GitHub step summary file.

Parameters
----------
tag : str
Resolved release tag to report.
index : str
Optional package index identifier provided to the publish step.
environment_name : str
Name of the deployment environment associated with the release.
summary_path : Path
File path to ``GITHUB_STEP_SUMMARY`` that should receive the content.
"""
index_label = index or "pypi (default)"
heading = "## Release summary\n"
lines = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,28 @@ def read(self) -> bytes:

@pytest.fixture(name="module")
def fixture_module() -> Any:
"""Load the ``check_github_release`` script for testing.

Returns
-------
Any
Imported module object exposing the ``main`` entrypoint.
"""
return load_script_module("check_github_release")


def test_success(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], module: Any) -> None:
"""Confirm that a published release prints a success message.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace ``urllib.request.urlopen``.
capsys : pytest.CaptureFixture[str]
Captures standard output and error from the command execution.
module : Any
Script module under test.
"""
def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401
return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"})

Expand All @@ -44,6 +62,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A


def test_draft_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None:
"""Fail when the release is still marked as a draft.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace ``urllib.request.urlopen``.
module : Any
Script module under test.
capsys : pytest.CaptureFixture[str]
Captures emitted error output.
"""
def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401
return DummyResponse({"draft": True, "prerelease": False, "name": "draft"})

Expand All @@ -57,6 +86,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A


def test_prerelease(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None:
"""Fail when the release is published as a prerelease.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace ``urllib.request.urlopen``.
module : Any
Script module under test.
capsys : pytest.CaptureFixture[str]
Captures emitted error output.
"""
def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401
return DummyResponse({"draft": False, "prerelease": True, "name": "pre"})

Expand All @@ -70,6 +110,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A


def test_missing_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None:
"""Raise an error when the requested release does not exist.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace ``urllib.request.urlopen``.
module : Any
Script module under test.
capsys : pytest.CaptureFixture[str]
Captures emitted error output.
"""
def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401
raise module.urllib.error.HTTPError(
url=str(request.full_url),
Expand All @@ -89,6 +140,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401


def test_permission_denied(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None:
"""Surface permission errors from the GitHub API.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace ``urllib.request.urlopen``.
module : Any
Script module under test.
capsys : pytest.CaptureFixture[str]
Captures emitted error output.
"""
detail = b"forbidden"
error = module.urllib.error.HTTPError(
url="https://api.github.com",
Expand All @@ -111,6 +173,17 @@ def raising_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401


def test_retries_then_success(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None:
"""Retry transient failures before succeeding.

Parameters
----------
monkeypatch : pytest.MonkeyPatch
Fixture used to replace network calls and sleep behaviour.
module : Any
Script module under test.
capsys : pytest.CaptureFixture[str]
Captures command output for assertions.
"""
attempts: list[int] = []

def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401
Expand Down
30 changes: 30 additions & 0 deletions .github/actions/release-to-pypi-uv/tests/test_confirm_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@


def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.CompletedProcess[str]:
"""Execute the confirmation script with the provided values.

Parameters
----------
tmp_path : Path
Temporary directory used as the working directory for the script.
expected : str
Expected confirmation string supplied via environment variable.
confirm : str
Confirmation value provided to the workflow input.

Returns
-------
subprocess.CompletedProcess[str]
Result from invoking the script with ``uv run``.
"""
env = base_env(tmp_path)
env["EXPECTED"] = expected
env["INPUT_CONFIRM"] = confirm
Expand All @@ -31,13 +47,27 @@ def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.Compl


def test_confirmation_success(tmp_path: Path) -> None:
"""Accept when the confirmation matches the expected phrase.

Parameters
----------
tmp_path : Path
Temporary directory provided by pytest.
"""
result = run_confirm(tmp_path, expected="release v1.2.3", confirm="release v1.2.3")

assert result.returncode == 0, result.stderr
assert "Manual confirmation OK." in result.stdout


def test_confirmation_failure(tmp_path: Path) -> None:
"""Reject confirmation attempts with mismatched input.

Parameters
----------
tmp_path : Path
Temporary directory provided by pytest.
"""
result = run_confirm(tmp_path, expected="release v1.2.3", confirm="nope")

assert result.returncode == 1
Expand Down
Loading
Loading