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
9 changes: 8 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_service_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
51 changes: 51 additions & 0 deletions tools/download_release_artifacts.py
Original file line number Diff line number Diff line change
@@ -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()
Loading