Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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

Expand Down
66 changes: 61 additions & 5 deletions tests/test_package_workflow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import os
import subprocess
import tempfile
from pathlib import Path

import yaml
Expand All @@ -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")

Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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"]

Comment thread
codeforester marked this conversation as resolved.
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
Loading