From e8a90de377fda83351cea8f4235bd7e6cd9e316c Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:18:17 +0530 Subject: [PATCH 1/4] security: gate publication on verified attestations --- .github/workflows/package.yml | 52 +++++++++++++++++++++++++++++----- tests/test_package_workflow.py | 27 +++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index bd1a44c..b216a9c 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -317,7 +317,7 @@ jobs: publish: name: Publish reviewed distribution - needs: [build, smoke, provenance] + needs: [build, smoke, provenance, 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 +381,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 }} + RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + 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 "refs/tags/$RELEASE_TAG" + gh attestation verify "$asset" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ + --source-digest "$SOURCE_COMMIT" \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --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 @@ -429,7 +466,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 @@ -466,6 +503,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: refs/tags/${{ needs.build.outputs.release_tag }} run: | set -euo pipefail tag="$RELEASE_TAG" @@ -482,13 +520,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 5008550..27632e8 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -54,8 +54,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 @@ -81,6 +81,7 @@ def test_package_workflow_gates_tag_publication_on_release_documentation() -> No assert "PYPI-BYTE-VERIFICATION.json" in workflow assert "if: ${{ always() }}" 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: @@ -90,6 +91,24 @@ 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", "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", "verify_attestations"] + assert jobs["verify_attestations"]["needs"] == ["build", "smoke", "provenance", "attest"] + assert '--source-digest "$SOURCE_COMMIT"' in workflow + assert '--source-ref "refs/tags/$RELEASE_TAG"' in workflow From 2219e34bbc0144b051b0d051610452baac1a8f33 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:39:56 +0530 Subject: [PATCH 2/4] ci: verify attestations against the actual source ref --- .github/workflows/package.yml | 13 +++++++++---- tests/test_package_workflow.py | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index b216a9c..36810c6 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 @@ -400,7 +405,7 @@ jobs: - name: Verify artifact provenance and SBOM attestations env: SOURCE_COMMIT: ${{ needs.build.outputs.source_commit }} - RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + SOURCE_REF: ${{ needs.build.outputs.source_ref }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -409,12 +414,12 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \ --source-digest "$SOURCE_COMMIT" \ - --source-ref "refs/tags/$RELEASE_TAG" + --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 "refs/tags/$RELEASE_TAG" \ + --source-ref "$SOURCE_REF" \ --predicate-type "https://spdx.dev/Document" done @@ -503,7 +508,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: refs/tags/${{ needs.build.outputs.release_tag }} + SOURCE_REF: ${{ needs.build.outputs.source_ref }} run: | set -euo pipefail tag="$RELEASE_TAG" diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 27632e8..702218c 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -19,6 +19,7 @@ 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 @@ -111,4 +112,6 @@ def test_package_workflow_verifies_attestations_before_publication() -> None: assert jobs["publish"]["needs"] == ["build", "smoke", "provenance", "verify_attestations"] assert jobs["verify_attestations"]["needs"] == ["build", "smoke", "provenance", "attest"] assert '--source-digest "$SOURCE_COMMIT"' in workflow - assert '--source-ref "refs/tags/$RELEASE_TAG"' 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 From 349243ff36f65e4128245be2b01d85d54a6a5e5d Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:26:15 +0530 Subject: [PATCH 3/4] test: lock publication and attestation dependencies --- .github/workflows/package.yml | 2 +- tests/test_package_workflow.py | 41 +++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 36810c6..ebbaafc 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -322,7 +322,7 @@ jobs: publish: name: Publish reviewed distribution - needs: [build, smoke, provenance, verify_attestations] + 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 diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 702218c..54b5349 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -1,6 +1,9 @@ from __future__ import annotations +import os import re +import subprocess +import tempfile from pathlib import Path import yaml @@ -26,6 +29,38 @@ def test_package_workflow_can_recover_an_immutable_tag_from_default_branch() -> 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_the_pinned_pypi_publisher_release() -> None: workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") @@ -92,7 +127,7 @@ 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", "verify_attestations"] + assert jobs["publish"]["needs"] == ["build", "smoke", "provenance", "attest", "verify_attestations"] assert jobs["attest"]["needs"] == ["build", "smoke", "provenance"] assert jobs["release"]["needs"] == [ "build", @@ -109,9 +144,9 @@ 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", "verify_attestations"] + 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: ${{ needs.build.outputs.source_ref }}" in workflow assert '--source-ref "$SOURCE_REF"' in workflow assert '--source-ref "refs/tags/$RELEASE_TAG"' not in workflow From 922c5b26ecf4e0b7189f6b204b828d9b3c743b49 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:36:33 +0530 Subject: [PATCH 4/4] test: make package metadata regression portable --- tests/test_package_workflow.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 54b5349..23efd02 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -2,10 +2,12 @@ import os import re +import shutil import subprocess import tempfile from pathlib import Path +import pytest import yaml @@ -34,21 +36,28 @@ def test_package_workflow_resolves_branch_source_ref_for_testpypi_dispatch() -> 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") + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is required to execute the workflow metadata step") + with tempfile.TemporaryDirectory() as directory: - output_path = Path(directory) / "github-output" + sandbox = Path(directory) + (sandbox / "VERSION").write_text((root / "VERSION").read_text(encoding="utf-8"), encoding="utf-8") + output_name = "github-output" + output_path = sandbox / output_name 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), + "GITHUB_OUTPUT": output_name, "REQUESTED_RELEASE_TAG": "", } ) subprocess.run( - ["bash", "-euo", "pipefail", "-c", metadata_step["run"]], - cwd=root, + [bash, "-euo", "pipefail", "-c", metadata_step["run"]], + cwd=sandbox, env=environment, check=True, capture_output=True, @@ -116,7 +125,6 @@ def test_package_workflow_gates_tag_publication_on_release_documentation() -> No assert "--artifacts-dir dist" in workflow assert "PYPI-BYTE-VERIFICATION.json" in workflow assert "if: ${{ always() }}" 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