Skip to content

Commit 8a08d61

Browse files
authored
Expose PEP 740 attestations functionality
PR #236 This patch adds PEP 740 attestation generation to the workflow: when the Trusted Publishing flow is used, this will generate a publish attestation for each distribution being uploaded. These generated attestations are then fed into `twine`, which newly supports them via `--attestations`. Ref: pypi/warehouse#15871
1 parent fb9fc6a commit 8a08d61

File tree

7 files changed

+274
-8
lines changed

7 files changed

+274
-8
lines changed

Dockerfile

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ COPY LICENSE.md .
2828
COPY twine-upload.sh .
2929
COPY print-hash.py .
3030
COPY oidc-exchange.py .
31+
COPY attestations.py .
3132

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

README.md

+29
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ filter to the job:
9999
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
100100
```
101101

102+
### Generating and uploading attestations
103+
104+
> [!IMPORTANT]
105+
> Support for generating and uploading [digital attestations] is currently
106+
> experimental and limited only to Trusted Publishing flows using PyPI or TestPyPI.
107+
> Support for this feature is not yet stable; the settings and behavior described
108+
> below may change without prior notice.
109+
110+
> [!NOTE]
111+
> Generating and uploading digital attestations currently requires
112+
> authentication with a [trusted publisher].
113+
114+
You can generate signed [digital attestations] for all the distribution files and
115+
upload them all together by enabling the `attestations` setting:
116+
117+
```yml
118+
with:
119+
attestations: true
120+
```
121+
122+
This will use [Sigstore] to create attestation
123+
objects for each distribution package, signing them with the identity provided
124+
by the GitHub's OIDC token associated with the current workflow. This means
125+
both the trusted publishing authentication and the attestations are tied to the
126+
same identity.
102127

103128
## Non-goals
104129

@@ -287,3 +312,7 @@ https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md
287312
[configured on PyPI]: https://docs.pypi.org/trusted-publishers/adding-a-publisher/
288313

289314
[how to specify username and password]: #specifying-a-different-username
315+
316+
[digital attestations]: https://peps.python.org/pep-0740/
317+
[Sigstore]: https://www.sigstore.dev/
318+
[trusted publisher]: #trusted-publishing

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

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import logging
2+
import os
3+
import sys
4+
from pathlib import Path
5+
from typing import NoReturn
6+
7+
from pypi_attestations import Attestation, Distribution
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 failure occurred after a successful Trusted Publishing Flow,
34+
suggesting a transient error.
35+
""" # noqa: S105; not a password
36+
37+
38+
def die(msg: str) -> NoReturn:
39+
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
40+
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)
41+
42+
# HACK: GitHub Actions' annotations don't work across multiple lines naively;
43+
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
44+
# See: https://github.com/actions/toolkit/issues/193
45+
msg = msg.replace('\n', '%0A')
46+
print(f'::error::Attestation generation failure: {msg}', file=sys.stderr)
47+
sys.exit(1)
48+
49+
50+
def debug(msg: str):
51+
print(f'::debug::{msg}', file=sys.stderr)
52+
53+
54+
def collect_dists(packages_dir: Path) -> list[Path]:
55+
# Collect all sdists and wheels.
56+
dist_paths = [sdist.resolve() for sdist in packages_dir.glob('*.tar.gz')]
57+
dist_paths.extend(whl.resolve() for whl in packages_dir.glob('*.whl'))
58+
59+
# Make sure everything that looks like a dist actually is one.
60+
# We do this up-front to prevent partial signing.
61+
if (invalid_dists := [path for path in dist_paths if path.is_file()]):
62+
invalid_dist_list = ', '.join(map(str, invalid_dists))
63+
die(
64+
'The following paths look like distributions but '
65+
f'are not actually files: {invalid_dist_list}',
66+
)
67+
68+
return dist_paths
69+
70+
71+
def attest_dist(dist_path: Path, signer: Signer) -> None:
72+
# We are the publishing step, so there should be no pre-existing publish
73+
# attestation. The presence of one indicates user confusion.
74+
attestation_path = Path(f'{dist_path}.publish.attestation')
75+
if attestation_path.exists():
76+
die(f'{dist_path} already has a publish attestation: {attestation_path}')
77+
78+
dist = Distribution.from_file(dist_path)
79+
attestation = Attestation.sign(signer, dist)
80+
81+
attestation_path.write_text(attestation.model_dump_json(), encoding='utf-8')
82+
debug(f'saved publish attestation: {dist_path=} {attestation_path=}')
83+
84+
85+
def get_identity_token() -> IdentityToken:
86+
# Will raise `sigstore.oidc.IdentityError` if it fails to get the token
87+
# from the environment or if the token is malformed.
88+
# NOTE: audience is always sigstore.
89+
oidc_token = detect_credential()
90+
return IdentityToken(oidc_token)
91+
92+
93+
def main() -> None:
94+
packages_dir = Path(sys.argv[1])
95+
96+
try:
97+
identity = get_identity_token()
98+
except IdentityError as identity_error:
99+
# NOTE: We only perform attestations in trusted publishing flows, so we
100+
# don't need to re-check for the "PR from fork" error mode, only
101+
# generic token retrieval errors. We also render a simpler error,
102+
# since permissions can't be to blame at this stage.
103+
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error))
104+
105+
dist_paths = collect_dists(packages_dir)
106+
107+
with SigningContext.production().signer(identity, cache=True) as s:
108+
debug(f'attesting to dists: {dist_paths}')
109+
for dist_path in dist_paths:
110+
attest_dist(dist_path, s)
111+
112+
113+
if __name__ == '__main__':
114+
main()

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+
# NOTE: 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-attestations ~= 0.0.11
14+
sigstore ~= 3.2.0

requirements/runtime.txt

+75-6
Original file line numberDiff line numberDiff line change
@@ -6,16 +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
13+
cffi==1.16.0
14+
# via cryptography
1115
charset-normalizer==3.3.2
1216
# via requests
17+
cryptography==42.0.7
18+
# via
19+
# pyopenssl
20+
# pypi-attestations
21+
# sigstore
22+
dnspython==2.6.1
23+
# via email-validator
1324
docutils==0.21.2
1425
# 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
1536
id==1.4.0
16-
# via -r runtime.in
37+
# via
38+
# -r runtime.in
39+
# sigstore
1740
idna==3.7
18-
# via requests
41+
# via
42+
# email-validator
43+
# requests
1944
importlib-metadata==7.1.0
2045
# via twine
2146
jaraco-classes==3.4.0
@@ -34,33 +59,77 @@ more-itertools==10.2.0
3459
# via
3560
# jaraco-classes
3661
# jaraco-functools
62+
multidict==6.0.5
63+
# via grpclib
3764
nh3==0.2.17
3865
# via readme-renderer
66+
packaging==24.1
67+
# via pypi-attestations
3968
pkginfo==1.10.0
4069
# via twine
70+
platformdirs==4.2.2
71+
# via sigstore
72+
pyasn1==0.6.0
73+
# via sigstore
74+
pycparser==2.22
75+
# via cffi
4176
pydantic==2.7.1
42-
# via id
77+
# via
78+
# id
79+
# pypi-attestations
80+
# sigstore
81+
# sigstore-rekor-types
4382
pydantic-core==2.18.2
4483
# via pydantic
4584
pygments==2.18.0
4685
# via
4786
# readme-renderer
4887
# rich
88+
pyjwt==2.8.0
89+
# via sigstore
90+
pyopenssl==24.1.0
91+
# via sigstore
92+
pypi-attestations==0.0.11
93+
# via -r runtime.in
94+
python-dateutil==2.9.0.post0
95+
# via betterproto
4996
readme-renderer==43.0
5097
# via twine
51-
requests==2.32.0
98+
requests==2.32.3
5299
# via
53100
# -r runtime.in
54101
# id
55102
# requests-toolbelt
103+
# sigstore
104+
# tuf
56105
# twine
57106
requests-toolbelt==1.0.0
58107
# via twine
59108
rfc3986==2.0.0
60109
# via twine
110+
rfc8785==0.1.2
111+
# via sigstore
61112
rich==13.7.1
62-
# via twine
63-
twine==5.1.0
113+
# via
114+
# sigstore
115+
# twine
116+
securesystemslib==1.0.0
117+
# via tuf
118+
sigstore==3.2.0
119+
# via
120+
# -r runtime.in
121+
# pypi-attestations
122+
sigstore-protobuf-specs==0.3.2
123+
# via
124+
# pypi-attestations
125+
# sigstore
126+
sigstore-rekor-types==0.0.13
127+
# via sigstore
128+
six==1.16.0
129+
# via python-dateutil
130+
tuf==5.0.0
131+
# via sigstore
132+
twine==5.1.1
64133
# via -r runtime.in
65134
typing-extensions==4.11.0
66135
# via

twine-upload.sh

+41-1
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,7 +54,37 @@ 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

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

164+
if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then
165+
# NOTE: Intentionally placed after `twine check`, to prevent attestation
166+
# NOTE: generation on distributions with invalid metadata.
167+
echo "::notice::Generating and uploading digital attestations"
168+
python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}"
169+
170+
TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS"
171+
fi
172+
133173
if [[ ${INPUT_PRINT_HASH,,} != "false" || ${INPUT_VERBOSE,,} != "false" ]] ; then
134174
python /app/print-hash.py ${INPUT_PACKAGES_DIR%%/}
135175
fi

0 commit comments

Comments
 (0)