Skip to content

Commit b526ff8

Browse files
woodruffwfacutuesca
authored andcommitted
requirements: Add initial support for uploading PEP 740 attestations
Signed-off-by: William Woodruff <[email protected]>
1 parent 699cd61 commit b526ff8

File tree

7 files changed

+239
-12
lines changed

7 files changed

+239
-12
lines changed

Dockerfile

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ COPY LICENSE.md .
2727
COPY twine-upload.sh .
2828
COPY print-hash.py .
2929
COPY oidc-exchange.py .
30+
COPY attestations.py .
3031

3132
RUN chmod +x twine-upload.sh
3233
ENTRYPOINT ["/app/twine-upload.sh"]

README.md

+21
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,25 @@ for example. See [Creating & using secrets]. While still secure,
246246
[trusted publishing] is now encouraged over API tokens as a best practice
247247
on supported platforms (like GitHub).
248248

249+
### Generating and uploading attestations (EXPERIMENTAL)
250+
251+
> [!NOTE]
252+
> Support for generating and uploading [PEP 740 attestations] is currently
253+
> experimental and limited only to Trusted Publishing flows using PyPI or TestPyPI.
254+
255+
You can generate signed [PEP 740 attestations] for all the distribution files and
256+
upload them all together by enabling the `attestations` setting:
257+
258+
```yml
259+
with:
260+
attestations: true
261+
```
262+
263+
This will use `sigstore` to create attestation objects for each distribution package,
264+
signing them with the identity provided by the GitHub's OIDC token associated with the
265+
current workflow. This means both the trusted publishing authentication and the
266+
attestations are tied to the same identity.
267+
249268
## License
250269

251270
The Dockerfile and associated scripts and documentation in this project
@@ -287,3 +306,5 @@ https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md
287306
[configured on PyPI]: https://docs.pypi.org/trusted-publishers/adding-a-publisher/
288307

289308
[how to specify username and password]: #specifying-a-different-username
309+
310+
[PEP 740 attestations]: https://peps.python.org/pep-0740/

action.yml

+8
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ inputs:
8080
Use `print-hash` instead.
8181
required: false
8282
default: 'false'
83+
attestations:
84+
description: >-
85+
[EXPERIMENTAL]
86+
Enable experimental support for PEP 740 attestations.
87+
Only works with PyPI and TestPyPI via Trusted Publishing.
88+
required: false
89+
default: 'false'
8390
branding:
8491
color: yellow
8592
icon: upload-cloud
@@ -95,3 +102,4 @@ runs:
95102
- ${{ inputs.skip-existing }}
96103
- ${{ inputs.verbose }}
97104
- ${{ inputs.print-hash }}
105+
- ${{ inputs.attestations }}

attestations.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import logging
2+
import os
3+
import sys
4+
from pathlib import Path
5+
from typing import NoReturn
6+
7+
from pypi_attestation_models import AttestationPayload
8+
from sigstore.oidc import IdentityError, IdentityToken, detect_credential
9+
from sigstore.sign import Signer, SigningContext
10+
11+
# Be very verbose.
12+
sigstore_logger = logging.getLogger("sigstore")
13+
sigstore_logger.setLevel(logging.DEBUG)
14+
sigstore_logger.addHandler(logging.StreamHandler())
15+
16+
_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY"))
17+
18+
# The top-level error message that gets rendered.
19+
# This message wraps one of the other templates/messages defined below.
20+
_ERROR_SUMMARY_MESSAGE = """
21+
Attestation generation failure:
22+
23+
{message}
24+
25+
You're seeing this because the action attempted to generated PEP 740
26+
attestations for its inputs, but failed to do so.
27+
"""
28+
29+
# Rendered if OIDC identity token retrieval fails for any reason.
30+
_TOKEN_RETRIEVAL_FAILED_MESSAGE = """
31+
OpenID Connect token retrieval failed: {identity_error}
32+
33+
This generally indicates a workflow configuration error, such as insufficient
34+
permissions. Make sure that your workflow has `id-token: write` configured
35+
at the job level, e.g.:
36+
37+
```yaml
38+
permissions:
39+
id-token: write
40+
```
41+
42+
Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings.
43+
"""
44+
45+
46+
def die(msg: str) -> NoReturn:
47+
with _GITHUB_STEP_SUMMARY.open("a", encoding="utf-8") as io:
48+
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)
49+
50+
# HACK: GitHub Actions' annotations don't work across multiple lines naively;
51+
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
52+
# See: https://github.com/actions/toolkit/issues/193
53+
msg = msg.replace("\n", "%0A")
54+
print(f"::error::Attestation generation failure: {msg}", file=sys.stderr)
55+
sys.exit(1)
56+
57+
58+
def debug(msg: str):
59+
print(f"::debug::{msg}", file=sys.stderr)
60+
61+
62+
# pylint: disable=redefined-outer-name
63+
def attest_dist(dist: Path, signer: Signer) -> None:
64+
# We are the publishing step, so there should be no pre-existing publish
65+
# attestation. The presence of one indicates user confusion.
66+
attestation_path = Path(f"{dist}.publish.attestation")
67+
if attestation_path.is_file():
68+
die(f"{dist} already has a publish attestation: {attestation_path}")
69+
70+
payload = AttestationPayload.from_dist(dist)
71+
attestation = payload.sign(signer)
72+
73+
attestation_path.write_text(attestation.model_dump_json(), encoding="utf-8")
74+
debug(f"saved publish attestation: {dist=} {attestation_path=}")
75+
76+
77+
packages_dir = Path(sys.argv[1])
78+
79+
try:
80+
# NOTE: audience is always sigstore.
81+
oidc_token = detect_credential()
82+
identity = IdentityToken(oidc_token)
83+
except IdentityError as identity_error:
84+
# NOTE: We only perform attestations in trusted publishing flows, so we
85+
# don't need to re-check for the "PR from fork" error mode, only
86+
# generic token retrieval errors.
87+
cause = _TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)
88+
die(cause)
89+
90+
# Collect all sdists and wheels.
91+
dists = [sdist.absolute() for sdist in packages_dir.glob("*.tar.gz")]
92+
dists.extend(whl.absolute() for whl in packages_dir.glob("*.whl"))
93+
94+
with SigningContext.production().signer(identity, cache=True) as signer:
95+
for dist in dists:
96+
# This should never really happen, but some versions of GitHub's
97+
# download-artifact will create a subdirectory with the same name
98+
# as the artifact being downloaded, e.g. `dist/foo.whl/foo.whl`.
99+
if not dist.is_file():
100+
die(f"Path looks like a distribution but is not a file: {dist}")
101+
102+
attest_dist(dist, signer)

