Skip to content

Commit b70025a

Browse files
authored
fix(release): tolerate PyPI index propagation delays (#33)
1 parent 77911ad commit b70025a

3 files changed

Lines changed: 161 additions & 2 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import json
2+
import os
3+
import subprocess
4+
import sys
5+
import tempfile
6+
import unittest
7+
from pathlib import Path
8+
9+
WORKFLOW = Path(__file__).resolve().parents[1] / "workflows" / "release.yml"
10+
11+
12+
def install_step():
13+
lines = WORKFLOW.read_text().splitlines()
14+
start = lines.index(" - name: Verify the installed public package") + 1
15+
run = lines.index(" run: |", start) + 1
16+
end = run
17+
while end < len(lines) and (not lines[end].strip() or lines[end].startswith(" ")):
18+
end += 1
19+
return "\n".join(line[10:] for line in lines[run:end]) + "\n"
20+
21+
22+
FAKE_PYTHON = """\
23+
import json
24+
import os
25+
import sys
26+
from pathlib import Path
27+
28+
root = Path(os.environ["TEST_ROOT"])
29+
args = sys.argv[1:]
30+
with (root / "calls.jsonl").open("a") as calls:
31+
calls.write(json.dumps(args) + "\\n")
32+
if args[:2] == ["-m", "venv"]:
33+
venv = Path(args[2])
34+
(root / "venv-path").write_text(str(venv))
35+
(venv / "bin").mkdir()
36+
(venv / "bin" / "python").symlink_to(Path(__file__).resolve())
37+
elif args[:3] == ["-m", "pip", "install"]:
38+
counter = root / "attempts"
39+
attempt = int(counter.read_text()) if counter.exists() else 0
40+
counter.write_text(str(attempt + 1))
41+
outcomes = json.loads(os.environ["PIP_OUTCOMES"])
42+
status, output = outcomes[min(attempt, len(outcomes) - 1)]
43+
print(output, file=sys.stderr if status else sys.stdout)
44+
raise SystemExit(status)
45+
elif args == ["-"]:
46+
(root / "import-check.py").write_text(sys.stdin.read())
47+
raise SystemExit(int(os.environ.get("IMPORT_STATUS", "0")))
48+
else:
49+
raise SystemExit(f"Unexpected Python arguments: {args}")
50+
"""
51+
52+
53+
class PublicInstallTests(unittest.TestCase):
54+
missing = (1, "ERROR: No matching distribution found for qca-sdk==0.12.0")
55+
56+
def run_install(self, outcomes, *, import_status=0):
57+
with tempfile.TemporaryDirectory() as directory:
58+
root = Path(directory)
59+
binary = root / "python"
60+
binary.write_text(f"#!{sys.executable}\n" + FAKE_PYTHON)
61+
binary.chmod(0o755)
62+
sleep = root / "sleep"
63+
sleep.write_text('#!/bin/sh\nprintf "%s\\n" "$1" >> "$TEST_ROOT/sleeps"\n')
64+
sleep.chmod(0o755)
65+
env = dict(
66+
os.environ,
67+
PATH=f"{root}{os.pathsep}{os.environ['PATH']}",
68+
TEST_ROOT=str(root),
69+
PIP_OUTCOMES=json.dumps(outcomes),
70+
IMPORT_STATUS=str(import_status),
71+
PACKAGE_NAME="qca-sdk",
72+
RELEASE_VERSION="0.12.0",
73+
PYPI_INDEX_URL="https://pypi.org/simple/",
74+
GITHUB_STEP_SUMMARY=str(root / "summary"),
75+
)
76+
result = subprocess.run(["bash", "-c", install_step()], env=env, capture_output=True, text=True, timeout=30)
77+
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
78+
installs = [args for args in calls if args[:3] == ["-m", "pip", "install"]]
79+
for command in installs:
80+
self.assertIn("--no-cache-dir", command)
81+
self.assertEqual(command[-3:], ["--index-url", "https://pypi.org/simple/", "qca-sdk==0.12.0"])
82+
self.assertFalse(Path((root / "venv-path").read_text()).exists(), "Temporary venv must be removed")
83+
sleeps = (root / "sleeps").read_text().splitlines() if (root / "sleeps").exists() else []
84+
summary = (root / "summary").read_text() if (root / "summary").exists() else ""
85+
return result, len(installs), sleeps, calls.count(["-"]), summary
86+
87+
def test_available_release_installs_once_and_checks_imports(self):
88+
result, attempts, sleeps, imports, summary = self.run_install([(0, "Installed")])
89+
self.assertEqual(result.returncode, 0, result.stderr)
90+
self.assertEqual((attempts, sleeps, imports), (1, [], 1))
91+
self.assertIn("Verified clean installation", summary)
92+
93+
def test_index_lag_retries_then_checks_imports(self):
94+
result, attempts, sleeps, imports, summary = self.run_install([self.missing, self.missing, (0, "Installed")])
95+
self.assertEqual(result.returncode, 0, result.stderr)
96+
self.assertEqual((attempts, sleeps, imports), (3, ["30", "30"], 1))
97+
self.assertIn("attempt 3/21", result.stdout)
98+
self.assertIn("Verified clean installation", summary)
99+
100+
def test_unavailable_release_exhausts_limit_without_imports_or_success(self):
101+
result, attempts, sleeps, imports, summary = self.run_install([self.missing])
102+
self.assertEqual(result.returncode, 1)
103+
self.assertEqual((attempts, sleeps, imports, summary), (21, ["30"] * 20, 0, ""))
104+
self.assertIn("still unavailable", result.stderr)
105+
self.assertIn(self.missing[1], result.stderr)
106+
107+
def test_other_install_errors_fail_immediately(self):
108+
for message in (
109+
"ERROR: No matching distribution found for httpx==999",
110+
"ERROR: No matching distribution found for qca-sdk==0.12.01",
111+
"ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE.",
112+
"ERROR: ResolutionImpossible",
113+
):
114+
with self.subTest(message=message):
115+
result, attempts, sleeps, imports, summary = self.run_install([(2, message)])
116+
self.assertEqual(result.returncode, 2)
117+
self.assertEqual((attempts, sleeps, imports, summary), (1, [], 0, ""))
118+
self.assertIn(message, result.stderr)
119+
120+
def test_import_failure_is_not_retried_or_reported_as_success(self):
121+
result, attempts, sleeps, imports, summary = self.run_install([(0, "Installed")], import_status=3)
122+
self.assertEqual(result.returncode, 3)
123+
self.assertEqual((attempts, sleeps, imports, summary), (1, [], 1, ""))
124+
125+
def test_install_step_has_wall_clock_limit(self):
126+
lines = WORKFLOW.read_text().splitlines()
127+
start = lines.index(" - name: Verify the installed public package") + 1
128+
run = lines.index(" run: |", start)
129+
self.assertIn(" timeout-minutes: 15", lines[start:run])
130+
131+
132+
if __name__ == "__main__":
133+
unittest.main()

‎.github/workflows/release.yml‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,34 @@ jobs:
488488
[[ "$verified" == true ]]
489489
490490
- name: Verify the installed public package
491+
timeout-minutes: 15
491492
run: |
492493
set -euo pipefail
493494
venv_dir=$(mktemp -d)
494495
trap 'rm -rf "$venv_dir"' EXIT
495496
python -m venv "$venv_dir"
496-
"$venv_dir/bin/python" -m pip install --disable-pip-version-check --no-cache-dir \
497-
--index-url "$PYPI_INDEX_URL" "$PACKAGE_NAME==$RELEASE_VERSION"
497+
install_log="$venv_dir/install.log"
498+
for attempt in $(seq 1 21); do
499+
echo "Installing $PACKAGE_NAME==$RELEASE_VERSION from PyPI (attempt $attempt/21)."
500+
if "$venv_dir/bin/python" -m pip install --disable-pip-version-check --no-cache-dir \
501+
--index-url "$PYPI_INDEX_URL" "$PACKAGE_NAME==$RELEASE_VERSION" >"$install_log" 2>&1; then
502+
cat "$install_log"
503+
break
504+
else
505+
install_status=$?
506+
cat "$install_log" >&2
507+
fi
508+
# The Simple index can lag behind the already-verified JSON metadata.
509+
if ! grep -Fxq "ERROR: No matching distribution found for $PACKAGE_NAME==$RELEASE_VERSION" "$install_log"; then
510+
exit "$install_status"
511+
fi
512+
if [[ "$attempt" == 21 ]]; then
513+
echo "$PACKAGE_NAME==$RELEASE_VERSION is still unavailable from the PyPI index after 21 attempts." >&2
514+
exit "$install_status"
515+
fi
516+
echo "The release is not yet installable from the PyPI index; retrying in 30 seconds."
517+
sleep 30
518+
done
498519
"$venv_dir/bin/python" - <<'PY'
499520
import os
500521
from importlib.metadata import version

‎CONTRIBUTING.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ Before the first release, create the GitHub `release` Environment with required
135135

136136
3. After approval, the workflow creates or reuses the annotated `v<version>` tag, publishes the approved wheel and sdist with PyPI Trusted Publishing, verifies their public digests, and performs a no-cache installation of the exact version and its synchronous and asynchronous imports. After verification, it publishes the changelog entry as a GitHub Release.
137137

138+
If the PyPI Simple index has not exposed the exact release yet, installation retries
139+
up to 21 times at 30-second intervals, with a 15-minute limit for the installation
140+
step. Other installation errors and version/import failures stop verification;
141+
the preceding approved-artifact digest checks remain required.
142+
138143
PyPI versions are immutable. Never reuse or overwrite one: fix forward with a new release pull request and version, and yank an unusable version when necessary. A safe rerun must use the same SHA, version, and `batch_id`; it verifies the existing PyPI files without uploading them again.
139144

140145
## Pull requests

0 commit comments

Comments
 (0)