@@ -3,8 +3,8 @@ name: Release Bundles
33# Manually-triggered workflow for publishing stable `lfx-<name>` bundle wheels
44# from src/bundles/* to PyPI. Bundles change infrequently, so we release them
55# on demand rather than on a schedule. Re-running the workflow without a
6- # version bump is a no-op (PyPI rejects the duplicate upload and we tolerate
7- # the conflict) .
6+ # version bump is a no-op; the publish job preflights PyPI and skips bundle
7+ # versions that are already present .
88
99on :
1010 workflow_dispatch :
@@ -182,6 +182,10 @@ jobs:
182182 needs.test-install.result == 'success'
183183 runs-on : ubuntu-latest
184184 steps :
185+ - name : Checkout code
186+ uses : actions/checkout@v6
187+ with :
188+ ref : ${{ inputs.release_tag || github.ref }}
185189 - name : Download bundles artifact
186190 uses : actions/download-artifact@v7
187191 with :
@@ -196,26 +200,122 @@ jobs:
196200 env :
197201 UV_PUBLISH_TOKEN : ${{ secrets.PYPI_API_TOKEN }}
198202 run : |
203+ set -euo pipefail
199204 shopt -s nullglob
200205 wheels=(bundles-dist/*.whl)
201206 if [ ${#wheels[@]} -eq 0 ]; then
202207 echo "No bundle wheels to publish."
203208 exit 0
204209 fi
210+
211+ publish_plan="$(mktemp)"
212+ uv run --with packaging --no-project python - "${wheels[@]}" > "$publish_plan" <<'PY'
213+ import email
214+ import json
215+ import sys
216+ import tomllib
217+ import urllib.error
218+ import urllib.request
219+ import zipfile
220+ from pathlib import Path
221+
222+ from packaging.requirements import Requirement
223+ from packaging.utils import canonicalize_name
224+ from packaging.version import InvalidVersion, Version
225+
226+ root_order = {}
227+ root_dependencies = tomllib.loads(Path("pyproject.toml").read_text())["project"].get("dependencies", [])
228+ for index, dependency in enumerate(root_dependencies):
229+ try:
230+ requirement = Requirement(dependency)
231+ except Exception:
232+ continue
233+ root_order.setdefault(canonicalize_name(requirement.name), index)
234+
235+ def read_wheel_metadata(path: Path) -> tuple[str, Version]:
236+ with zipfile.ZipFile(path) as wheel:
237+ metadata_path = next(name for name in wheel.namelist() if name.endswith(".dist-info/METADATA"))
238+ metadata = email.message_from_bytes(wheel.read(metadata_path))
239+ return canonicalize_name(metadata["Name"]), Version(metadata["Version"])
240+
241+ plan = []
242+ for wheel_arg in sys.argv[1:]:
243+ wheel_path = Path(wheel_arg)
244+ name, version = read_wheel_metadata(wheel_path)
245+ url = f"https://pypi.org/pypi/{name}/json"
246+ try:
247+ with urllib.request.urlopen(url, timeout=30) as response:
248+ project = json.load(response)
249+ except urllib.error.HTTPError as exc:
250+ if exc.code != 404:
251+ raise
252+ print(f"Will publish new PyPI project {name} {version}: {wheel_path}", file=sys.stderr)
253+ plan.append((0, root_order.get(name, 9999), name, str(wheel_path)))
254+ continue
255+
256+ published_versions = set()
257+ for version_text in project.get("releases", {}):
258+ try:
259+ published_versions.add(Version(version_text))
260+ except InvalidVersion:
261+ continue
262+
263+ if version in published_versions:
264+ print(f"Skipping {name} {version}: already published.", file=sys.stderr)
265+ continue
266+
267+ print(f"Will publish new version {name} {version}: {wheel_path}", file=sys.stderr)
268+ plan.append((1, root_order.get(name, 9999), name, str(wheel_path)))
269+
270+ for _, _, _, wheel_path in sorted(plan):
271+ print(wheel_path)
272+ PY
273+
274+ if [ ! -s "$publish_plan" ]; then
275+ echo "No bundle wheels need publishing."
276+ exit 0
277+ fi
278+
279+ mapfile -t wheels < "$publish_plan"
205280 failed=0
281+ pypi_publish_delay_seconds=60
282+ pypi_publish_max_attempts=5
283+ wheel_count=${#wheels[@]}
284+ wheel_number=0
206285 for wheel in "${wheels[@]}"; do
286+ wheel_number=$((wheel_number + 1))
207287 echo "Publishing $wheel"
208- # Capture stderr so we can tolerate "already exists" without failing
209- # the whole run — re-running this workflow without bumping a bundle
210- # version should be a no-op for that bundle.
211- if ! err=$(uv publish "$wheel" 2>&1); then
288+ attempt=1
289+ published=0
290+ while true; do
291+ # Capture stderr so we can tolerate "already exists" without failing
292+ # the whole run — re-running this workflow without bumping a bundle
293+ # version should be a no-op for that bundle.
294+ if err=$(uv publish "$wheel" 2>&1); then
295+ echo "$err"
296+ published=1
297+ break
298+ fi
212299 echo "$err"
213300 if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
214301 echo "Skipping $wheel: already published."
215- else
216- echo "Publish failed for $wheel"
217- failed=1
302+ break
303+ fi
304+ if echo "$err" | grep -qiE 'HTTP 429|429 Too Many Requests|Too many new projects created'; then
305+ if [ "$attempt" -lt "$pypi_publish_max_attempts" ]; then
306+ echo "PyPI rate limited $wheel; sleeping ${pypi_publish_delay_seconds}s before retry $((attempt + 1))/${pypi_publish_max_attempts}."
307+ sleep "$pypi_publish_delay_seconds"
308+ attempt=$((attempt + 1))
309+ continue
310+ fi
218311 fi
312+ echo "Publish failed for $wheel"
313+ failed=1
314+ break
315+ done
316+ if [ "$published" = "1" ] && [ "$wheel_number" -lt "$wheel_count" ]; then
317+ echo "Sleeping ${pypi_publish_delay_seconds}s before the next PyPI upload."
318+ sleep "$pypi_publish_delay_seconds"
219319 fi
220320 done
221321 if [ "$failed" = "1" ]; then
0 commit comments