diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afaec53..30ca199 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,12 @@ jobs: timeout-minutes: 30 outputs: version: ${{ steps.version.outputs.version }} + env: + VERSION: ${{ needs.build.outputs.version }} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -49,7 +54,14 @@ jobs: with: name: package path: artifacts - - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - id: select + run: | + count=$(python3 scripts/release-files.py "$VERSION" artifacts --pending pending) + echo "count=$count" >> "$GITHUB_OUTPUT" + - if: steps.select.outputs.count != '0' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: - packages-dir: artifacts/ + packages-dir: pending/ attestations: true + - name: Verify both distributions on PyPI + run: python3 scripts/release-files.py "$VERSION" artifacts diff --git a/scripts/release-files.py b/scripts/release-files.py new file mode 100644 index 0000000..d08ca21 --- /dev/null +++ b/scripts/release-files.py @@ -0,0 +1,68 @@ +"""Verify PyPI release files and select the missing distributions for publication.""" + +import argparse +import hashlib +import json +import re +import shutil +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +PACKAGE = "proofline-sdk" + + +def published(version: str) -> dict[str, str]: + try: + url = f"https://pypi.org/pypi/{PACKAGE}/{version}/json" + with urllib.request.urlopen(url, timeout=30) as response: + release = json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return {} + raise + if release["info"]["name"] != PACKAGE or release["info"]["version"] != version: + raise ValueError("PyPI returned a different distribution") + return {file["filename"]: file["digests"]["sha256"] for file in release["urls"]} + + +def missing(expected: dict[str, str], released: dict[str, str]) -> list[str]: + for name, digest in released.items(): + if expected.get(name) != digest: + raise ValueError(f"PyPI has unexpected content for {name}") + return sorted(expected.keys() - released.keys()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("artifacts", type=Path) + parser.add_argument("--pending", type=Path, help="copy only missing files to a new directory") + args = parser.parse_args() + if not re.fullmatch(r"(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}", args.version): + raise ValueError("expected a stable package version") + files = sorted(args.artifacts.iterdir()) + prefix = f"proofline_sdk-{args.version}" + if {file.name for file in files} != {prefix + ".tar.gz", prefix + "-py3-none-any.whl"}: + raise ValueError("expected the wheel and source distribution for this version") + expected = {file.name: hashlib.sha256(file.read_bytes()).hexdigest() for file in files} + if args.pending: + names = missing(expected, published(args.version)) + args.pending.mkdir() + for name in names: + shutil.copyfile(args.artifacts / name, args.pending / name) + print(len(names)) + return + for attempt in range(6): + names = missing(expected, published(args.version)) + if not names: + return + if attempt < 5: + time.sleep(5) + sys.exit("PyPI is missing " + ", ".join(names)) + + +if __name__ == "__main__": + main()