diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index d47ddb7..475ec9d 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -73,6 +73,7 @@ jobs: version: ${{ steps.metadata.outputs.version }} source_commit: ${{ steps.metadata.outputs.source_commit }} release_tag: ${{ steps.metadata.outputs.release_tag }} + source_ref: ${{ steps.metadata.outputs.source_ref }} source_ref_type: ${{ steps.metadata.outputs.source_ref_type }} steps: - name: Check out source @@ -93,19 +94,23 @@ jobs: run: | version="$(tr -d '\\r\\n' < VERSION)" release_tag="$REQUESTED_RELEASE_TAG" + source_ref="$GITHUB_REF" if [[ -n "$release_tag" ]]; then source_ref_type=tag source_commit="$(git rev-parse --verify "refs/tags/$release_tag^{}")" + source_ref="refs/tags/$release_tag" else source_ref_type="$GITHUB_REF_TYPE" source_commit="$GITHUB_SHA" if [[ "$source_ref_type" == tag ]]; then release_tag="$GITHUB_REF_NAME" + source_ref="refs/tags/$release_tag" fi fi echo "version=$version" >> "$GITHUB_OUTPUT" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "source_ref=$source_ref" >> "$GITHUB_OUTPUT" echo "source_ref_type=$source_ref_type" >> "$GITHUB_OUTPUT" - name: Validate release ref @@ -317,7 +322,7 @@ jobs: publish: name: Publish reviewed distribution - needs: [build, smoke, provenance] + needs: [build, smoke, provenance, attest, verify_attestations] if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -381,9 +386,46 @@ jobs: subject-checksums: dist/SHA256SUMS sbom-path: dist/SBOM.spdx.json + verify_attestations: + name: Verify reviewed release attestations + needs: [build, smoke, provenance, attest] + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + attestations: read + steps: + - name: Download reviewed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist + + - name: Verify artifact provenance and SBOM attestations + env: + SOURCE_COMMIT: ${{ needs.build.outputs.source_commit }} + SOURCE_REF: ${{ needs.build.outputs.source_ref }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for asset in dist/*.whl dist/*.tar.gz; do + gh attestation verify "$asset" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ + --source-digest "$SOURCE_COMMIT" \ + --source-ref "$SOURCE_REF" + gh attestation verify "$asset" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ + --source-digest "$SOURCE_COMMIT" \ + --source-ref "$SOURCE_REF" \ + --predicate-type "https://spdx.dev/Document" + done + verify_pypi: name: Verify published PyPI metadata - needs: [build, publish, attest] + needs: [build, publish, verify_attestations] if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -405,7 +447,7 @@ jobs: release: name: Create GitHub Release - needs: [build, smoke, provenance, publish, attest, verify_pypi] + needs: [build, smoke, provenance, attest, verify_attestations, publish, verify_pypi] if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -442,6 +484,7 @@ jobs: SOURCE_REVISION: ${{ needs.build.outputs.source_commit }} RELEASE_TAG: ${{ needs.build.outputs.release_tag }} SOURCE_COMMIT: ${{ needs.build.outputs.source_commit }} + SOURCE_REF: ${{ needs.build.outputs.source_ref }} run: | set -euo pipefail tag="$RELEASE_TAG" @@ -458,13 +501,13 @@ jobs: gh attestation verify "$asset" \ --repo "$GITHUB_REPOSITORY" \ --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ - --source-digest "$GITHUB_SHA" \ - --source-ref "$GITHUB_REF" + --source-digest "$SOURCE_COMMIT" \ + --source-ref "$SOURCE_REF" gh attestation verify "$asset" \ --repo "$GITHUB_REPOSITORY" \ --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ - --source-digest "$GITHUB_SHA" \ - --source-ref "$GITHUB_REF" \ + --source-digest "$SOURCE_COMMIT" \ + --source-ref "$SOURCE_REF" \ --predicate-type "https://spdx.dev/Document" done diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 38dd089..ead90c1 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import subprocess +import tempfile from pathlib import Path import yaml @@ -18,12 +21,45 @@ def test_package_workflow_can_recover_an_immutable_tag_from_default_branch() -> assert "release_tag:" in workflow assert "ref: ${{ inputs.release_tag || github.ref }}" in workflow assert "source_commit: ${{ steps.metadata.outputs.source_commit }}" in workflow + assert "source_ref: ${{ steps.metadata.outputs.source_ref }}" in workflow assert "SOURCE_COMMIT: ${{ steps.metadata.outputs.source_commit }}" in workflow assert 'git archive "$SOURCE_COMMIT"' in workflow assert "Manual PyPI publication requires the release_tag input." in workflow assert '--source-commit "$SOURCE_COMMIT"' in workflow +def test_package_workflow_resolves_branch_source_ref_for_testpypi_dispatch() -> None: + root = Path(__file__).resolve().parents[1] + workflow = yaml.safe_load((root / ".github/workflows/package.yml").read_text(encoding="utf-8")) + metadata_step = next(step for step in workflow["jobs"]["build"]["steps"] if step.get("id") == "metadata") + + with tempfile.TemporaryDirectory() as directory: + output_path = Path(directory) / "github-output" + environment = os.environ.copy() + environment.update( + { + "GITHUB_REF": "refs/heads/testpypi-rehearsal", + "GITHUB_REF_TYPE": "branch", + "GITHUB_SHA": "a" * 40, + "GITHUB_OUTPUT": str(output_path), + "REQUESTED_RELEASE_TAG": "", + } + ) + subprocess.run( + ["bash", "-euo", "pipefail", "-c", metadata_step["run"]], + cwd=root, + env=environment, + check=True, + capture_output=True, + text=True, + ) + outputs = dict(line.split("=", 1) for line in output_path.read_text(encoding="utf-8").splitlines()) + + assert outputs["source_ref"] == "refs/heads/testpypi-rehearsal" + assert outputs["source_ref_type"] == "branch" + assert outputs["release_tag"] == "" + + def test_package_workflow_uses_a_published_pypi_publisher_image_tag() -> None: workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") @@ -40,8 +76,8 @@ def test_package_workflow_does_not_replace_published_release_assets() -> None: assert "refusing to replace immutable release assets" in verifier assert "gh release download" in workflow assert "gh attestation verify" in workflow - assert '--source-digest "$GITHUB_SHA"' in workflow - assert '--source-ref "$GITHUB_REF"' in workflow + assert '--source-digest "$SOURCE_COMMIT"' in workflow + assert '--source-ref "$SOURCE_REF"' in workflow assert '--source-commit "$source_commit"' in workflow assert "--clobber" not in workflow @@ -63,7 +99,7 @@ def test_package_workflow_gates_tag_publication_on_release_documentation() -> No assert "python scripts/validate_release_docs.py" in workflow assert "scripts/verify_pypi_metadata.py" in workflow assert "name: Verify published PyPI metadata" in workflow - assert "needs: [build, smoke, provenance, publish, attest, verify_pypi]" in workflow + assert "needs: [build, smoke, provenance, attest, verify_attestations, publish, verify_pypi]" in workflow def test_package_workflow_gates_writes_on_release_provenance() -> None: @@ -73,6 +109,26 @@ def test_package_workflow_gates_writes_on_release_provenance() -> None: assert "name: Verify reviewed release provenance" in workflow assert 'git fetch --no-tags --prune origin "refs/heads/main:refs/remotes/origin/main"' in workflow assert "python scripts/validate_release_provenance.py" in workflow - assert jobs["publish"]["needs"] == ["build", "smoke", "provenance"] + assert jobs["publish"]["needs"] == ["build", "smoke", "provenance", "attest", "verify_attestations"] assert jobs["attest"]["needs"] == ["build", "smoke", "provenance"] - assert jobs["release"]["needs"] == ["build", "smoke", "provenance", "publish", "attest", "verify_pypi"] + assert jobs["release"]["needs"] == [ + "build", + "smoke", + "provenance", + "attest", + "verify_attestations", + "publish", + "verify_pypi", + ] + + +def test_package_workflow_verifies_attestations_before_publication() -> None: + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") + jobs = yaml.safe_load(workflow)["jobs"] + + assert jobs["publish"]["needs"] == ["build", "smoke", "provenance", "attest", "verify_attestations"] + assert jobs["verify_attestations"]["needs"] == ["build", "smoke", "provenance", "attest"] + assert '--source-digest "$SOURCE_COMMIT"' in workflow + assert "SOURCE_REF: ${{ needs.build.outputs.source_ref }}" in workflow + assert '--source-ref "$SOURCE_REF"' in workflow + assert '--source-ref "refs/tags/$RELEASE_TAG"' not in workflow