PyPI Operations
Publish Python packages to PyPI on the 2026 best-practice path: OIDC Trusted
Publishing with signed PEP 740 attestations, no long-lived token to leak. This
skill owns the publish layer (the registry handshake, the first-publish
gotchas, the recovery playbook). General GitHub Actions syntax is ci-cd-ops;
the install-side worm defense is supply-chain-defense; gh/release-page
mechanics are github-ops.
Where this fits — the release pipeline
A release spans several skills; pypi-ops owns the registry step. Chain them:
- Vet dependencies before cutting a release —
supply-chain-defense(cooldown + behavioural scan). The build runs dependency code before it touches your publish credential, so a poisoned build dep can steal the token. - Preflight —
scripts/publish-preflight.sh --build .(this skill). - Bump → tag → push —
git-ops(its push-gate scans for secrets / forbidden files before the tag goes up). - CI publishes via OIDC — this skill's
assets/publish.yml; you approve at thepypienvironment gate. - Release page (optional, GitHub) —
github-ops, human-reviewed notes.
The one decision: OIDC vs API token
Default to OIDC Trusted Publishing. Reach for a token only when OIDC is impossible (publishing from a non-supported CI, or a one-off local push).
| | Trusted Publishing (OIDC) ← default | API token |
|---|---|---|
| Secret stored | None — short-lived OIDC token minted per run | Long-lived pypi-… token in a secret |
| Leak/phish blast radius | None to steal | Full publish rights until rotated |
| Provenance | PEP 740 attestations (signed, verifiable) | None by default |
| Setup | One-time publisher registration on PyPI | Generate token + store secret |
| Best for | All CI/CD releases | Legacy CI, emergency local upload |
If a repo currently uses a token, migrating to OIDC is strictly an upgrade — see references/trusted-publishing.md.
The #1 gotcha: first publish needs a pending publisher
A Trusted Publisher is normally configured under the project's settings on PyPI — but on the first ever publish the project doesn't exist yet, so there's nothing to configure it under. The fix is a pending publisher, registered at the account level before the first upload.
Symptom (the exact failure this skill exists to kill):
Trusted publishing exchange failure:
* invalid-publisher: valid token, but no corresponding publisher
(Publisher with matching claims was not found)
The OIDC token was valid; PyPI just has no publisher matching the claims. Fix:
PyPI → https://pypi.org/manage/account/publishing/ → Add a pending publisher
| Field | Value | |---|---| | PyPI Project Name | the dist name from
pyproject.toml[project].name| | Owner | GitHub org/user | | Repository name | repo name | | Workflow name | the filename, e.g.publish.yml(not thename:) | | Environment name | must equal the job'senvironment:(e.g.pypi) |
All four claims must match the run's OIDC token exactly. After the first
successful publish, the pending publisher auto-converts to a normal project
publisher — no further action. Run diagnose-publish.sh on a failed run to read
the exact claims it presented and compare them field-by-field.
This is the most common silent-failure mode: a package's
publish.ymllooks perfect and every release builds green, yet nothing ever reaches PyPI because the publisher was never registered. Check it first.
Recommended workflow (copy assets/publish.yml)
The shipped template is hardened to the patterns below — adapt the marked points
and drop it in .github/workflows/. Non-negotiables it encodes:
on: push: tags: ['v*']— release on a version tag, never on every push.- OIDC, no token: the
publishjob haspermissions: id-token: writeandpypa/gh-action-pypi-publishwithattestations: true. Nopassword:/token. environment: pypion the publish job → a human approves every release (defense-in-depth: even a compromised repo can't auto-ship).- Build/publish split: a
buildjob (no elevated perms) produces + uploads thedistartifact;publishdownloads it. Least privilege per job. uv sync --locked+pip-audit: the release is built against the committed, hash-verified lockfile and blocked if a dep has a known CVE.twine check/ metadata validation before upload.- SHA-pinned actions with a trailing
# vXcomment (mutable tags get hijacked — seecheck-action-pins.py). - Verify-on-PyPI tail job — polls the JSON API so a silent publish failure (accepted-but-not-live, CDN lag) surfaces loudly instead of looking fine.
Supply-chain hardening — the publisher side
Stealing your publish credential lets an attacker ship malware to everyone who installs you — so the publish path is the surface the 2026 worm campaign (Mini Shai-Hulud) targets, minting PyPI/npm tokens from stale OIDC trust and orphaned workflows. The template above isn't just convention; each choice is a defense:
| Control | Defends against |
|---|---|
| OIDC, no stored token | Credential theft/phishing — there is no long-lived secret to steal |
| PEP 740 attestations | Tampered artifacts — provenance is signed and verifiable |
| environment: pypi + reviewers | A compromised repo/CI auto-shipping — a human still gates the release |
| pip-audit gate | A knowingly-vulnerable dependency reaching the release build |
| SHA-pinned actions (check-action-pins.py) | Action-tag hijacks (tj-actions, 2025) repointing @vN to a malicious commit |
| permissions: {} + per-job least privilege | A poisoned build step escalating beyond read |
| uv sync --locked | Build-time dependency injection / silent re-resolution |
Then audit the trust itself, not just the workflow:
- Revoke stale Trusted Publishers / OIDC federation you no longer use — an orphaned publisher bound to a deletable workflow is the Mini Shai-Hulud entry point. Review PyPI → project → Publishing periodically.
- If a token is in play, rotate it (project-scoped, short-lived) — better, migrate to OIDC and delete it. See trusted-publishing.md.
- Vet build dependencies before a release, not after — a poisoned
uv syncstep runs before your OIDC token is even minted.
Division of labour: pypi-ops owns publisher hardening; supply-chain-defense
owns the install side and ships integrity-audit.sh (hunts pull_request_target
- OIDC misconfig and worm persistence) — run it on any repo that publishes, and gate dependency bumps through its cooldown + behavioural scan.
Cutting a release — preflight then tag
Before tagging, run the preflight so a release never fails on something mechanical (version skew, dirty lock, missing publisher config):
scripts/publish-preflight.sh . # human summary; exit 10 = not ready
scripts/publish-preflight.sh --build . # also build + twine-check the dist
scripts/publish-preflight.sh --json . | jq '.data[] | select(.ok==false)'
It checks: pyproject version == __init__.__version__, the version is not
already on PyPI (uploads are immutable — you cannot re-push 1.2.3), the
lockfile self-version matches, a tag (if present) matches the version, and the
publish workflow uses OIDC (flags a stored token). --build additionally
verifies the package actually builds and passes twine check. Dynamic-versioned
projects (hatch-vcs / setuptools-scm) are read from the HEAD tag. Green → bump,
commit, tag, push the tag; CI builds, waits at the pypi environment gate, you
approve.
When a publish fails — classify, don't guess
scripts/diagnose-publish.sh <run-id> # reads gh run log, names the cause + fix
gh run view <run-id> --log-failed | scripts/diagnose-publish.sh - # or pipe a log
The high-frequency failure classes and their fixes:
| Symptom | Cause | Fix |
|---|---|---|
| invalid-publisher / claims not found | No (pending) publisher on PyPI | Register the pending publisher (above) |
| File already exists / 400 on upload | Version already on PyPI (immutable) | Bump the version; never reuse — see recovery |
| Job stuck "Waiting" | environment: pypi needs approval | Approve the deployment in the run's UI |
| environment … not found | Publisher claim names an env the job lacks | Make environment: and the publisher's Environment match |
| Built green, not on PyPI | Silent accept / no verify step | Add the verify-on-PyPI job; re-run |
| non-OIDC/token rejected | Token wrong/expired, or OIDC misread as token | Prefer OIDC; if token, rotate + re-store |
Full catalogue with the underlying mechanics: references/recovery-playbook.md.
Local & manual publishing (uv / twine)
For a one-off or a non-CI environment. Prefer uv in 2026 (faster, native):
uv build # sdist + wheel into dist/
uv publish --trusted-publishing automatic # OIDC if in supported CI, else prompts
# token path (store in ~/.pypirc or env, never inline on the CLI history):
UV_PUBLISH_TOKEN="pypi-…" uv publish
twine remains the canonical fallback and the metadata validator (the GitHub
Action wraps it internally):
python -m twine check dist/* # ALWAYS run before any upload
python -m twine upload dist/* # token from ~/.pypirc; legacy path
Never hand-roll the HTTP upload. Details + ~/.pypirc shape:
references/uv-publish.md.
Dry-run on TestPyPI first
For a brand-new package or a risky metadata change, publish to test.pypi.org
first — it has its own separate accounts and its own pending-publisher
registration. Point the action at repository-url: https://test.pypi.org/legacy/
and register the pending publisher on TestPyPI. See
references/trusted-publishing.md.
Keeping the workflow from rotting
The pinned action SHAs and pypa/gh-action-pypi-publish major drift over time.
The verifier flags it before a release does:
scripts/check-action-pins.py --offline .github/workflows/publish.yml # structure: all pinned + commented
scripts/check-action-pins.py --live .github/workflows/publish.yml # resolve tags → flag SHA drift
--offline is the PR gate (every uses: is SHA-pinned with a # vX comment);
--live runs scheduled (resolves each pin against GitHub and exits 10 on drift,
7 if GitHub is unreachable — advisory, never a flaky block).
Publishing many packages (a fleet)
When several repos publish the same way, don't copy publish.yml N times — each
copy drifts its own SHA pins. Hoist the publish job into a reusable workflow
(on: workflow_call) in one repo, and have each package's tiny caller pass its
dist name. OIDC still works: the caller's workflow_ref is what PyPI matches,
so register each package's pending publisher against the caller filename
(e.g. release.yml), not the shared one. One place to refresh pins
(check-action-pins.py on the reusable workflow); one approval gate definition;
per-package publishers. See references/trusted-publishing.md
for the claim that must match.
Reference files
| File | Load when |
|---|---|
| references/trusted-publishing.md | Setting up OIDC, pending vs project publisher, OIDC claim semantics, environments, TestPyPI, token→OIDC migration |
| references/recovery-playbook.md | A publish failed and you need the full failure-class catalogue + mechanics |
| references/uv-publish.md | Local/manual publishing, uv build/uv publish, twine, ~/.pypirc, build backends |