diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 850509a..329bc1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -573,7 +573,9 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist-crate - path: attested + # Keep the downloaded artifact outside the checkout. `cargo package` + # refuses to run when untracked files dirty the working tree. + path: ${{ runner.temp }}/attested - name: Re-package the crate (must be byte-identical to the attested .crate) run: cargo package -p ordvec --locked - name: Verify byte-identity vs the attested .crate @@ -581,7 +583,7 @@ jobs: VERSION: ${{ needs.guard.outputs.version }} run: | set -euo pipefail - ATTESTED="attested/ordvec-${VERSION}.crate" + ATTESTED="${RUNNER_TEMP}/attested/ordvec-${VERSION}.crate" PACKAGED="target/package/ordvec-${VERSION}.crate" [ -f "$ATTESTED" ] || { echo "::error::attested .crate not found at $ATTESTED"; exit 1; } [ -f "$PACKAGED" ] || { echo "::error::packaged .crate not found at $PACKAGED"; exit 1; } @@ -619,7 +621,7 @@ jobs: VERSION: ${{ needs.guard.outputs.version }} run: | set -euo pipefail - ATTESTED="attested/ordvec-${VERSION}.crate" + ATTESTED="${RUNNER_TEMP}/attested/ordvec-${VERSION}.crate" [ -f "$ATTESTED" ] || { echo "::error::attested .crate missing at $ATTESTED"; exit 1; } A_SHA=$(sha256sum "$ATTESTED" | cut -d' ' -f1) # crates.io's stable download endpoint (follows redirect to the CDN). @@ -672,6 +674,10 @@ jobs: uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: packages-dir: dist + # Makes release recovery idempotent if PyPI already accepted this + # version but another registry publish failed. The next step still + # fails closed unless PyPI-served hashes equal the staged dist files. + skip-existing: true - name: Post-publish PyPI hashes match staged dist env: VERSION: ${{ needs.guard.outputs.version }} diff --git a/tests/release_publish_invariants.py b/tests/release_publish_invariants.py index 53994d5..3096edc 100644 --- a/tests/release_publish_invariants.py +++ b/tests/release_publish_invariants.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Structural release publish invariants for the PyPI upload job.""" +"""Structural release publish invariants for registry upload jobs.""" from __future__ import annotations @@ -122,6 +122,11 @@ def check_publish_pypi(workflow: dict[str, Any], path: str) -> None: ) if norm_path(publish_with.get("packages-dir")) != "dist": fail(f"{path}: PyPI publish step must upload packages-dir: dist") + if not boolish_true(publish_with.get("skip-existing")): + fail( + f"{path}: PyPI publish step must set skip-existing: true so a recovery " + "rerun is idempotent after PyPI has already accepted the version" + ) wheels: list[int] = [] sdists: list[int] = [] @@ -163,8 +168,61 @@ def check_publish_pypi(workflow: dict[str, Any], path: str) -> None: fail(f"{path}: publish-pypi must download exactly one sdist artifact into dist") +def check_publish_crate(workflow: dict[str, Any], path: str) -> None: + jobs = mapping(workflow.get("jobs"), f"{path}: jobs") + job = mapping(jobs.get("publish-crate"), f"{path}: jobs.publish-crate") + steps = sequence(job.get("steps"), f"{path}: jobs.publish-crate.steps") + + crate_downloads: list[tuple[int, dict[str, Any], dict[str, Any]]] = [] + + for index, raw_step in enumerate(steps): + step = mapping(raw_step, f"{path}: jobs.publish-crate.steps[{index}]") + if action_name(step) != "actions/download-artifact": + continue + with_block = step.get("with", {}) + with_map = mapping(with_block, f"{path}: {step_label(index, step)} with") + if with_map.get("name") == "dist-crate": + crate_downloads.append((index, step, with_map)) + + if len(crate_downloads) != 1: + fail(f"{path}: publish-crate must download exactly one dist-crate artifact") + + index, step, with_map = crate_downloads[0] + label = step_label(index, step) + artifact_path = norm_path(with_map.get("path")) + if artifact_path != "${{ runner.temp }}/attested": + fail( + f"{path}: {label} downloads dist-crate to {artifact_path or 'the default path'!r}; " + "it must use ${{ runner.temp }}/attested so cargo package sees a clean checkout" + ) + + verify_step_names = { + "Verify byte-identity vs the attested .crate", + "Post-publish byte-identity (download from crates.io == attested)", + } + verify_steps: list[dict[str, Any]] = [] + for index, raw_step in enumerate(steps): + step = mapping(raw_step, f"{path}: jobs.publish-crate.steps[{index}]") + if step.get("name") in verify_step_names: + verify_steps.append(step) + if len(verify_steps) != 2: + fail(f"{path}: publish-crate must have both attested .crate verification steps") + + for step in verify_steps: + name = step.get("name") + run = step.get("run") + if not isinstance(run, str): + fail(f"{path}: publish-crate step {name!r} must be a run step") + if "${RUNNER_TEMP}/attested/ordvec-${VERSION}.crate" not in run: + fail( + f"{path}: publish-crate step {name!r} must read the attested .crate " + "from ${RUNNER_TEMP}/attested" + ) + + def main() -> None: workflow = load_workflow(WORKFLOW_PATH) + check_publish_crate(workflow, WORKFLOW_PATH) check_publish_pypi(workflow, WORKFLOW_PATH)