Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
156 changes: 156 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Publish Package

# Builds hash-pinnable sdist + wheel artifacts for a published GitHub Release
# and publishes them to PyPI. This exists so downstream consumers can depend on
# fast-mlsirm through a normal versioned package with uv/pip hash pinning.

on:
release:
types: [published]

permissions:
contents: read
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved

concurrency:
group: publish-pypi-${{ github.event.release.tag_name }}
cancel-in-progress: false

jobs:
verify-release:
name: verify release provenance
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.12"
- name: Require release tag to match project version
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
python - <<'PY'
import os
import tomllib

with open("pyproject.toml", "rb") as stream:
project = tomllib.load(stream)["project"]

expected = f"v{project['version']}"
actual = os.environ["RELEASE_TAG"]
if actual != expected:
raise SystemExit(
f"release tag does not match project version: expected {expected!r}, got {actual!r}"
)
PY

sdist:
name: sdist
needs: verify-release
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.12"
- name: Build sdist
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b
with:
command: sdist
maturin-version: v1.14.1
args: --out dist
Comment thread
seonghobae marked this conversation as resolved.
Resolved
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: dist-sdist
path: dist

wheels:
name: wheels (${{ matrix.target }})
needs: verify-release
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
manylinux: "2014"
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
manylinux: "2014"
- runner: macos-latest
target: universal2-apple-darwin
manylinux: ""
- runner: windows-latest
target: x86_64-pc-windows-msvc
manylinux: ""
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.12"
- name: Build wheel
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b
with:
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux || 'auto' }}
maturin-version: v1.14.1
args: --release --out dist
Comment thread
github-actions[bot] marked this conversation as resolved.
Resolved
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: dist-wheel-${{ matrix.target }}
path: dist

release-assets:
name: attach release assets
needs: [sdist, wheels]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: List built artifacts
run: ls -la dist
- name: Attach release assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$RELEASE_TAG" dist/* --repo "${{ github.repository }}"

publish-pypi:
name: publish to PyPI
needs: [sdist, wheels, release-assets]
Comment thread
seonghobae marked this conversation as resolved.
Resolved
runs-on: ubuntu-latest
timeout-minutes: 15
environment: pypi
permissions:
contents: read
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: List built artifacts
run: ls -la dist
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
with:
password: ${{ secrets.PIPY_TOKEN }}
Comment thread
seonghobae marked this conversation as resolved.
Resolved
attestations: false
6 changes: 6 additions & 0 deletions docs/changelog.d/1207-pypi-release-publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Reproducible PyPI release publishing

## Added

- Added release-tag-bound sdist and wheel publication with a project-version provenance check, pinned Maturin and PyPA publisher revisions, and persisted checkout credentials disabled.
- Isolated GitHub release-asset mutation from PyPI credentials, removed the unpinned runtime Twine installation path, and kept duplicate GitHub release assets and PyPI filenames fail-closed rather than silently replacing an immutable release artifact.
69 changes: 69 additions & 0 deletions tests/test_publish_pypi_workflow_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from pathlib import Path
import re


REPO_ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "publish-pypi.yml"
PYPI_PUBLISH_SHA = "dc37677b2e1c63e2034f94d8a5b11f265b73ba33"


def _workflow_text() -> str:
return WORKFLOW.read_text(encoding="utf-8")


def _job_block(text: str, name: str) -> str:
match = re.search(
rf"(?ms)^ {re.escape(name)}:\n(?P<body>.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)",
text,
)
assert match is not None, f"missing {name!r} job"
return match.group(0)


def test_release_builds_are_bound_to_the_published_version() -> None:
text = _workflow_text()
verify = _job_block(text, "verify-release")
sdist = _job_block(text, "sdist")
wheels = _job_block(text, "wheels")

for job in (verify, sdist, wheels):
assert "ref: ${{ github.event.release.tag_name }}" in job
assert "persist-credentials: false" in job

assert 'RELEASE_TAG: ${{ github.event.release.tag_name }}' in verify
assert 'tomllib.load' in verify or 'tomllib.loads' in verify
assert 'f"v{project[\'version\']}"' in verify
assert text.count("maturin-version: v1.14.1") == 2


def test_release_asset_write_is_isolated_from_pypi_credentials() -> None:
text = _workflow_text()
assets = _job_block(text, "release-assets")
publish = _job_block(text, "publish-pypi")

assert "permissions:\n contents: write" in assets
assert "GH_TOKEN: ${{ github.token }}" in assets
assert "RELEASE_TAG: ${{ github.event.release.tag_name }}" in assets
assert 'gh release upload "$RELEASE_TAG"' in assets
assert "--clobber" not in assets
assert "secrets.PIPY_TOKEN" not in assets

assert "environment: pypi" in publish
assert "permissions:\n contents: read" in publish
assert "gh release upload" not in publish
assert "contents: write" not in publish


def test_pypi_publish_uses_a_pinned_package_owned_uploader() -> None:
text = _workflow_text()
publish = _job_block(text, "publish-pypi")

assert "python -m pip install" not in text
assert "python -m twine upload" not in text
assert "TWINE_USERNAME" not in text
assert f"uses: pypa/gh-action-pypi-publish@{PYPI_PUBLISH_SHA}" in publish
assert "password: ${{ secrets.PIPY_TOKEN }}" in publish
assert "attestations: false" in publish
assert "skip-existing" not in publish
Loading