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
- A CLI published to PyPI from GitHub Actions with trusted publishing; publishing to PyPI with trusted publishing covers the PyPI side.
- Optionally, binaries built in CI as in building cross-platform release binaries in CI.
- The GitHub CLI (
gh) anduvxon the machine where you verify.
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 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.
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
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/mytoolbyrelease.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.