Project Setup

Publishing Attestations and Verifying Python CLI Releases

Publish a Python CLI with PEP 740 attestations via trusted publishing, attest binaries with GitHub, ship checksums, and give users verification commands.

Updated

When a user installs mytool 1.6.0, they are trusting that the file they downloaded is the one your release workflow built from the tagged commit — not something uploaded with a stolen token, and not something altered on the way. Until recently, a Python project had no practical way to prove that. Now there are two complementary mechanisms. PEP 740 attestations let PyPI store, next to each uploaded file, a signed statement — recorded in Sigstore's public transparency log — that this exact file was published by a specific workflow in a specific repository. GitHub artifact attestations do the same for files you host yourself, such as standalone binaries on a release page. This guide sets up both, adds plain checksums for users without the tooling, and shows the commands users and security teams run to verify a release. It belongs to the supply-chain security topic.

Prerequisites

What an attestation proves

An attestation binds three things together: the digest of a file, the identity of the CI workflow that produced it (repository, workflow file, ref), and a signature from a short-lived certificate issued to that workflow by Sigstore. The signing event is written to a public, append-only log, so it cannot be quietly removed or backdated.

What an attestation binds together An attestation ties a file digest to the identity of the workflow that produced it, signed with a short-lived certificate and recorded in a public log. What an attestation binds together PEP 740 attestation stored on PyPI Digest sha256 of the exact file Identity repo + workflow + ref Signature short-lived Sigstore cert Logged in a public, append-only log Proves origin, not safety Created at upload time only Repository protections keep the origin honest; attestations carry that honesty to users.

What it does not prove is that the code is safe. An attestation says "this file came from that workflow run in that repository" — if an attacker can push to the repository and trigger the release, the attestation will faithfully describe their release. Attestations close the gap between your repository and your users; repository protections (reviews, protected tags and environments) keep the repository itself honest.

The recipe

Python packages: attestations come with trusted publishing

The PyPA publishing action generates and uploads PEP 740 attestations by default when it uses trusted publishing. A release workflow with separate build and publish jobs is all that is needed:

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - run: uv build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi                 # protected: requires approval
    permissions:
      id-token: write                 # trusted publishing + attestation signing
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

The build job has no special permissions, so nothing in the build can mint an identity token. The publish job has only id-token: write and runs in a protected environment, so a pushed tag still waits for a maintainer's approval. (Pin each uses: to a full commit SHA in practice.) After the upload, each file's page on PyPI shows its provenance — the repository and workflow that published it.

Publishing with attestations Sequence of a tagged release: the build job uploads artifacts, the publish job obtains an identity token, signs attestations with Sigstore and uploads files and attestations to PyPI. Publishing with attestations build job publish job Sigstore PyPI dist/ artifact OIDC token → certificate signing cert + log entry files + attestations provenance stored The PyPA publish action does the signing; no long-lived key exists anywhere.

Binaries and other files: GitHub artifact attestations

