-
-
Notifications
You must be signed in to change notification settings - Fork 99
Expose PEP 740 attestations functionality #236
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
Changes from all commits
b526ff8
f267787
1571a0d
27500cf
e9c72dd
3166978
e7bd6ea
5aa7e41
4bc4ced
0e2b9c9
242d7e9
aa69903
6b4d371
16aa3a2
6dbccb5
16b5dc1
251402e
1e91a3b
835d65d
95be6b9
176c905
9bac976
6a808bf
1bb6510
8c640e3
e6556ab
8094cdf
57dba07
af78f7a
bcc935f
66f02b6
28806ba
fed8784
61ffce1
e1b63c3
15d9377
473ca48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import logging | ||
import os | ||
import sys | ||
from pathlib import Path | ||
from typing import NoReturn | ||
|
||
from pypi_attestations import Attestation, Distribution | ||
from sigstore.oidc import IdentityError, IdentityToken, detect_credential | ||
from sigstore.sign import Signer, SigningContext | ||
|
||
# Be very verbose. | ||
sigstore_logger = logging.getLogger('sigstore') | ||
sigstore_logger.setLevel(logging.DEBUG) | ||
sigstore_logger.addHandler(logging.StreamHandler()) | ||
|
||
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY')) | ||
|
||
# The top-level error message that gets rendered. | ||
# This message wraps one of the other templates/messages defined below. | ||
_ERROR_SUMMARY_MESSAGE = """ | ||
Attestation generation failure: | ||
|
||
{message} | ||
|
||
You're seeing this because the action attempted to generated PEP 740 | ||
attestations for its inputs, but failed to do so. | ||
""" | ||
|
||
# Rendered if OIDC identity token retrieval fails for any reason. | ||
_TOKEN_RETRIEVAL_FAILED_MESSAGE = """ | ||
OpenID Connect token retrieval failed: {identity_error} | ||
|
||
This failure occurred after a successful Trusted Publishing Flow, | ||
suggesting a transient error. | ||
""" # noqa: S105; not a password | ||
|
||
|
||
def die(msg: str) -> NoReturn: | ||
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io: | ||
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io) | ||
|
||
# HACK: GitHub Actions' annotations don't work across multiple lines naively; | ||
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work. | ||
# See: https://github.com/actions/toolkit/issues/193 | ||
msg = msg.replace('\n', '%0A') | ||
print(f'::error::Attestation generation failure: {msg}', file=sys.stderr) | ||
sys.exit(1) | ||
|
||
|
||
def debug(msg: str): | ||
print(f'::debug::{msg}', file=sys.stderr) | ||
|
||
|
||
def collect_dists(packages_dir: Path) -> list[Path]: | ||
# Collect all sdists and wheels. | ||
dist_paths = [sdist.resolve() for sdist in packages_dir.glob('*.tar.gz')] | ||
dist_paths.extend(whl.resolve() for whl in packages_dir.glob('*.whl')) | ||
|
||
# Make sure everything that looks like a dist actually is one. | ||
# We do this up-front to prevent partial signing. | ||
if (invalid_dists := [path for path in dist_paths if path.is_file()]): | ||
invalid_dist_list = ', '.join(map(str, invalid_dists)) | ||
die( | ||
'The following paths look like distributions but ' | ||
f'are not actually files: {invalid_dist_list}', | ||
) | ||
|
||
return dist_paths | ||
|
||
|
||
def attest_dist(dist_path: Path, signer: Signer) -> None: | ||
# We are the publishing step, so there should be no pre-existing publish | ||
# attestation. The presence of one indicates user confusion. | ||
attestation_path = Path(f'{dist_path}.publish.attestation') | ||
if attestation_path.exists(): | ||
die(f'{dist_path} already has a publish attestation: {attestation_path}') | ||
|
||
dist = Distribution.from_file(dist_path) | ||
attestation = Attestation.sign(signer, dist) | ||
|
||
attestation_path.write_text(attestation.model_dump_json(), encoding='utf-8') | ||
debug(f'saved publish attestation: {dist_path=} {attestation_path=}') | ||
|
||
|
||
def get_identity_token() -> IdentityToken: | ||
# Will raise `sigstore.oidc.IdentityError` if it fails to get the token | ||
# from the environment or if the token is malformed. | ||
# NOTE: audience is always sigstore. | ||
oidc_token = detect_credential() | ||
return IdentityToken(oidc_token) | ||
|
||
|
||
def main() -> None: | ||
packages_dir = Path(sys.argv[1]) | ||
|
||
try: | ||
identity = get_identity_token() | ||
except IdentityError as identity_error: | ||
# NOTE: We only perform attestations in trusted publishing flows, so we | ||
# don't need to re-check for the "PR from fork" error mode, only | ||
# generic token retrieval errors. We also render a simpler error, | ||
# since permissions can't be to blame at this stage. | ||
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)) | ||
|
||
dist_paths = collect_dists(packages_dir) | ||
|
||
with SigningContext.production().signer(identity, cache=True) as s: | ||
debug(f'attesting to dists: {dist_paths}') | ||
for dist_path in dist_paths: | ||
attest_dist(dist_path, s) | ||
|
||
|
||
if __name__ == '__main__': | ||
main() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,14 @@ | ||
twine | ||
|
||
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing. | ||
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing, | ||
# NOTE: as well as PEP 740 attestations. | ||
id ~= 1.0 | ||
|
||
# NOTE: This is pulled in transitively through `twine`, but we also declare | ||
# NOTE: it explicitly here because `oidc-exchange.py` uses it. | ||
# Ref: https://github.com/di/id | ||
requests | ||
|
||
# NOTE: Used to generate attestations. | ||
pypi-attestations ~= 0.0.11 | ||
sigstore ~= 3.2.0 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,6 +39,7 @@ INPUT_PACKAGES_DIR="$(get-normalized-input 'packages-dir')" | |
INPUT_VERIFY_METADATA="$(get-normalized-input 'verify-metadata')" | ||
INPUT_SKIP_EXISTING="$(get-normalized-input 'skip-existing')" | ||
INPUT_PRINT_HASH="$(get-normalized-input 'print-hash')" | ||
INPUT_ATTESTATIONS="$(get-normalized-input 'attestations')" | ||
|
||
PASSWORD_DEPRECATION_NUDGE="::error title=Password-based uploads disabled::\ | ||
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 \ | |
combinations or API tokens to authenticate with PyPI. Read more: \ | ||
https://docs.pypi.org/trusted-publishers" | ||
|
||
if [[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] ; then | ||
ATTESTATIONS_WITHOUT_TP_WARNING="::warning title=attestations input ignored::\ | ||
The workflow was run with the 'attestations: true' input, but an explicit \ | ||
password was also set, disabling Trusted Publishing. As a result, the \ | ||
attestations input is ignored." | ||
|
||
ATTESTATIONS_WRONG_INDEX_WARNING="::warning title=attestations input ignored::\ | ||
The workflow was run with 'attestations: true' input, but the specified \ | ||
repository URL does not support PEP 740 attestations. As a result, the \ | ||
attestations input is ignored." | ||
|
||
[[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] \ | ||
&& TRUSTED_PUBLISHING=true || TRUSTED_PUBLISHING=false | ||
|
||
if [[ "${INPUT_ATTESTATIONS}" != "false" ]] ; then | ||
# Setting `attestations: true` without Trusted Publishing indicates | ||
# user confusion, since attestations (currently) require it. | ||
if ! "${TRUSTED_PUBLISHING}" ; then | ||
echo "${ATTESTATIONS_WITHOUT_TP_WARNING}" | ||
INPUT_ATTESTATIONS="false" | ||
fi | ||
|
||
# Setting `attestations: true` with an index other than PyPI or TestPyPI | ||
# indicates user confusion, since attestations are not supported on other | ||
# indices presently. | ||
if [[ ! "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]] ; then | ||
echo "${ATTESTATIONS_WRONG_INDEX_WARNING}" | ||
INPUT_ATTESTATIONS="false" | ||
fi | ||
Comment on lines
+78
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this necessary given pypa/twine#1099? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not necessary, since This is similar to how the action pre-validates the dist directory, even though There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's probably fine for this to catch it before twine and give a more tailored error message, I'm mostly just thinking about the long-term pain of maintaining/updating this list of supported indexes in multiple places, but this is fine for now. |
||
fi | ||
|
||
if "${TRUSTED_PUBLISHING}" ; then | ||
# No password supplied by the user implies that we're in the OIDC flow; | ||
# retrieve the OIDC credential and exchange it for a PyPI API token. | ||
echo "::debug::Authenticating to ${INPUT_REPOSITORY_URL} via Trusted Publishing" | ||
|
@@ -130,6 +161,15 @@ if [[ ${INPUT_VERBOSE,,} != "false" ]] ; then | |
TWINE_EXTRA_ARGS="--verbose $TWINE_EXTRA_ARGS" | ||
fi | ||
|
||
if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then | ||
# NOTE: Intentionally placed after `twine check`, to prevent attestation | ||
# NOTE: generation on distributions with invalid metadata. | ||
echo "::notice::Generating and uploading digital attestations" | ||
python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}" | ||
|
||
TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS" | ||
fi | ||
|
||
if [[ ${INPUT_PRINT_HASH,,} != "false" || ${INPUT_VERBOSE,,} != "false" ]] ; then | ||
python /app/print-hash.py ${INPUT_PACKAGES_DIR%%/} | ||
fi | ||
|
Uh oh!
There was an error while loading. Please reload this page.