From bc63acb2adae159def86eb272cc8c31ec86a6c7b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:08:35 +0530 Subject: [PATCH 1/2] fix(ci): skip already-uploaded wheels on publish rerun --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5ddfcb3..4fa46cc7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -265,3 +265,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ + skip-existing: true From 60739636b6ec196703198a44094ade8ca1d78e6c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:13:38 +0530 Subject: [PATCH 2/2] fix(ci): verify PyPI upload after skip-existing publish --- .github/workflows/publish.yml | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4fa46cc7..7b1ccff7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -261,8 +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