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
7 changes: 6 additions & 1 deletion .github/actions/generate-coverage/scripts/run_rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@ def _run_cargo(args: list[str]) -> str:
missing_streams.append("stderr")
missing = ", ".join(missing_streams)
message = f"cargo output streams not captured: missing {missing}"
raise RuntimeError(message)
with contextlib.suppress(Exception):
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=5)
typer.echo(f"::error::{message}", err=True)
raise typer.Exit(1)
stdout_lines: list[str] = []

if os.name == "nt":
Expand Down
11 changes: 10 additions & 1 deletion .github/actions/release-to-pypi-uv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@ Build and publish Python distributions via
| environment-name | GitHub environment to reference in the release summary. | no | `pypi` |
| uv-index | Optional uv index name to publish to (e.g. `testpypi`). Must exist in `tool.uv.index`. | no | _(empty)_ |
| toml-glob | Glob used to discover `pyproject.toml` files for version validation. | no | `**/pyproject.toml` |
| skip-directories | Comma- or newline-separated directory names to skip during discovery. | no | _(empty)_ |
| fail-on-dynamic-version | Fail when a project declares a dynamic PEP 621 version instead of a literal string. | no | `false` |
| fail-on-empty | Fail when no `pyproject.toml` files match the discovery glob. | no | `false` |
| python-version | Python version to install and use for all uv commands. | no | `3.13` |

The composite action installs the interpreter requested through `python-version`
before invoking any uv commands, ensuring builds run against the expected
runtime.
runtime. Set `fail-on-empty: true` when your repository must always contain at
least one `pyproject.toml`. This turns the default warning into a failing error
so misconfigured globs surface early.

Directories named `.venv`, `venv`, `.mypy_cache`, `.pytest_cache`, `.cache`,
`htmlcov`, and `node_modules` are skipped during TOML discovery. Provide a
comma- or newline-separated list via `skip-directories` when your repository
uses additional transient paths that should be excluded.

## Outputs

Expand Down
16 changes: 13 additions & 3 deletions .github/actions/release-to-pypi-uv/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ inputs:
description: Glob used to discover pyproject.toml files for version validation.
required: false
default: "**/pyproject.toml"
skip-directories:
description: Comma- or newline-separated directory names to skip during TOML discovery.
required: false
default: ''
fail-on-dynamic-version:
description: Fail if any project declares a dynamic version instead of a literal string.
required: false
default: 'false'
fail-on-empty:
description: Fail when no pyproject.toml files match the configured glob.
required: false
default: 'false'
python-version:
description: Python version to install and use with uv commands.
required: false
Expand All @@ -53,6 +61,9 @@ runs:
**/pyproject.toml
**/uv.lock
cache-suffix: action-${{ github.action_ref || github.sha }}
- name: Install Python
run: uv python install "${{ inputs.python-version }}"
shell: bash
- name: Determine tag and version
id: resolve
run: uv run --script "${{ github.action_path }}/scripts/determine_release.py"
Expand Down Expand Up @@ -81,9 +92,8 @@ runs:
RESOLVED_VERSION: ${{ steps.resolve.outputs.version }}
INPUT_TOML_GLOB: ${{ inputs.toml-glob }}
INPUT_FAIL_ON_DYNAMIC_VERSION: ${{ inputs.fail-on-dynamic-version }}
- name: Install Python
run: uv python install "${{ inputs.python-version }}"
shell: bash
INPUT_FAIL_ON_EMPTY: ${{ inputs.fail-on-empty }}
INPUT_SKIP_DIRECTORIES: ${{ inputs.skip-directories }}
- name: Build distributions
run: uv build
shell: bash
Expand Down
39 changes: 37 additions & 2 deletions .github/actions/release-to-pypi-uv/scripts/check_github_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@

from __future__ import annotations

import contextlib
import json
import random
import time
import typing as typ
import urllib.error
import urllib.parse
import urllib.request
Expand All @@ -20,6 +23,32 @@
REPO_OPTION = typer.Option(..., envvar="GITHUB_REPOSITORY")


class _UniformGenerator(typ.Protocol):
"""Protocol describing RNG objects that provide ``uniform``."""

def uniform(self, a: float, b: float) -> float:
"""Return a random floating point number N such that ``a <= N <= b``."""


SleepFn = typ.Callable[[float], None]

_JITTER = random.SystemRandom()


def _sleep_with_jitter(
delay: float,
*,
jitter: _UniformGenerator | None = None,
sleep: SleepFn | None = None,
) -> None:
"""Sleep for ``delay`` seconds with a deterministic jitter hook for tests."""
sleep_base = max(delay, 0.0)
jitter_source = _JITTER if jitter is None else jitter
sleep_fn = time.sleep if sleep is None else sleep
jitter_amount = sleep_base * jitter_source.uniform(0.0, 0.1)
sleep_fn(sleep_base + jitter_amount)