requirements/runtime.in

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
twine
22

3-
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing.
3+
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing,
4+
# as well as PEP 740 attestations.
45
id ~= 1.0
56

67
# NOTE: This is pulled in transitively through `twine`, but we also declare
78
# NOTE: it explicitly here because `oidc-exchange.py` uses it.
89
# Ref: https://github.com/di/id
910
requests
11+
12+
# NOTE: Used to generate attestations.
13+
pypi-attestation-models == 0.0.2
14+
sigstore ~= 3.0.0

requirements/runtime.txt

+64-11
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,41 @@
66
#
77
annotated-types==0.6.0
88
# via pydantic
9+
betterproto==2.0.0b6
10+
# via sigstore-protobuf-specs
911
certifi==2024.2.2
1012
# via requests
1113
cffi==1.16.0
1214
# via cryptography
1315
charset-normalizer==3.3.2
1416
# via requests
1517
cryptography==42.0.7
16-
# via secretstorage
18+
# via
19+
# pyopenssl
20+
# pypi-attestation-models
21+
# sigstore
22+
dnspython==2.6.1
23+
# via email-validator
1724
docutils==0.21.2
1825
# via readme-renderer
26+
email-validator==2.1.1
27+
# via pydantic
28+
grpclib==0.4.7
29+
# via betterproto
30+
h2==4.1.0
31+
# via grpclib
32+
hpack==4.0.0
33+
# via h2
34+
hyperframe==6.0.1
35+
# via h2
1936
id==1.4.0
20-
# via -r runtime.in
37+
# via
38+
# -r runtime.in
39+
# sigstore
2140
idna==3.7
22-
# via requests
41+
# via
42+
# email-validator
43+
# requests
2344
importlib-metadata==7.1.0
2445
# via twine
2546
jaraco-classes==3.4.0
@@ -28,10 +49,6 @@ jaraco-context==5.3.0
2849
# via keyring
2950
jaraco-functools==4.0.1
3051
# via keyring
31-
jeepney==0.8.0
32-
# via
33-
# keyring
34-
# secretstorage
3552
keyring==25.2.1
3653
# via twine
3754
markdown-it-py==3.0.0
@@ -42,36 +59,72 @@ more-itertools==10.2.0
4259
# via
4360
# jaraco-classes
4461
# jaraco-functools
62+
multidict==6.0.5
63+
# via grpclib
4564
nh3==0.2.17
4665
# via readme-renderer
4766
pkginfo==1.10.0
4867
# via twine
68+
platformdirs==4.2.2
69+
# via sigstore
70+
pyasn1==0.6.0
71+
# via sigstore
4972
pycparser==2.22
5073
# via cffi
5174
pydantic==2.7.1
52-
# via id
75+
# via
76+
# id
77+
# pypi-attestation-models
78+
# sigstore
79+
# sigstore-rekor-types
5380
pydantic-core==2.18.2
5481
# via pydantic
5582
pygments==2.18.0
5683
# via
5784
# readme-renderer
5885
# rich
86+
pyjwt==2.8.0
87+
# via sigstore
88+
pyopenssl==24.1.0
89+
# via sigstore
90+
pypi-attestation-models==0.0.2
91+
# via -r runtime.in
92+
python-dateutil==2.9.0.post0
93+
# via betterproto
5994
readme-renderer==43.0
6095
# via twine
6196
requests==2.31.0
6297
# via
6398
# -r runtime.in
6499
# id
65100
# requests-toolbelt
101+
# sigstore
102+
# tuf
66103
# twine
67104
requests-toolbelt==1.0.0
68105
# via twine
69106
rfc3986==2.0.0
70107
# via twine
108+
rfc8785==0.1.2
109+
# via sigstore
71110
rich==13.7.1
72-
# via twine
73-
secretstorage==3.3.3
74-
# via keyring
111+
# via
112+
# sigstore
113+
# twine
114+
securesystemslib==1.0.0
115+
# via tuf
116+
sigstore==3.0.0
117+
# via
118+
# -r runtime.in
119+
# pypi-attestation-models
120+
sigstore-protobuf-specs==0.3.2
121+
# via sigstore
122+
sigstore-rekor-types==0.0.13
123+
# via sigstore
124+
six==1.16.0
125+
# via python-dateutil
126+
tuf==5.0.0
127+
# via sigstore
75128
twine==5.1.0
76129
# via -r runtime.in
77130
typing-extensions==4.11.0

