diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d4985f..db97588 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,9 +278,9 @@ jobs: env: RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: >- - uv run --isolated --no-project - --with "ml4t-engineer==${RELEASE_VERSION}" - python tests/documentation_workflows.py --readme-only + python scripts/ci/release.py smoke-test + ml4t-engineer "${RELEASE_VERSION}" + tests/documentation_workflows.py - name: Verify GitHub release artifacts against the manifest env: GH_TOKEN: ${{ github.token }} diff --git a/scripts/ci/release.py b/scripts/ci/release.py index fb378c5..b24db66 100644 --- a/scripts/ci/release.py +++ b/scripts/ci/release.py @@ -4,6 +4,8 @@ import argparse import json +import subprocess +import time from pathlib import Path from typing import Any from urllib.error import HTTPError @@ -67,6 +69,36 @@ def verify_publication(candidate_dir: Path) -> None: raise ValueError("PyPI artifacts do not match the candidate manifest") +def verify_install( + name: str, + version: str, + script: Path, + *, + attempts: int = 12, + retry_seconds: int = 10, +) -> None: + command = [ + "uv", + "run", + "--isolated", + "--no-project", + "--refresh-package", + name, + "--with", + f"{name}=={version}", + "python", + str(script), + "--readme-only", + ] + for attempt in range(attempts): + result = subprocess.run(command, check=False) + if result.returncode == 0: + return + if attempt + 1 < attempts: + time.sleep(retry_seconds) + raise RuntimeError(f"failed to install and exercise {name} {version} from PyPI") + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command", required=True) @@ -75,6 +107,10 @@ def _parser() -> argparse.ArgumentParser: absent.add_argument("version") verify = subparsers.add_parser("verify") verify.add_argument("candidate_dir", type=Path) + smoke_test = subparsers.add_parser("smoke-test") + smoke_test.add_argument("name") + smoke_test.add_argument("version") + smoke_test.add_argument("script", type=Path) return parser @@ -82,8 +118,10 @@ def main() -> None: args = _parser().parse_args() if args.command == "require-absent": require_version_absent(args.name, args.version) - else: + elif args.command == "verify": verify_publication(args.candidate_dir) + else: + verify_install(args.name, args.version, args.script) if __name__ == "__main__": diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index b28eedd..617a169 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -168,3 +168,49 @@ def test_pypi_publication_must_match_candidate_manifest( response["urls"][0]["digests"]["sha256"] = "0" * 64 with pytest.raises(ValueError, match="artifacts do not match"): release.verify_publication(candidate_dir) + + +def test_pypi_install_verification_retries_index_propagation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + results = iter( + ( + subprocess.CompletedProcess([], 1), + subprocess.CompletedProcess([], 1), + subprocess.CompletedProcess([], 0), + ) + ) + commands: list[list[str]] = [] + sleeps: list[int] = [] + + def run(command: list[str], *, check: bool) -> subprocess.CompletedProcess[bytes]: + assert check is False + commands.append(command) + return next(results) + + monkeypatch.setattr(release.subprocess, "run", run) + monkeypatch.setattr(release.time, "sleep", sleeps.append) + + release.verify_install( + "ml4t-engineer", + "0.1.4", + Path("tests/documentation_workflows.py"), + attempts=3, + retry_seconds=7, + ) + + assert len(commands) == 3 + assert commands[0] == [ + "uv", + "run", + "--isolated", + "--no-project", + "--refresh-package", + "ml4t-engineer", + "--with", + "ml4t-engineer==0.1.4", + "python", + str(Path("tests/documentation_workflows.py")), + "--readme-only", + ] + assert sleeps == [7, 7] diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index 730f520..e0fcf9b 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -192,7 +192,7 @@ def test_release_publishes_only_the_qualified_artifact() -> None: in verify_commands["Verify PyPI metadata and artifact SHA256 digests"] ) assert ( - "--readme-only" + "release.py smoke-test" in verify_commands["Install the published wheel and run the README quick start"] ) assert (