Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/scripts/check_pypi_artifacts.py
Original file line number Diff line number Diff line change
@@ -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()
85 changes: 29 additions & 56 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
130 changes: 130 additions & 0 deletions tests/test_pypi_artifacts.py
Original file line number Diff line number Diff line change
@@ -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()
Loading