twine-upload.sh

+37
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ INPUT_PACKAGES_DIR="$(get-normalized-input 'packages-dir')"
3939
INPUT_VERIFY_METADATA="$(get-normalized-input 'verify-metadata')"
4040
INPUT_SKIP_EXISTING="$(get-normalized-input 'skip-existing')"
4141
INPUT_PRINT_HASH="$(get-normalized-input 'print-hash')"
42+
INPUT_ATTESTATIONS="$(get-normalized-input 'attestations')"
4243

4344
PASSWORD_DEPRECATION_NUDGE="::error title=Password-based uploads disabled::\
4445
As of 2024, PyPI requires all users to enable Two-Factor \
@@ -53,6 +54,33 @@ environments like GitHub Actions without needing to use username/password \
5354
combinations or API tokens to authenticate with PyPI. Read more: \
5455
https://docs.pypi.org/trusted-publishers"
5556

57+
ATTESTATIONS_WITHOUT_TP_WARNING="::warning title=attestations setting ignored::\
58+
The workflow was run with 'attestations: true', but an explicit password was \
59+
also supplied, disabling Trusted Publishing. As a result, the attestations \
60+
setting is ignored."
61+
62+
ATTESTATIONS_WRONG_INDEX_WARNING="::warning title=attestations setting ignored::\
63+
The workflow was run with 'attestations: true', but the specified repository URL \
64+
does not support PEP 740 attestations. As a result, the attestations setting \
65+
is ignored."
66+
67+
if [[ "${INPUT_ATTESTATIONS}" != "false" ]] ; then
68+
# Setting `attestations: true` and explicitly passing a password indicates
69+
# user confusion, since attestations (currently) require Trusted Publishing.
70+
if [[ -n "${INPUT_PASSWORD}" ]] ; then
71+
echo "${ATTESTATIONS_WITHOUT_TP_WARNING}"
72+
INPUT_ATTESTATIONS="false"
73+
fi
74+
75+
# Setting `attestations: true` with an index other than PyPI or TestPyPI
76+
# indicates user confusion, since attestations are not supported on other
77+
# indices presently.
78+
if [[ ! "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]] ; then
79+
echo "${ATTESTATIONS_WRONG_INDEX_WARNING}"
80+
INPUT_ATTESTATIONS="false"
81+
fi
82+
fi
83+
5684
if [[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] ; then
5785
# No password supplied by the user implies that we're in the OIDC flow;
5886
# retrieve the OIDC credential and exchange it for a PyPI API token.
@@ -130,6 +158,15 @@ if [[ ${INPUT_VERBOSE,,} != "false" ]] ; then
130158
TWINE_EXTRA_ARGS="--verbose $TWINE_EXTRA_ARGS"
131159
fi
132160

161+
if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then
162+
# NOTE: Intentionally placed after `twine check`, to prevent attestation
163+
# generation on distributions with invalid metadata.
164+
echo "::debug::Generating and uploading PEP 740 attestations"
165+
python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}"
166+
167+
TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS"
168+
fi
169+
133170
if [[ ${INPUT_PRINT_HASH,,} != "false" || ${INPUT_VERBOSE,,} != "false" ]] ; then
134171
python /app/print-hash.py ${INPUT_PACKAGES_DIR%%/}
135172
fi

0 commit comments

Comments
 (0)