2024-05-03 00:58:33 +08:00
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import sys
|
|
|
|
from pathlib import Path
|
|
|
|
from typing import NoReturn
|
|
|
|
|
|
|
|
from pypi_attestation_models import AttestationPayload
|
|
|
|
from sigstore.oidc import IdentityError, IdentityToken, detect_credential
|
|
|
|
from sigstore.sign import Signer, SigningContext
|
|
|
|
|
|
|
|
# Be very verbose.
|
2024-05-17 01:43:17 +08:00
|
|
|
sigstore_logger = logging.getLogger('sigstore')
|
2024-05-03 00:58:33 +08:00
|
|
|
sigstore_logger.setLevel(logging.DEBUG)
|
|
|
|
sigstore_logger.addHandler(logging.StreamHandler())
|
|
|
|
|
2024-05-17 01:43:17 +08:00
|
|
|
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY'))
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
# 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 generally indicates a workflow configuration error, such as insufficient
|
|
|
|
permissions. Make sure that your workflow has `id-token: write` configured
|
|
|
|
at the job level, e.g.:
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
permissions:
|
|
|
|
id-token: write
|
|
|
|
```
|
|
|
|
|
|
|
|
Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings.
|
2024-05-17 01:43:17 +08:00
|
|
|
""" # noqa: S105; not a password
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
def die(msg: str) -> NoReturn:
|
2024-05-17 01:43:17 +08:00
|
|
|
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
|
2024-05-03 00:58:33 +08:00
|
|
|
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
|
2024-05-17 01:43:17 +08:00
|
|
|
msg = msg.replace('\n', '%0A')
|
|
|
|
print(f'::error::Attestation generation failure: {msg}', file=sys.stderr)
|
2024-05-03 00:58:33 +08:00
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
def debug(msg: str):
|
2024-05-17 01:43:17 +08:00
|
|
|
print(f'::debug::{msg}', file=sys.stderr)
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
# pylint: disable=redefined-outer-name
|
|
|
|
def attest_dist(dist: 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.
|
2024-05-17 01:43:17 +08:00
|
|
|
attestation_path = Path(f'{dist}.publish.attestation')
|
2024-05-03 00:58:33 +08:00
|
|
|
if attestation_path.is_file():
|
2024-05-17 01:43:17 +08:00
|
|
|
die(f'{dist} already has a publish attestation: {attestation_path}')
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
payload = AttestationPayload.from_dist(dist)
|
|
|
|
attestation = payload.sign(signer)
|
|
|
|
|
2024-05-17 01:43:17 +08:00
|
|
|
attestation_path.write_text(attestation.model_dump_json(), encoding='utf-8')
|
|
|
|
debug(f'saved publish attestation: {dist=} {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)
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
packages_dir = Path(sys.argv[1])
|
|
|
|
|
|
|
|
try:
|
2024-05-17 01:43:17 +08:00
|
|
|
identity = get_identity_token()
|
2024-05-03 00:58:33 +08:00
|
|
|
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.
|
|
|
|
cause = _TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)
|
|
|
|
die(cause)
|
|
|
|
|
|
|
|
# Collect all sdists and wheels.
|
2024-05-17 01:43:17 +08:00
|
|
|
dists = [sdist.absolute() for sdist in packages_dir.glob('*.tar.gz')]
|
|
|
|
dists.extend(whl.absolute() for whl in packages_dir.glob('*.whl'))
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
with SigningContext.production().signer(identity, cache=True) as signer:
|
|
|
|
for dist in dists:
|
|
|
|
# This should never really happen, but some versions of GitHub's
|
|
|
|
# download-artifact will create a subdirectory with the same name
|
|
|
|
# as the artifact being downloaded, e.g. `dist/foo.whl/foo.whl`.
|
|
|
|
if not dist.is_file():
|
2024-05-17 01:43:17 +08:00
|
|
|
die(f'Path looks like a distribution but is not a file: {dist}')
|
2024-05-03 00:58:33 +08:00
|
|
|
|
|
|
|
attest_dist(dist, signer)
|