Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate PEP 740 attestations for PyPI #12981

Merged
merged 13 commits into from
Oct 8, 2024
39 changes: 37 additions & 2 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
environment: release
if: github.repository_owner == 'sphinx-doc'
permissions:
attestations: write # for actions/attest
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v4
Expand All @@ -40,7 +41,9 @@ jobs:

- name: Install build dependencies (pypa/build, twine)
run: |
uv pip install build twine
uv pip install build "twine>=5.1"
# resolution fails without betterproto and protobuf-specs
uv pip install "pypi-attestations~=0.0.12" "sigstore-protobuf-specs==0.3.2" "betterproto==2.0.0b6"

- name: Build distribution
run: python -m build
Expand All @@ -49,6 +52,38 @@ jobs:
run: |
twine check dist/*

- name: Create Sigstore attestations for built distributions
uses: actions/attest@v1
id: attest
with:
subject-path: "dist/*"
predicate-type: "https://docs.pypi.org/attestations/publish/v1"
predicate: "null"
show-summary: "true"

- name: Convert attestations to PEP 740
# workflow_ref example: sphinx-doc/sphinx/.github/workflows/create-release.yml@refs/heads/master
run: >
python utils/convert_attestations.py
"${{ steps.attest.outputs.bundle-path }}"
"https://github.com/${{ github.workflow_ref }}"

- name: Inspect PEP 740 attestations
run: |
python -m pypi_attestations inspect dist/*.publish.attestation

- name: Prepare attestation bundles for uploading
run: |
mkdir -p /tmp/attestation-bundles
cp "${{ steps.attest.outputs.bundle-path }}" /tmp/attestation-bundles/
cp dist/*.publish.attestation /tmp/attestation-bundles/

- name: Upload attestation bundles
uses: actions/upload-artifact@v4
with:
name: attestation-bundles
path: /tmp/attestation-bundles/

- name: Mint PyPI API token
id: mint-token
uses: actions/github-script@v7
Expand Down Expand Up @@ -81,7 +116,7 @@ jobs:
TWINE_USERNAME: "__token__"
TWINE_PASSWORD: "${{ steps.mint-token.outputs.api-token }}"
run: |
twine upload dist/*
twine upload dist/* --attestations

github-release:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ exclude = [
"^tests/test_util/test_util_typing\\.py$",
"^tests/test_util/typing_test_data\\.py$",
# tests/test_writers
"^utils/convert_attestations\\.py$",
]
python_version = "3.10"
strict = true
Expand Down
42 changes: 42 additions & 0 deletions utils/convert_attestations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Convert Sigstore attestations to PEP 740.

See https://github.com/trailofbits/pypi-attestations.
"""

import json
import sys
from base64 import b64decode
from pathlib import Path

from pypi_attestations import Attestation, Distribution
from sigstore.models import Bundle
from sigstore.verify import Verifier
from sigstore.verify.policy import Identity

DIST = Path('dist')
bundle_path = Path(sys.argv[1])
signer_identity = sys.argv[2]

for line in bundle_path.read_bytes().splitlines():
dsse_envelope_payload = json.loads(line)['dsseEnvelope']['payload']
subjects = json.loads(b64decode(dsse_envelope_payload))['subject']
for subject in subjects:
filename = subject['name']
assert (DIST / filename).is_file()

# Convert attestation from Sigstore to PEP 740
print(f'Converting attestation for {filename}')
sigstore_bundle = Bundle.from_json(line)
attestation = Attestation.from_bundle(sigstore_bundle)
attestation_path = DIST / f'{filename}.publish.attestation'
attestation_path.write_text(attestation.model_dump_json())
print(f'Attestation for {filename} written to {attestation_path}')
print()

# Validate attestation
dist = Distribution.from_file(DIST / filename)
attestation = Attestation.model_validate_json(attestation_path.read_bytes())
verifier = Verifier.production()
policy = Identity(identity=signer_identity)
attestation.verify(verifier, policy, dist)
print(f'Verified {attestation_path}')