diff --git a/.github/scripts/verify-pypi-release.py b/.github/scripts/verify-pypi-release.py index e8396ff..b606e4b 100755 --- a/.github/scripts/verify-pypi-release.py +++ b/.github/scripts/verify-pypi-release.py @@ -24,8 +24,15 @@ def distribution_digests(directory: Path) -> dict[str, str]: distributions = sorted( path for path in directory.iterdir() - if path.name != "SHA256SUMS" and path.is_file() + if path.is_file() and path.name.endswith((".whl", ".tar.gz")) ) + allowed = {path.name for path in distributions} | {"SHA256SUMS"} + allowed.update(f"{path.name}.publish.attestation" for path in distributions) + unexpected = sorted( + path.name for path in directory.iterdir() if path.name not in allowed + ) + if unexpected: + raise ValueError(f"unexpected distribution files: {unexpected}") if not distributions: raise ValueError(f"no distributions found in {directory}") return { diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0791e71..43505cf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -73,6 +73,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.release.outputs.tag }} + # Preserve the trusted main policy at the workspace root. Only the + # retained candidate archives are uploaded; this source is not rebuilt. + path: candidate-source persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/tests/unit/test_pypi_release_verifier.py b/tests/unit/test_pypi_release_verifier.py new file mode 100644 index 0000000..a34e81d --- /dev/null +++ b/tests/unit/test_pypi_release_verifier.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import hashlib +import runpy +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[2] / ".github/scripts/verify-pypi-release.py" + + +def test_publisher_attestations_do_not_change_distribution_digests( + tmp_path: Path, +) -> None: + digest = runpy.run_path(str(SCRIPT))["distribution_digests"] + wheel = "rstreamlabs_rstream-0.7.0-py3-none-any.whl" + sdist = "rstreamlabs_rstream-0.7.0.tar.gz" + for name in (wheel, sdist): + (tmp_path / name).write_bytes(name.encode()) + expected = { + name: hashlib.sha256(name.encode()).hexdigest() for name in (wheel, sdist) + } + assert digest(tmp_path) == expected + for name in (wheel, sdist): + (tmp_path / f"{name}.publish.attestation").write_text("{}") + assert digest(tmp_path) == expected + (tmp_path / wheel).write_bytes(b"changed") + assert digest(tmp_path) != expected + + +@pytest.mark.parametrize("name", ["unknown.txt", "missing.whl.publish.attestation"]) +def test_unexpected_or_orphaned_files_are_rejected(tmp_path: Path, name: str) -> None: + digest = runpy.run_path(str(SCRIPT))["distribution_digests"] + (tmp_path / "package.whl").write_bytes(b"wheel") + (tmp_path / name).write_text("{}") + with pytest.raises(ValueError, match="unexpected distribution files"): + digest(tmp_path)