From 06b9b0af27c4715bc03d53fc75513899d9585a5c Mon Sep 17 00:00:00 2001 From: Source Robotics Automation Date: Thu, 5 Nov 2026 19:05:52 +0100 Subject: [PATCH] Allow cold runner startup and verify release artifact uploads --- .github/workflows/publish.yml | 9 ++++- docs/RELEASING.md | 5 +-- tests/test_service_helper.py | 2 +- tools/download_release_artifacts.py | 51 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tools/download_release_artifacts.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2447d5e..90af3ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,11 @@ name: Publish Python package on: workflow_dispatch: + inputs: + tag: + description: Existing GitHub release tag + required: true + default: v0.1.0b1 permissions: contents: read jobs: @@ -13,7 +18,9 @@ jobs: python-version: '3.15' - run: python -m pip install .[dev] twine - run: python -m pytest -q - - run: python -m build + - run: python tools/download_release_artifacts.py + env: + RELEASE_TAG: ${{ inputs.tag }} - run: python -m twine check dist/* - run: python tools/check_release.py - uses: actions/upload-artifact@v4 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 109b267..8d5866b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -55,8 +55,9 @@ Environment: pypi No long-lived PyPI token is required by the prepared workflow. The PyPI account owner must authorize the publisher (a pending publisher can create the first project). -The manually triggered publication workflow runs tests, builds distributions, -checks metadata and public runtime availability, then publishes through OIDC. +The manually triggered publication workflow runs tests, downloads the exact existing +GitHub release wheel/sdist, verifies their GitHub SHA-256 digests, checks metadata and +public runtime availability, then publishes those same bytes through OIDC. [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/) The CI workflow tests installed packages on Windows/Linux/macOS. Hosted runners have diff --git a/tests/test_service_helper.py b/tests/test_service_helper.py index b7fb0f4..7ff5807 100644 --- a/tests/test_service_helper.py +++ b/tests/test_service_helper.py @@ -72,7 +72,7 @@ def quote(path): ], capture_output=True, text=True, - timeout=10, + timeout=30, # Cold PowerShell startup can exceed 10s on a hosted Windows runner. ) assert result.returncode == 0, result.stderr service = json.loads(mock_output.read_text(encoding="utf-8-sig")) diff --git a/tools/download_release_artifacts.py b/tools/download_release_artifacts.py new file mode 100644 index 0000000..2bc0e08 --- /dev/null +++ b/tools/download_release_artifacts.py @@ -0,0 +1,51 @@ +"""Fetch the exact published GitHub wheel and sdist for the PyPI upload job.""" + +from __future__ import annotations + +import hashlib +import json +import os +import urllib.request +from pathlib import Path + +from source_robotics_leap_motion import __version__ + + +def main(): + tag = os.environ.get("RELEASE_TAG", "v" + __version__) + if tag != "v" + __version__: + raise SystemExit("Release tag must match the tested source package version") + repository = "Source-Robotics/Source-Robotics-Leap-Motion" + request = urllib.request.Request( + f"https://api.github.com/repos/{repository}/releases/tags/{tag}", + headers={"User-Agent": "source-robotics-leap-release"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + release = json.load(response) + if release["draft"]: + raise SystemExit("A draft release cannot be published to PyPI") + names = { + f"source_robotics_leap_motion-{__version__}-py3-none-any.whl", + f"source_robotics_leap_motion-{__version__}.tar.gz", + } + assets = {asset["name"]: asset for asset in release["assets"]} + destination = Path("dist") + destination.mkdir(exist_ok=True) + for name in sorted(names): + asset = assets[name] + expected_url = f"https://github.com/{repository}/releases/download/{tag}/{name}" + if asset["browser_download_url"] != expected_url: + raise SystemExit("Unexpected release asset URL") + digest = asset.get("digest", "") + if not digest or not digest.startswith("sha256:"): + raise SystemExit("GitHub asset has no SHA-256 digest") + with urllib.request.urlopen(expected_url, timeout=30) as response: + data = response.read(10 * 1024 * 1024 + 1) + if len(data) != asset["size"] or hashlib.sha256(data).hexdigest() != digest[7:]: + raise SystemExit("Release artifact size or SHA-256 mismatch") + (destination / name).write_bytes(data) + print(f"Verified {name}") + + +if __name__ == "__main__": + main()