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
50 changes: 28 additions & 22 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
sha256sums: ${{ steps.digests.outputs.sha256sums }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
Expand Down Expand Up @@ -101,6 +103,22 @@ jobs:
HEAD_SHA="$(git rev-parse HEAD)"
test "$HEAD_SHA" = "$TAG_SHA"
test "$EVENT_SHA" = "$TAG_SHA"
# The digests travel as a job OUTPUT (Actions control plane), not inside
# the artifact, so the publish job can check the artifact against a
# value the artifact store cannot change -- without starting Python on
# the distributions it is about to publish (a wheel may ship a .pth file
# whose `import` lines run at every later interpreter start).
- name: Record distribution digests
id: digests
run: |
set -euo pipefail
(cd dist && sha256sum -- * | LC_ALL=C sort -k2) > "$RUNNER_TEMP/SHA256SUMS"
cat "$RUNNER_TEMP/SHA256SUMS"
{
echo 'sha256sums<<SHA256SUMS_EOF'
cat "$RUNNER_TEMP/SHA256SUMS"
echo 'SHA256SUMS_EOF'
} >> "$GITHUB_OUTPUT"
- name: Upload verified distributions
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
Expand All @@ -124,37 +142,25 @@ jobs:
id-token: write
attestations: write
contents: read
# Sterile publish job (PyPI Trusted Publishing security model): it holds
# the OIDC token, so it runs no repository or distribution code. Source,
# version, tag and commit identity are proven in `build`; here only the
# bytes are matched against the digests `build` recorded.
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Download verified distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: pypi-distributions-${{ github.run_id }}
path: dist/
- name: Re-verify release identity before upload
- name: Match artifact against build digests (exact file set)
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
EVENT_SHA: ${{ github.sha }}
SHA256SUMS: ${{ needs.build.outputs.sha256sums }}
run: |
set -euo pipefail
python -m pip install --force-reinstall --no-deps dist/*.whl
SOURCE_VERSION="$(python -c 'import runpy; print(runpy.run_path("src/liouscope/_version.py")["__version__"])')"
DIST_VERSION="$(python -c 'import importlib.metadata as m; print(m.version("liouscope"))')"
EXPECTED_VERSION="${RELEASE_TAG#v}"
TAG_SHA="$(git rev-parse "${RELEASE_TAG}^{commit}")"
HEAD_SHA="$(git rev-parse HEAD)"
test "$SOURCE_VERSION" = "$DIST_VERSION"
test "$SOURCE_VERSION" = "$EXPECTED_VERSION"
test "$HEAD_SHA" = "$TAG_SHA"
test "$EVENT_SHA" = "$TAG_SHA"
test -n "$SHA256SUMS"
printf '%s\n' "$SHA256SUMS" > "$RUNNER_TEMP/SHA256SUMS"
(cd dist && sha256sum -c --strict -- "$RUNNER_TEMP/SHA256SUMS")
diff <(cd dist && ls -A | LC_ALL=C sort) <(awk '{print $2}' "$RUNNER_TEMP/SHA256SUMS" | LC_ALL=C sort)
- name: Generate build provenance attestation
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
Expand Down
56 changes: 48 additions & 8 deletions tests/test_pypi_workflow_contract.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""Static safety contract for the PyPI trusted-publishing workflow."""

import re
from pathlib import Path

_WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "pypi.yml"
# Any interpreter or package tool that could start code from dist/ (review of
# #186: the substring list alone let `python3 -c`, `pip3`, `uv run` through).
_INTERPRETER_RE = re.compile(
r"(?<![\w.-])(python[0-9.]*|pip[0-9.]*|pipx|uvx?|twine|node|ruby|perl|php)(?![\w.-])"
)


def test_manual_dispatch_is_build_only_without_oidc_permission():
Expand Down Expand Up @@ -49,17 +55,51 @@ def test_publish_job_is_release_only_and_feature_gated():
assert "github.event_name == 'release'" in publish_block
assert "github.event.action == 'published'" in publish_block
assert "PYPI_PUBLISH_ENABLED" in publish_block
assert "ref: ${{ github.event.release.tag_name }}" in publish_block
assert "name: pypi" in publish_block


def test_identity_is_checked_before_artifact_handoff_and_upload():
"""Identity is proven once, in the OIDC-free build job, before the handoff."""
text = _WORKFLOW.read_text(encoding="utf-8")
assert text.count("test \"$SOURCE_VERSION\" = \"$DIST_VERSION\"") == 2
assert text.count("test \"$HEAD_SHA\" = \"$TAG_SHA\"") == 2
assert text.count("test \"$EVENT_SHA\" = \"$TAG_SHA\"") == 2
assert text.index("Verify source, artifact, tag and commit identity") < text.index(
build_block, _ = text.split("\n publish:\n", maxsplit=1)
for check in (
'test "$SOURCE_VERSION" = "$DIST_VERSION"',
'test "$HEAD_SHA" = "$TAG_SHA"',
'test "$EVENT_SHA" = "$TAG_SHA"',
):
assert text.count(check) == 1
assert check in build_block
assert build_block.index(
"Verify source, artifact, tag and commit identity"
) < build_block.index("Record distribution digests")
assert build_block.index("Record distribution digests") < build_block.index(
"Upload verified distributions"
)
assert text.index("Re-verify release identity before upload") < text.index(
"Publish via Trusted Publishing"
)


def test_publish_job_is_sterile():
"""The job holding id-token: write runs no repository or distribution code.

PyPI's Trusted Publishing security model: fetch the build job's artifacts,
publish, nothing else. A wheel can install a ``.pth`` file whose ``import``
lines run at every later interpreter start, so no Python may start after
the distributions are installed in this job -- here, none is installed.
"""
text = _WORKFLOW.read_text(encoding="utf-8")
_, publish_block = text.split("\n publish:\n", maxsplit=1)
for forbidden in (
"actions/checkout",
"actions/setup-python",
"pip install",
"python ",
"runpy",
):
assert forbidden not in publish_block, forbidden
assert not _INTERPRETER_RE.findall(publish_block), _INTERPRETER_RE.findall(publish_block)
assert "needs.build.outputs.sha256sums" in publish_block
assert "sha256sum -c --strict" in publish_block
# Exact file set: an extra file in the artifact must fail the job.
assert "diff <(cd dist && ls -A | LC_ALL=C sort)" in publish_block
match = publish_block.index("Match artifact against build digests")
assert match < publish_block.index("Generate build provenance attestation")
assert match < publish_block.index("Publish via Trusted Publishing")
Loading