From e7db2718ffd08bb3aeff10ac8613298495289350 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 9 Sep 2026 11:01:42 -0400 Subject: [PATCH 1/2] ci: retry PyPI install verification after publish --- .github/workflows/release.yml | 6 ++--- scripts/ci/release.py | 40 +++++++++++++++++++++++++++- tests/test_release_candidate.py | 46 +++++++++++++++++++++++++++++++++ tests/test_release_policy.py | 2 +- 4 files changed, 89 insertions(+), 5 deletions(-) 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..0f103df 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", + "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 ( From ce0ea4f362666b4cc6a3b2a4efd71697071ee411 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 9 Sep 2026 11:09:22 -0400 Subject: [PATCH 2/2] test: normalize release smoke-test paths --- tests/test_release_candidate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index 0f103df..617a169 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -210,7 +210,7 @@ def run(command: list[str], *, check: bool) -> subprocess.CompletedProcess[bytes "--with", "ml4t-engineer==0.1.4", "python", - "tests/documentation_workflows.py", + str(Path("tests/documentation_workflows.py")), "--readme-only", ] assert sleeps == [7, 7]