pypi-ops

star 22

Publish Python packages to PyPI the 2026-best-practice way — OIDC Trusted Publishing with PEP 740 attestations via gh-action-pypi-publish, not stored API tokens. Use when: setting up PyPI publishing for a new package, a release workflow fails with `invalid-publisher` / `Trusted publishing exchange failure`, a first publish 404s because no pending publisher exists, a version upload is rejected as already existing, choosing between Trusted Publishing and an API token, publishing locally with `uv publish` or `twine`, wiring TestPyPI for a dry run, adding a release `environment` approval gate, or a tag-triggered publish built fine but never went live on PyPI. Triggers on: pypi, publish to pypi, pypi release, cut a release, trusted publishing, pending publisher, invalid-publisher, gh-action-pypi-publish, pypa publish, twine upload, twine check, uv publish, uv build, build sdist wheel, PEP 740, attestations, OIDC publish, id-token, environment pypi, testpypi, test.pypi.org, version already exists, file already exis

0xDarkMatter By 0xDarkMatter schedule Updated 6/17/2026

name: pypi-ops description: "Publish Python packages to PyPI the 2026-best-practice way — OIDC Trusted Publishing with PEP 740 attestations via gh-action-pypi-publish, not stored API tokens. Use when: setting up PyPI publishing for a new package, a release workflow fails with invalid-publisher / Trusted publishing exchange failure, a first publish 404s because no pending publisher exists, a version upload is rejected as already existing, choosing between Trusted Publishing and an API token, publishing locally with uv publish or twine, wiring TestPyPI for a dry run, adding a release environment approval gate, or a tag-triggered publish built fine but never went live on PyPI. Triggers on: pypi, publish to pypi, pypi release, cut a release, trusted publishing, pending publisher, invalid-publisher, gh-action-pypi-publish, pypa publish, twine upload, twine check, uv publish, uv build, build sdist wheel, PEP 740, attestations, OIDC publish, id-token, environment pypi, testpypi, test.pypi.org, version already exists, file already exists, 400 reupload, api token pypi, token, hatchling build, package publishing, release automation pypi, secure pypi publishing, publish token theft, stale OIDC federation, trusted publisher audit, supply chain publishing, sha-pinned actions, mini shai-hulud, rotate pypi token, yank release." when_to_use: "Use when setting up or fixing PyPI publishing — especially a release CI that fails with invalid-publisher / no pending publisher, a first publish, choosing OIDC vs token, or publishing locally with uv/twine." license: MIT compatibility: "Python 3.8+ packaging; GitHub Actions for the OIDC flow; uv or twine for local publish" allowed-tools: "Read Write Edit Bash Glob Grep WebFetch" metadata: author: claude-mods related-skills: "supply-chain-defense, github-ops, git-ops, ci-cd-ops, python-env"

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:

  1. 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.
  2. Preflightscripts/publish-preflight.sh --build . (this skill).
  3. Bump → tag → pushgit-ops (its push-gate scans for secrets / forbidden files before the tag goes up).
  4. CI publishes via OIDC — this skill's assets/publish.yml; you approve at the pypi environment gate.
  5. 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 the name:)
Environment name must equal the job's environment: (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.yml looks 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 publish job has permissions: id-token: write and pypa/gh-action-pypi-publish with attestations: true. No password:/token.
  • environment: pypi on the publish job → a human approves every release (defense-in-depth: even a compromised repo can't auto-ship).
  • Build/publish split: a build job (no elevated perms) produces + uploads the dist artifact; publish downloads 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 # vX comment (mutable tags get hijacked — see check-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 sync step 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
Install via CLI
npx skills add https://github.com/0xDarkMatter/claude-mods --skill pypi-ops
Repository Details
star Stars 22
call_split Forks 4
navigation Branch main
article Path SKILL.md
More from Creator
0xDarkMatter
0xDarkMatter Explore all skills →