class GithubReleaseError(RuntimeError):
"""Raised when the GitHub release is not ready for publishing."""

Expand Down Expand Up @@ -76,13 +105,19 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]:
f"{exc.code}: {failure_reason}"
)
raise GithubReleaseError(message) from exc
time.sleep(delay)
retry_after = None
if hasattr(exc, "headers") and exc.headers is not None:
retry_after = exc.headers.get("Retry-After")
if retry_after:
with contextlib.suppress(Exception):
delay = float(retry_after)
_sleep_with_jitter(delay)
delay *= backoff_factor
except urllib.error.URLError as exc: # pragma: no cover - network failure path
if attempt == max_attempts:
message = f"Failed to reach GitHub API: {exc.reason}"
raise GithubReleaseError(message) from exc
time.sleep(delay)
_sleep_with_jitter(delay)
delay *= backoff_factor
else: # pragma: no cover - loop exhausted without break
message = "GitHub API request failed after retries."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ def main(expected: str = EXPECTED_OPTION, confirm: str = CONFIRM_OPTION) -> None
typer.Exit
Raised when the supplied confirmation does not match ``expected``.
"""
# Normalise whitespace in both inputs before comparison.
expected = expected.strip()
confirm = confirm.strip()
if confirm != expected:
typer.echo(
f"::error::Confirmation failed. Set the 'confirm' input to: {expected}",
"::error::Confirmation failed. "
"Set the 'confirm' input to the expected phrase.",
err=True,
)
raise typer.Exit(1)
Expand Down
15 changes: 9 additions & 6 deletions .github/actions/release-to-pypi-uv/scripts/determine_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

def _emit_outputs(dest: Path, tag: str, version: str) -> None:
with dest.open("a", encoding="utf-8") as fh:
fh.write(f"tag={tag}\n")
fh.write(f"version={version}\n")
for key, value in (("tag", tag), ("version", version)):
fh.write(f"{key}={value}\n")


def main(
Expand All @@ -45,10 +45,12 @@ def main(
ref_name = os.getenv("GITHUB_REF_NAME", "")

resolved_tag: str | None = None
if ref_type == "tag" and ref_name:
candidate_tag = (tag or "").strip()
ref_name = ref_name.strip()
if candidate_tag:
resolved_tag = candidate_tag
elif ref_type == "tag" and ref_name:
resolved_tag = ref_name
elif tag:
resolved_tag = tag

if not resolved_tag:
typer.echo(
Expand All @@ -57,7 +59,8 @@ def main(
)
raise typer.Exit(1)

if not re.fullmatch(r"v\d+\.\d+\.\d+", resolved_tag):
semver_pattern = r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?"
if not re.fullmatch(semver_pattern, resolved_tag):
typer.echo(
"::error::Tag must be a valid semantic version (e.g. v1.2.3), "
f"got '{resolved_tag}'.",
Expand Down
15 changes: 15 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 @@ -9,12 +9,26 @@

import contextlib
import os
import shutil
import sys
from pathlib import Path

import typer


def _ensure_python_runtime() -> None:
"""Fail fast when Python 3.13+ or uv provisioning is unavailable."""
if sys.version_info >= (3, 13):
return
if shutil.which("uv") is not None:
return
typer.echo(
"::error::Python >= 3.13 or uv must be available before publishing.",
err=True,
)
raise typer.Exit(1)


def _extend_sys_path() -> None:
candidates: list[Path] = []
action_path_env = os.getenv("GITHUB_ACTION_PATH")
Expand All @@ -38,6 +52,7 @@ def _extend_sys_path() -> None:
sys.path.insert(0, path_str)


_ensure_python_runtime()
_extend_sys_path()

from cmd_utils import run_cmd # noqa: E402
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@
"false",
envvar="INPUT_FAIL_ON_DYNAMIC_VERSION",
)
FAIL_ON_EMPTY_OPTION = typer.Option(
"false",
envvar="INPUT_FAIL_ON_EMPTY",
)
SKIP_DIRECTORIES_OPTION = typer.Option(
"",
envvar="INPUT_SKIP_DIRECTORIES",
)

# Common transient directories created by tooling (virtualenvs, caches,
# pytest artefacts such as ``.pytest_cache``/``.cache`` and coverage reports
# under ``htmlcov``) that should be ignored when searching for
# ``pyproject.toml`` files to validate.
SKIP_PARTS = {
DEFAULT_SKIP_PARTS = {
".git",
".venv",
"venv",
Expand All @@ -37,22 +45,34 @@
"htmlcov",
}

SKIP_PARTS = frozenset(DEFAULT_SKIP_PARTS)

TRUTHY_STRINGS = {"true", "1", "yes", "y", "on"}


def _iter_files(pattern: str) -> typ.Iterable[Path]:
def _iter_files(
pattern: str, *, skip_parts: typ.Collection[str] | None = None
) -> typ.Iterable[Path]:
root = Path()
skip = set(SKIP_PARTS if skip_parts is None else skip_parts)
for path in sorted(
root.glob(pattern), key=lambda candidate: tuple(candidate.parts)
):
if not path.is_file():
continue
parts = set(path.parts)
if parts & SKIP_PARTS:
if parts & skip:
continue
yield path


def _parse_skip_directories(raw: str | None) -> set[str]:
if not raw:
return set()
normalized = raw.replace(",", "\n")
return {part.strip() for part in normalized.splitlines() if part.strip()}


def _parse_bool(value: str | None) -> bool:
if value is None:
return False
Expand Down Expand Up @@ -86,6 +106,8 @@ def main(
version: str = VERSION_OPTION,
pattern: str = PATTERN_OPTION,
fail_on_dynamic: str = FAIL_ON_DYNAMIC_OPTION,
fail_on_empty: str = FAIL_ON_EMPTY_OPTION,
skip_directories: str = SKIP_DIRECTORIES_OPTION,
) -> None:
"""Confirm that project versions in TOML files match the release version.

