diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5ddfcb3..7b1ccff7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -261,7 +261,78 @@ jobs: path: dist merge-multiple: true + - name: Validate wheels + run: | + pip install twine + for whl in dist/*.whl; do + unzip -qt "$whl" + done + twine check --strict dist/* + + - name: Check PyPI for existing version + id: pypi-check + run: | + version="${GITHUB_REF_NAME}" + python3 - "$version" <<'PYCHECK' + import hashlib, json, os, sys, urllib.request + from pathlib import Path + + version = sys.argv[1] + dist = Path("dist") + url = f"https://pypi.org/pypi/taskito/{version}/json" + req = urllib.request.Request(url, headers={"User-Agent": "taskito-release/1.0"}) + + try: + resp = urllib.request.urlopen(req, timeout=30) + remote = {f["filename"]: f["digests"]["sha256"] for f in json.loads(resp.read())["urls"]} + except urllib.error.HTTPError as e: + if e.code == 404: + remote = {} + else: + raise + + local = {} + for p in sorted(dist.iterdir()): + if p.suffix in (".whl", ".gz"): + local[p.name] = hashlib.sha256(p.read_bytes()).hexdigest() + + conflicts = [f for f in local if f in remote and local[f] != remote[f]] + if conflicts: + for f in conflicts: + print(f"::error::Hash conflict: {f} (local {local[f][:12]}… vs remote {remote[f][:12]}…)") + sys.exit(1) + + missing = [f for f in local if f not in remote] + out = os.environ["GITHUB_OUTPUT"] + with open(out, "a") as fh: + if not missing: + fh.write("all_uploaded=true\n") + print(f"::notice::All {len(local)} artifacts on PyPI — skipping upload") + else: + fh.write("all_uploaded=false\n") + print(f"::notice::{len(missing)}/{len(local)} artifact(s) missing on PyPI") + PYCHECK + - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + if: steps.pypi-check.outputs.all_uploaded != 'true' + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: packages-dir: dist/ + skip-existing: true + + - name: Verify version on PyPI + run: | + version="${GITHUB_REF_NAME}" + for _attempt in 1 2 3 4 5; do + status=$(curl -s -o /dev/null -w '%{http_code}' --head \ + --connect-timeout 10 --max-time 30 \ + -H "User-Agent: taskito-release/1.0" \ + "https://pypi.org/pypi/taskito/${version}/json") + if [ "$status" = "200" ]; then + echo "taskito==${version} live on PyPI" + exit 0 + fi + sleep 15 + done + echo "::error::taskito==${version} not found on PyPI" + exit 1