From 9d00951b1ec3d2096ec7a172bd65fcf328941167 Mon Sep 17 00:00:00 2001 From: poly-william <244229515+poly-william@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:28:24 -0400 Subject: [PATCH 1/4] fix: repair 1.0.0 publishing --- .github/workflows/publish.yml | 78 +++++++++++------------------------ pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 55 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 83376a6..4535aec 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,15 +9,13 @@ jobs: publish: runs-on: ubuntu-latest permissions: - contents: write - id-token: write + contents: read steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GH_PAT }} - name: Set up Python uses: actions/setup-python@v5 @@ -33,65 +31,37 @@ jobs: - name: Run tests run: uv run pytest - - name: Configure Git + - name: Read package metadata + id: package run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + echo "name=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["name"])')" >> "$GITHUB_OUTPUT" + echo "version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" >> "$GITHUB_OUTPUT" - - name: Determine version bump type - id: version + - name: Check PyPI + id: registry run: | - COMMIT_MSG=$(git log -1 --pretty=%B) - if echo "$COMMIT_MSG" | grep -iq "^feat!:\|BREAKING CHANGE"; then - echo "bump=major" >> $GITHUB_OUTPUT - elif echo "$COMMIT_MSG" | grep -iq "^feat:"; then - echo "bump=minor" >> $GITHUB_OUTPUT - else - echo "bump=patch" >> $GITHUB_OUTPUT - fi - - - name: Bump version - run: | - # Get current version from pyproject.toml - CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') - if [ -z "$CURRENT_VERSION" ]; then - echo "Error: Could not extract current version from pyproject.toml" - exit 1 - fi - - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - - BUMP_TYPE="${{ steps.version.outputs.bump }}" - - if [ "$BUMP_TYPE" = "major" ]; then - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - elif [ "$BUMP_TYPE" = "minor" ]; then - MINOR=$((MINOR + 1)) - PATCH=0 - else - PATCH=$((PATCH + 1)) - fi - - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - - # Update version in pyproject.toml - use a pattern that matches any version - sed -i 's/^version = ".*"/version = "'"$NEW_VERSION"'"/' pyproject.toml - - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - echo "Bumped version from $CURRENT_VERSION to $NEW_VERSION" - - - name: Commit and push version bump - run: | - git add pyproject.toml - git commit -m "chore: bump version to ${{ env.NEW_VERSION }} [skip ci]" - git push + status=$(curl --silent --show-error --output /dev/null --write-out "%{http_code}" \ + "https://pypi.org/pypi/${{ steps.package.outputs.name }}/${{ steps.package.outputs.version }}/json") + case "$status" in + 200) + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "${{ steps.package.outputs.name }}==${{ steps.package.outputs.version }} is already published" + ;; + 404) + echo "publish=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unexpected PyPI response: HTTP $status" >&2 + exit 1 + ;; + esac - name: Build package + if: steps.registry.outputs.publish == 'true' run: uv build - name: Publish to PyPI + if: steps.registry.outputs.publish == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index ff78e9f..7558736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "polymarket-us" -version = "0.1.2" +version = "1.0.0" description = "Polymarket US Python SDK" readme = "README.md" license = "MIT" From 66ed01dd9cae50edd366e8a4c70dffb4be4a8511 Mon Sep 17 00:00:00 2001 From: poly-william <244229515+poly-william@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:39:53 -0400 Subject: [PATCH 2/4] fix: resume partial PyPI releases --- .github/scripts/check_pypi_artifacts.py | 34 +++++++++++++++++++++++++ .github/workflows/publish.yml | 17 +++++++------ 2 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/check_pypi_artifacts.py diff --git a/.github/scripts/check_pypi_artifacts.py b/.github/scripts/check_pypi_artifacts.py new file mode 100644 index 0000000..f8ea29e --- /dev/null +++ b/.github/scripts/check_pypi_artifacts.py @@ -0,0 +1,34 @@ +"""Report whether a PyPI release is missing any locally built artifacts.""" + +import argparse +import json +import sys +from pathlib import Path + + +def publish_required(dist_dir: Path, release_json: Path) -> bool: + expected = {path.name for path in dist_dir.iterdir() if path.name.endswith((".whl", ".tar.gz"))} + if not expected: + raise RuntimeError("Build produced no wheel or source distribution") + + with release_json.open(encoding="utf-8") as response: + uploaded = {file["filename"] for file in json.load(response)["urls"]} + + missing = sorted(expected - uploaded) + if missing: + print("Missing from PyPI: " + ", ".join(missing), file=sys.stderr) + else: + print("All built artifacts are already published", file=sys.stderr) + return bool(missing) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("dist_dir", type=Path) + parser.add_argument("release_json", type=Path) + args = parser.parse_args() + print(str(publish_required(args.dist_dir, args.release_json)).lower()) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4535aec..e16cf6f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,15 +37,19 @@ jobs: echo "name=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["name"])')" >> "$GITHUB_OUTPUT" echo "version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" >> "$GITHUB_OUTPUT" - - name: Check PyPI + - name: Build package + run: uv build + + - name: Check PyPI artifacts id: registry run: | - status=$(curl --silent --show-error --output /dev/null --write-out "%{http_code}" \ + response=$(mktemp) + status=$(curl --silent --show-error --output "$response" --write-out "%{http_code}" \ "https://pypi.org/pypi/${{ steps.package.outputs.name }}/${{ steps.package.outputs.version }}/json") case "$status" in 200) - echo "publish=false" >> "$GITHUB_OUTPUT" - echo "${{ steps.package.outputs.name }}==${{ steps.package.outputs.version }} is already published" + publish=$(python .github/scripts/check_pypi_artifacts.py dist "$response") + echo "publish=$publish" >> "$GITHUB_OUTPUT" ;; 404) echo "publish=true" >> "$GITHUB_OUTPUT" @@ -56,12 +60,9 @@ jobs: ;; esac - - name: Build package - if: steps.registry.outputs.publish == 'true' - run: uv build - - name: Publish to PyPI if: steps.registry.outputs.publish == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true From 3afa7eef7a88061b67505f9580ab4f90fcd15d65 Mon Sep 17 00:00:00 2001 From: poly-william <244229515+poly-william@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:41:31 -0400 Subject: [PATCH 3/4] fix: upload only missing PyPI artifacts --- .github/scripts/check_pypi_artifacts.py | 31 +++++++++++++++++++++---- .github/workflows/publish.yml | 8 ++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/scripts/check_pypi_artifacts.py b/.github/scripts/check_pypi_artifacts.py index f8ea29e..8adee35 100644 --- a/.github/scripts/check_pypi_artifacts.py +++ b/.github/scripts/check_pypi_artifacts.py @@ -2,21 +2,33 @@ import argparse import json +import shutil import sys from pathlib import Path -def publish_required(dist_dir: Path, release_json: Path) -> bool: +def prepare_artifacts( + dist_dir: Path, + publish_dir: Path, + release_json: Path | None, +) -> bool: expected = {path.name for path in dist_dir.iterdir() if path.name.endswith((".whl", ".tar.gz"))} if not expected: raise RuntimeError("Build produced no wheel or source distribution") - with release_json.open(encoding="utf-8") as response: - uploaded = {file["filename"] for file in json.load(response)["urls"]} + uploaded: set[str] = set() + if release_json is not None: + with release_json.open(encoding="utf-8") as response: + uploaded = {file["filename"] for file in json.load(response)["urls"]} missing = sorted(expected - uploaded) if missing: print("Missing from PyPI: " + ", ".join(missing), file=sys.stderr) + publish_dir.mkdir(parents=True, exist_ok=True) + if any(publish_dir.iterdir()): + raise RuntimeError(f"Publish directory is not empty: {publish_dir}") + for filename in missing: + shutil.copy2(dist_dir / filename, publish_dir / filename) else: print("All built artifacts are already published", file=sys.stderr) return bool(missing) @@ -25,9 +37,18 @@ def publish_required(dist_dir: Path, release_json: Path) -> bool: def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("dist_dir", type=Path) - parser.add_argument("release_json", type=Path) + parser.add_argument("publish_dir", type=Path) + parser.add_argument("--release-json", type=Path) args = parser.parse_args() - print(str(publish_required(args.dist_dir, args.release_json)).lower()) + print( + str( + prepare_artifacts( + args.dist_dir, + args.publish_dir, + args.release_json, + ) + ).lower() + ) if __name__ == "__main__": diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e16cf6f..ae8c71f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,11 +48,13 @@ jobs: "https://pypi.org/pypi/${{ steps.package.outputs.name }}/${{ steps.package.outputs.version }}/json") case "$status" in 200) - publish=$(python .github/scripts/check_pypi_artifacts.py dist "$response") + publish=$(python .github/scripts/check_pypi_artifacts.py \ + dist publish-dist --release-json "$response") echo "publish=$publish" >> "$GITHUB_OUTPUT" ;; 404) - echo "publish=true" >> "$GITHUB_OUTPUT" + publish=$(python .github/scripts/check_pypi_artifacts.py dist publish-dist) + echo "publish=$publish" >> "$GITHUB_OUTPUT" ;; *) echo "Unexpected PyPI response: HTTP $status" >&2 @@ -65,4 +67,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} - skip-existing: true + packages-dir: publish-dist From 76633ddf299bf04ba1e164e821cdcb06874f7260 Mon Sep 17 00:00:00 2001 From: poly-william <244229515+poly-william@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:50:09 -0400 Subject: [PATCH 4/4] fix: verify partial PyPI artifact digests --- .github/scripts/check_pypi_artifacts.py | 74 +++++++++++--- tests/test_pypi_artifacts.py | 130 ++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 tests/test_pypi_artifacts.py diff --git a/.github/scripts/check_pypi_artifacts.py b/.github/scripts/check_pypi_artifacts.py index 8adee35..cb33d57 100644 --- a/.github/scripts/check_pypi_artifacts.py +++ b/.github/scripts/check_pypi_artifacts.py @@ -1,11 +1,54 @@ """Report whether a PyPI release is missing any locally built artifacts.""" import argparse +import hashlib +import hmac import json +import re import shutil import sys from pathlib import Path +SHA256_PATTERN = re.compile(r"[0-9a-fA-F]{64}") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def uploaded_artifacts(release_json: Path) -> dict[str, dict[str, object]]: + with release_json.open(encoding="utf-8") as response: + release = json.load(response) + + files = release.get("urls") + if not isinstance(files, list): + raise RuntimeError("PyPI release metadata has no urls list") + + uploaded: dict[str, dict[str, object]] = {} + for file in files: + if not isinstance(file, dict): + raise RuntimeError("PyPI release metadata contains an invalid file entry") + filename = file.get("filename") + if not isinstance(filename, str) or not filename: + raise RuntimeError("PyPI release metadata contains an invalid filename") + if filename in uploaded: + raise RuntimeError(f"PyPI release metadata contains duplicate filename: {filename}") + uploaded[filename] = file + return uploaded + + +def validate_uploaded_artifact(path: Path, metadata: dict[str, object]) -> None: + digests = metadata.get("digests") + remote_sha256 = digests.get("sha256") if isinstance(digests, dict) else None + if not isinstance(remote_sha256, str) or SHA256_PATTERN.fullmatch(remote_sha256) is None: + raise RuntimeError(f"PyPI release metadata has no valid SHA256 for: {path.name}") + if not hmac.compare_digest(sha256(path), remote_sha256.lower()): + raise RuntimeError(f"SHA256 mismatch for already-published artifact: {path.name}") + def prepare_artifacts( dist_dir: Path, @@ -16,22 +59,25 @@ def prepare_artifacts( if not expected: raise RuntimeError("Build produced no wheel or source distribution") - uploaded: set[str] = set() + uploaded: dict[str, dict[str, object]] = {} if release_json is not None: - with release_json.open(encoding="utf-8") as response: - uploaded = {file["filename"] for file in json.load(response)["urls"]} - - missing = sorted(expected - uploaded) - if missing: - print("Missing from PyPI: " + ", ".join(missing), file=sys.stderr) - publish_dir.mkdir(parents=True, exist_ok=True) - if any(publish_dir.iterdir()): - raise RuntimeError(f"Publish directory is not empty: {publish_dir}") - for filename in missing: - shutil.copy2(dist_dir / filename, publish_dir / filename) - else: + uploaded = uploaded_artifacts(release_json) + + missing = sorted(expected - uploaded.keys()) + if not missing: print("All built artifacts are already published", file=sys.stderr) - return bool(missing) + return False + + for filename in sorted(expected & uploaded.keys()): + validate_uploaded_artifact(dist_dir / filename, uploaded[filename]) + + print("Missing from PyPI: " + ", ".join(missing), file=sys.stderr) + publish_dir.mkdir(parents=True, exist_ok=True) + if any(publish_dir.iterdir()): + raise RuntimeError(f"Publish directory is not empty: {publish_dir}") + for filename in missing: + shutil.copy2(dist_dir / filename, publish_dir / filename) + return True def main() -> None: diff --git a/tests/test_pypi_artifacts.py b/tests/test_pypi_artifacts.py new file mode 100644 index 0000000..ad136bf --- /dev/null +++ b/tests/test_pypi_artifacts.py @@ -0,0 +1,130 @@ +"""Offline tests for partial PyPI release recovery.""" + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / ".github" / "scripts" / "check_pypi_artifacts.py" +WHEEL = "polymarket_us-1.0.0-py3-none-any.whl" +SDIST = "polymarket_us-1.0.0.tar.gz" + + +def build_artifacts(tmp_path: Path) -> Path: + dist = tmp_path / "dist" + dist.mkdir() + (dist / WHEEL).write_bytes(b"wheel from current build") + (dist / SDIST).write_bytes(b"sdist from current build") + return dist + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write_release( + tmp_path: Path, + entries: list[dict[str, object]], +) -> Path: + release = tmp_path / "release.json" + release.write_text(json.dumps({"urls": entries}), encoding="utf-8") + return release + + +def run_guard( + dist: Path, + publish_dir: Path, + release: Path | None = None, +) -> subprocess.CompletedProcess[str]: + command = [sys.executable, str(SCRIPT), str(dist), str(publish_dir)] + if release is not None: + command.extend(["--release-json", str(release)]) + return subprocess.run(command, check=False, capture_output=True, text=True) + + +def staged_files(publish_dir: Path) -> set[str]: + return {path.name for path in publish_dir.iterdir()} if publish_dir.exists() else set() + + +def test_absent_release_stages_both_artifacts(tmp_path: Path) -> None: + dist = build_artifacts(tmp_path) + publish_dir = tmp_path / "publish" + + result = run_guard(dist, publish_dir) + + assert result.returncode == 0 + assert result.stdout.strip() == "true" + assert staged_files(publish_dir) == {WHEEL, SDIST} + + +def test_complete_release_skips_without_digest_comparison(tmp_path: Path) -> None: + dist = build_artifacts(tmp_path) + release = write_release(tmp_path, [{"filename": WHEEL}, {"filename": SDIST}]) + publish_dir = tmp_path / "publish" + + result = run_guard(dist, publish_dir, release) + + assert result.returncode == 0 + assert result.stdout.strip() == "false" + assert staged_files(publish_dir) == set() + + +@pytest.mark.parametrize("uploaded,missing", [(WHEEL, SDIST), (SDIST, WHEEL)]) +def test_matching_partial_release_stages_only_missing_artifact( + tmp_path: Path, + uploaded: str, + missing: str, +) -> None: + dist = build_artifacts(tmp_path) + release = write_release( + tmp_path, + [{"filename": uploaded, "digests": {"sha256": sha256(dist / uploaded)}}], + ) + publish_dir = tmp_path / "publish" + + result = run_guard(dist, publish_dir, release) + + assert result.returncode == 0 + assert result.stdout.strip() == "true" + assert staged_files(publish_dir) == {missing} + + +@pytest.mark.parametrize("uploaded", [WHEEL, SDIST]) +def test_mismatched_partial_release_fails_without_staging( + tmp_path: Path, + uploaded: str, +) -> None: + dist = build_artifacts(tmp_path) + release = write_release( + tmp_path, + [{"filename": uploaded, "digests": {"sha256": "0" * 64}}], + ) + publish_dir = tmp_path / "publish" + + result = run_guard(dist, publish_dir, release) + + assert result.returncode != 0 + assert "SHA256 mismatch" in result.stderr + assert staged_files(publish_dir) == set() + + +@pytest.mark.parametrize("digests", [None, {"sha256": "not-a-digest"}]) +def test_partial_release_with_missing_or_invalid_digest_fails_closed( + tmp_path: Path, + digests: dict[str, str] | None, +) -> None: + dist = build_artifacts(tmp_path) + entry: dict[str, object] = {"filename": WHEEL} + if digests is not None: + entry["digests"] = digests + release = write_release(tmp_path, [entry]) + publish_dir = tmp_path / "publish" + + result = run_guard(dist, publish_dir, release) + + assert result.returncode != 0 + assert "no valid SHA256" in result.stderr + assert staged_files(publish_dir) == set()