Expand All @@ -98,14 +120,27 @@ def main(
fail_on_dynamic : str
String flag that controls whether dynamic versions should raise an
error.
fail_on_empty : str
String flag that controls whether missing matches should raise an
error instead of logging a warning.
skip_directories : str
Comma- or newline-separated list of directory name components to ignore
when matching ``pyproject.toml`` files.

Raises
------
typer.Exit
Raised when TOML files cannot be read or contain mismatched versions.
"""
files = list(_iter_files(pattern))
skip_parts = set(SKIP_PARTS) | _parse_skip_directories(skip_directories)
files = list(_iter_files(pattern, skip_parts=skip_parts))
if not files:
if _parse_bool(fail_on_empty):
typer.echo(
f"::error::No TOML files matched pattern {pattern}",
err=True,
)
raise typer.Exit(1)
typer.echo(f"::warning::No TOML files matched pattern {pattern}")
return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,25 @@ def test_install_step_uses_python_version_input() -> None:
steps = data["runs"]["steps"]
install_step = next(step for step in steps if step["name"] == "Install Python")
assert 'uv python install "${{ inputs.python-version }}"' in install_step["run"]


def test_validate_step_passes_fail_on_empty_flag() -> None:
"""Ensure the validation step forwards the fail-on-empty input."""
data = _load_action()
steps = data["runs"]["steps"]
validate_step = next(
step for step in steps if step["name"] == "Validate TOML files"
)
env = validate_step["env"]
assert env["INPUT_FAIL_ON_EMPTY"] == "${{ inputs.fail-on-empty }}"


def test_validate_step_passes_skip_directories_input() -> None:
"""Ensure the validation step forwards the skip-directories input."""
data = _load_action()
steps = data["runs"]["steps"]
validate_step = next(
step for step in steps if step["name"] == "Validate TOML files"
)
env = validate_step["env"]
assert env["INPUT_SKIP_DIRECTORIES"] == "${{ inputs.skip-directories }}"
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ def fixture_fake_token() -> str:
return f"test-token-{uuid.uuid4().hex}"


def test_sleep_with_jitter_allows_custom_rng(module: ModuleType) -> None:
"""Allow tests to provide deterministic jitter and sleep functions."""
calls: list[float] = []

class FixedRandom:
"""Stub RNG that always returns a fixed jitter fraction."""

def uniform(self, a: float, b: float) -> float:
assert a == 0.0
assert b == 0.1
return 0.05

module._sleep_with_jitter(4.0, jitter=FixedRandom(), sleep=calls.append)

assert calls == [4.2]


def test_success(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
Expand Down Expand Up @@ -206,8 +223,9 @@ def failing_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa:
monkeypatch.setattr(module.urllib.request, "urlopen", failing_urlopen)
monkeypatch.setattr(module.time, "sleep", lambda _: None)

with pytest.raises(module.typer.Exit):
with pytest.raises(module.typer.Exit) as exc_info:
module.main(tag="v1.0.0", token=fake_token, repo="owner/repo")

captured = capsys.readouterr()
assert exc_info.value.exit_code == 1
assert "temporary" in captured.err or "fetch" in captured.err
Loading
Loading