november 2026 · 5 min

Provenance Is a Diff, Not a Badge

PyPI's new project pages put a provenance label on every release, and most of this week's argument is about the less useful one. "No Provenance" mostly tells you where a project runs its CI. "Loss of Provenance" tells you something changed -- and that's the signal an attacker uploading with a stolen token, from outside the project's workflow, can't fake. Nothing on the install path reads it, so if you want it, you have to pin it yourself.

What Shipped

PyPI finished its project page redesign on November 6. Each release now gets a status built from its PEP 740 attestations: full provenance when every file is attested, none when no file is, incomplete when only some are -- and loss of provenance, when a project that used to publish attested files uploads files without them.

Attestations come from Trusted Publishing, and Trusted Publishing has a short list of providers: GitHub Actions, GitLab CI/CD (gitlab.com only, "Self-managed instances are not supported"), Google Cloud and ActiveState. Codeberg isn't on it.

That's where the discuss.python.org thread went. Ricardo Newbery called it a possible two-tier system and said he might move his Codeberg projects to GitHub. William Woodruff, who helped design Trusted Publishing and attestations, answered that more providers are wanted but limited by reviewer time and by concerns about how Codeberg/Forgejo would store keys. The HN thread had the same fight about GitHub lock-in.

The Badge Measures the Host

Look at who gets "No Provenance". A DEV post read the status of the latest release of the top 100 PyPI packages on November 14: 61 full, 39 none. The none list includes boto3, fastapi, httpx, ruff and setuptools.

Those aren't the risky ones. Absence on a project that never attested says nothing about safety -- it's a fact about tooling. The critics are right about that, and it's why the label reads as a judgment it can't back up.

The Diff Measures an Attack

Loss of provenance is different. It compares a project to its own history, and that's exactly where publisher-account attacks show up.

The proof is a case PyPI wrote up itself. In December 2024 ultralytics shipped malicious releases twice. The second round was uploaded with an API token that was never revoked after the project moved to Trusted Publishing: in PyPI's words, "no corresponding source repository activity or PyPI publish attestations". A project that had attested releases suddenly had releases that weren't. The attack was visible as one fact: this release is not like the last one.

And nothing on the install path reads that fact. In the same thread Woodruff says he sees no foreseeable future in which installers like pip and uv require attestations. PyPI's provenance docs point to a third-party pip plugin for verification; native pip and uv support is proposed, not in development. And by Woodruff's reading of the code, the loss flag clears 14 days after the last attested release. So the one signal that catches a stolen token lives on a web page, for two weeks, for whoever happens to look.

A warning nobody reads at install time is a preference, not a control.

Pin the Publisher

You don't need PyPI's label. You need the same comparison, made on your side, against what you trusted last time. PyPI's Integrity API returns each file's attestation and the Trusted Publisher behind it. Record that per dependency, and fail when it changes:

import json, os, sys, urllib.error, urllib.request

def get(url):
    try:
        with urllib.request.urlopen(url) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise

def publishers(name, version):
    seen = set()
    for f in get(f"https://pypi.org/pypi/{name}/{version}/json")["urls"]:
        prov = get(f"https://pypi.org/integrity/{name}/{version}/{f['filename']}/provenance")
        if not prov:
            seen.add("none")
            continue
        for b in prov["attestation_bundles"]:
            p = b["publisher"]
            seen.add(f"{p['kind']} {p.get('repository')} {p.get('workflow')}")
    return sorted(seen)

pins = json.load(open("publishers.json")) if os.path.exists("publishers.json") else {}
changed = False
for line in open(sys.argv[1]):
    line = line.split("#")[0].strip()
    if "==" not in line:
        continue
    name, version = line.split("==")
    now = publishers(name, version)
    if name in pins and pins[name] != now:
        print(f"{name}=={version}: was {pins[name]}, now {now}")
        changed = True
    else:
        pins[name] = now
json.dump(pins, open("publishers.json", "w"), indent=2, sort_keys=True)
sys.exit(1 if changed else 0)

Run it on your pinned requirements in CI and commit publishers.json. A project that never attested is recorded as none and stays quiet -- boto3 doesn't fail your build. Moving sampleproject from 4.0.0 (attested by pypa/sampleproject's release.yml) back to 3.0.0 prints the change and exits 1. A real change of hands looks the same. Accepting a legitimate one is deleting a line from the file, in a reviewed commit.

This is the same instinct I had designing Preflight: check on the consumer's side, because the publisher is the thing that gets compromised. The difference now is that the publisher's identity is signed and queryable, so you don't have to guess it.

What It Costs

PyPI built the signal and put it on a web page. Supply chain failures are a lack of agency -- and here, agency is a committed publishers.json and a CI step that fails when a publisher changes.

← all posts