Files that do not go to PyPI — PyInstaller binaries, zipapps, SBOMs — can be attested by GitHub itself and verified with gh:

  binaries:
    needs: build
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    permissions:
      id-token: write
      attestations: write
      contents: write
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build-binary.sh          # produces dist-bin/mytool-<os>
        shell: bash
      - uses: actions/attest-build-provenance@v2
        with:
          subject-path: dist-bin/*
      - run: gh release upload "$GITHUB_REF_NAME" dist-bin/* --clobber
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}

Checksums for everyone

Not every user will install gh or uvx. A SHA256SUMS file attached to the release lets anyone confirm a download with standard tools — and it is itself attested, so checking it links back to the workflow:

cd dist-bin && sha256sum mytool-* > SHA256SUMS

For Windows users and for scripted installers, a tiny verifier in Python avoids depending on platform tools:

# src/mytool_release/verify.py
from __future__ import annotations

import hashlib
import sys
from pathlib import Path


def parse_sums(text: str) -> dict[str, str]:
    sums = {}
    for line in text.splitlines():
        if not line.strip():
            continue
        digest, name = line.split(maxsplit=1)
        sums[name.lstrip("*")] = digest.lower()       # '*' marks binary mode
    return sums


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def verify(file: Path, sums_file: Path) -> bool:
    expected = parse_sums(sums_file.read_text()).get(file.name)
    return expected is not None and expected == sha256(file)


if __name__ == "__main__":
    ok = verify(Path(sys.argv[1]), Path(sys.argv[2]))
    print("OK" if ok else "MISMATCH or not listed", file=sys.stderr)
    raise SystemExit(0 if ok else 1)

How users verify

Publish these commands in your installation docs or SECURITY.md. For a wheel or sdist on PyPI:

uvx pypi-attestations verify pypi \
  --repository https://github.com/acme/mytool \
  pypi:mytool-1.6.0-py3-none-any.whl

The tool downloads the file and its provenance from PyPI, checks the Sigstore signature and the transparency log entry, and confirms that the publisher identity is the given repository. Pointing it at a file you already downloaded (a local path instead of pypi:…) verifies that exact file.

For a binary from the release page:

gh attestation verify mytool-linux-x86_64 --repo acme/mytool
sha256sum --check --ignore-missing SHA256SUMS
Verifying a release Terminal session verifying a wheel from PyPI with pypi-attestations and a binary from a GitHub release with gh attestation verify and checksums. Verifying a release bash $ uvx pypi-attestations verify pypi --repository https://github.com/acme/mytool \ > pypi:mytool-1.6.0-py3-none-any.whl OK: mytool-1.6.0-py3-none-any.whl $ gh attestation verify mytool-linux-x86_64 --repo acme/mytool ✓ Verification succeeded! $ sha256sum --check --ignore-missing SHA256SUMS mytool-linux-x86_64: OK Verification is only meaningful against the repository you expect — document it.

Checking provenance from a script

PyPI exposes provenance through its integrity API, one URL per file: https://pypi.org/integrity/<project>/<version>/<filename>/provenance. The response lists attestation bundles together with the publisher that produced them, which makes a quick automated policy check easy — for example, refusing to mirror a release into an internal index unless it was published by the expected workflow:

import httpx

EXPECTED = {"kind": "GitHub", "repository": "acme/mytool", "workflow": "release.yml"}


def published_by_expected_workflow(project: str, version: str, filename: str) -> bool:
    url = f"https://pypi.org/integrity/{project}/{version}/{filename}/provenance"
    response = httpx.get(url, timeout=10)
    if response.status_code != 200:
        return False                                  # no provenance at all
    bundles = response.json().get("attestation_bundles", [])
    return any(all(b["publisher"].get(k) == v for k, v in EXPECTED.items()) for b in bundles)

This only reads the claimed publisher; it does not check signatures. Use it as a fast pre-filter and keep pypi-attestations verify as the authoritative check, which validates the signature and transparency-log entry as well.

UX considerations

  • Document the expected identity. Verification is only meaningful against the right repository. State it explicitly: "releases are published from github.com/acme/mytool by release.yml".
  • Name files predictably. mytool-<version>-<os>-<arch> makes verification commands copy-pasteable and scripts simple.
  • Attest the SBOM and checksums too. If they are attested, verifying them proves the inventory and the hash list came from the same release.
  • Keep the publish job boring. No build steps, no scripts, no third-party actions beyond download and publish. Every extra step runs with the identity that signs.
  • Explain what verification means. Users should understand that a passing check proves origin, not safety; link to your vulnerability policy and audit process.

Testing the behaviour

Two kinds of tests matter. The checksum verifier is plain Python and gets unit tests:

# tests/test_verify.py
from mytool_release.verify import parse_sums, sha256, verify


def test_parse_handles_binary_marker_and_blank_lines():
    text = "ABC123  mytool-linux\n\nDEF456 *mytool-windows.exe\n"
    assert parse_sums(text) == {"mytool-linux": "abc123", "mytool-windows.exe": "def456"}


def test_verify_accepts_matching_file(tmp_path):
    f = tmp_path / "mytool-linux"
    f.write_bytes(b"binary")
    (tmp_path / "SHA256SUMS").write_text(f"{sha256(f)}  mytool-linux\n")
    assert verify(f, tmp_path / "SHA256SUMS")


def test_verify_rejects_tampered_or_unlisted(tmp_path):
    f = tmp_path / "mytool-linux"
    f.write_bytes(b"binary")
    sums = tmp_path / "SHA256SUMS"
    sums.write_text(f"{sha256(f)}  mytool-linux\n")
    f.write_bytes(b"binary-but-changed")
    assert not verify(f, sums)
    other = tmp_path / "mytool-mac"
    other.write_bytes(b"x")
    assert not verify(other, sums)

The second is an end-to-end check that runs after each release: a small workflow triggered on release: published downloads the new wheel from PyPI and runs pypi-attestations verify pypi against your repository URL, and gh attestation verify against each binary. If a release ever goes out without provenance — a manual upload, a misconfigured workflow — that job fails within minutes, while the release is still fresh enough to investigate.

Conclusion

Attestations give users cryptographic evidence that the file they installed came from your release workflow. On PyPI they are nearly free: publish with trusted publishing through the PyPA action from an isolated, protected job. For self-hosted binaries, add GitHub artifact attestations and a checksum file. Then do the part that makes it count — tell users exactly how to verify, name the expected repository, and run the same verification yourself after every release.

Frequently asked questions

Do users need to verify for attestations to be useful?

Verification is what turns provenance into protection, but attestations help even before most users check: security teams and tooling increasingly verify automatically, and the public log makes a malicious release harder to hide. The post-release check in your own CI is the minimum.

Can I add attestations to old releases?

No. An attestation must be created by the workflow that publishes the file, at upload time. Start with the next release and say so in the changelog.

What about GPG signatures?

PyPI no longer displays or encourages PGP signatures, and few users ever verified them because key distribution was the hard part. Sigstore-based attestations solve that by tying signatures to CI identities rather than long-lived keys.

Does this work with GitLab or other CI systems?

PyPI's trusted publishing supports GitLab CI/CD, Google Cloud and ActiveState as well as GitHub Actions, and pypi-attestations verify pypi --repository accepts GitLab repository URLs. GitHub artifact attestations are specific to GitHub; on other platforms, sign binaries with Sigstore's cosign or publish checksums.