diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6284ec..bdebe9d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: with: enable-cache: false - id: wheels - uses: LedFx/release-ci/actions/plan@c4a56af516e431cbdb18519889fd0ffa7166f217 # v0.3.0 + uses: LedFx/release-ci/actions/plan@442e00705d4e5edd589021851e756761363f9d84 # v0.3.1 lint: name: Lint (prek) @@ -240,7 +240,7 @@ jobs: id: prepare env: GH_TOKEN: ${{ steps.release-token.outputs.token }} - uses: LedFx/release-ci/actions/release@c4a56af516e431cbdb18519889fd0ffa7166f217 # v0.3.0 + uses: LedFx/release-ci/actions/release@442e00705d4e5edd589021851e756761363f9d84 # v0.3.1 with: phase: prepare project: release-tools @@ -259,22 +259,29 @@ jobs: id: upload env: GH_TOKEN: ${{ steps.release-token.outputs.token }} - uses: LedFx/release-ci/actions/release@c4a56af516e431cbdb18519889fd0ffa7166f217 # v0.3.0 + uses: LedFx/release-ci/actions/release@442e00705d4e5edd589021851e756761363f9d84 # v0.3.1 with: phase: check-upload project: release-tools dist: dist snapshot: ${{ runner.temp }}/release-snapshot.json wheel-plan: ${{ needs.plan.outputs.wheel-plan }} + - name: Stage verified distributions for PyPI + if: steps.upload.outputs.pypi_upload == 'true' + working-directory: ${{ github.workspace }} + run: | + mkdir pypi-dist + cp -- dist/* pypi-dist/ - name: Publish matching missing Python distributions if: steps.upload.outputs.pypi_upload == 'true' uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: + packages-dir: pypi-dist/ skip-existing: true # Both preflights require matching remote SHA-256. - name: Verify provenance and finalize existing GitHub draft env: GH_TOKEN: ${{ steps.release-token.outputs.token }} - uses: LedFx/release-ci/actions/release@c4a56af516e431cbdb18519889fd0ffa7166f217 # v0.3.0 + uses: LedFx/release-ci/actions/release@442e00705d4e5edd589021851e756761363f9d84 # v0.3.1 with: phase: finalize project: release-tools diff --git a/ci/test_shared_release.py b/ci/test_shared_release.py index 3e50c5e..24c4726 100644 --- a/ci/test_shared_release.py +++ b/ci/test_shared_release.py @@ -1,8 +1,12 @@ """Consumer authority remains explicit around the pinned shared transaction.""" -import tomllib +import os import re +import subprocess +import textwrap +import tomllib from pathlib import Path +from tempfile import TemporaryDirectory ROOT = Path(__file__).resolve().parents[1] @@ -27,7 +31,7 @@ def test_release_workflow_preserves_identity_gates_and_same_run_artifacts() -> N assert "run-id:" not in job assert workflow.count("id-token: write") == 1 pins = re.findall( - r"uses: LedFx/release-ci/actions/release@([0-9a-f]{40}) # (v[0-9]+\.[0-9]+\.[0-9]+)\s*$", + r"uses: LedFx/release-ci/actions/release@([0-9a-f]{40})(?:[ \t]+#.*)?[ \t]*$", job, re.MULTILINE, ) @@ -36,7 +40,7 @@ def test_release_workflow_preserves_identity_gates_and_same_run_artifacts() -> N assert job.count("project: release-tools") == 3 assert job.count("wheel-plan: ${{ needs.plan.outputs.wheel-plan }}") == 3 planning = re.findall( - r"uses: LedFx/release-ci/actions/plan@([0-9a-f]{40}) # (v[0-9]+\.[0-9]+\.[0-9]+)\s*$", + r"uses: LedFx/release-ci/actions/plan@([0-9a-f]{40})(?:[ \t]+#.*)?[ \t]*$", workflow, re.MULTILINE, ) @@ -44,8 +48,13 @@ def test_release_workflow_preserves_identity_gates_and_same_run_artifacts() -> N assert "sparse-checkout-cone-mode: false" in job assert "pyproject.toml" in job assert "policy:" not in workflow - assert "uv run --frozen --only-group wheel-build python -m cibuildwheel ." in workflow - assert '--config-file pyproject.toml --platform "$PLATFORM" --archs "$ARCH"' in workflow + assert ( + "uv run --frozen --only-group wheel-build python -m cibuildwheel ." in workflow + ) + assert ( + '--config-file pyproject.toml --platform "$PLATFORM" --archs "$ARCH"' + in workflow + ) assert "uses: pypa/cibuildwheel@" not in workflow assert ( job.index("phase: prepare") @@ -65,7 +74,9 @@ def test_pyproject_keeps_native_portable_matrix() -> None: assert all({"runner", "platform", "arch"} <= set(row) for row in rows) dependencies = config["dependency-groups"]["wheel-build"] assert len(dependencies) == 1 - assert re.fullmatch(r"cibuildwheel(?:\[uv\])?==[0-9]+\.[0-9]+\.[0-9]+", dependencies[0]) + assert re.fullmatch( + r"cibuildwheel(?:\[uv\])?==[0-9]+\.[0-9]+\.[0-9]+", dependencies[0] + ) assert config["project"]["name"] == "pyfastnoiselite-ledfx" assert set(config["tool"]["release-ci"]) == {"targets"} assert not (ROOT / ".github/release-policy.json").exists() @@ -76,7 +87,67 @@ def test_pyproject_keeps_native_portable_matrix() -> None: assert any(row["arch"] == "armv7l" for row in rows) +def test_upload_sidecars_leave_frozen_inputs_unchanged(tmp_path: Path) -> None: + workflow = (ROOT / ".github/workflows/build.yml").read_text() + match = re.search( + r"(?m)^ - name: Stage verified distributions for PyPI\n" + r"(?:(?:^ .*\n)|(?:^\n))*?^ run: \|\n" + r"((?:^ .*\n|^\n)+)", + workflow, + ) + assert match is not None, "The uploader needs a separate verified input copy" + stage = workflow.split(" - name: Stage verified distributions for PyPI\n", 1)[ + 1 + ].split("\n - ", 1)[0] + assert "if: steps.upload.outputs.pypi_upload == 'true'" in stage + assert "working-directory: ${{ github.workspace }}" in stage + assert workflow.index("phase: check-upload") < workflow.index( + "Stage verified distributions for PyPI" + ) + uploader = workflow.split("uses: pypa/gh-action-pypi-publish@", 1)[1].split( + "\n - ", 1 + )[0] + assert "packages-dir: pypi-dist/" in uploader + script = textwrap.dedent(match.group(1)) + original = tmp_path / "dist" + original.mkdir() + frozen = { + "example-1.0-py3-none-any.whl": b"tested wheel", + "example-1.0.tar.gz": b"tested sdist", + } + for name, data in frozen.items(): + (original / name).write_bytes(data) + result = subprocess.run( + ["bash", "-euo", "pipefail", "-c", script], + cwd=tmp_path, + env={**os.environ, "GITHUB_WORKSPACE": str(tmp_path)}, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + staging = tmp_path / "pypi-dist" + assert {p.name: p.read_bytes() for p in staging.iterdir()} == frozen + for name in frozen: + (staging / (name + ".publish.attestation")).write_bytes( + b"generated PyPI sidecar" + ) + assert {p.name: p.read_bytes() for p in original.iterdir()} == frozen + before_retry = {p.name: p.read_bytes() for p in staging.iterdir()} + retry = subprocess.run( + ["bash", "-euo", "pipefail", "-c", script], + cwd=tmp_path, + env={**os.environ, "GITHUB_WORKSPACE": str(tmp_path)}, + capture_output=True, + check=False, + ) + assert retry.returncode != 0 + assert {p.name: p.read_bytes() for p in staging.iterdir()} == before_retry + assert {p.name: p.read_bytes() for p in original.iterdir()} == frozen + + if __name__ == "__main__": test_release_workflow_preserves_identity_gates_and_same_run_artifacts() test_pyproject_keeps_native_portable_matrix() - print("2 shared publication contracts passed") + with TemporaryDirectory() as directory: + test_upload_sidecars_leave_frozen_inputs_unchanged(Path(directory)) + print("3 shared publication contracts passed")