diff --git a/.github/scripts/check_pypi_artifacts.py b/.github/scripts/check_pypi_artifacts.py new file mode 100644 index 0000000..cb33d57 --- /dev/null +++ b/.github/scripts/check_pypi_artifacts.py @@ -0,0 +1,101 @@ +"""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, + 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") + + uploaded: dict[str, dict[str, object]] = {} + if release_json is not None: + uploaded = uploaded_artifacts(release_json) + + missing = sorted(expected - uploaded.keys()) + if not missing: + print("All built artifacts are already published", file=sys.stderr) + 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: + parser = argparse.ArgumentParser() + parser.add_argument("dist_dir", type=Path) + parser.add_argument("publish_dir", type=Path) + parser.add_argument("--release-json", type=Path) + args = parser.parse_args() + print( + str( + prepare_artifacts( + args.dist_dir, + args.publish_dir, + args.release_json, + ) + ).lower() + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 83376a6..ae8c71f 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,40 @@ 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" - - - name: Determine version bump type - id: version - 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 + 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: Build package run: uv build + - name: Check PyPI artifacts + id: registry + run: | + 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) + publish=$(python .github/scripts/check_pypi_artifacts.py \ + dist publish-dist --release-json "$response") + echo "publish=$publish" >> "$GITHUB_OUTPUT" + ;; + 404) + publish=$(python .github/scripts/check_pypi_artifacts.py dist publish-dist) + echo "publish=$publish" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unexpected PyPI response: HTTP $status" >&2 + exit 1 + ;; + esac + - name: Publish to PyPI + if: steps.registry.outputs.publish == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: publish-dist 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" 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()