Publish Python packages to PyPI via OIDC Trusted Publishing (PEP 740 attestations, gh-action-pypi-publish) instead of stored tokens. Use for: invalid-publisher errors, pending-publisher 404s, uv publish/twine, TestPyPI, environment approval gates, and rotating/auditing publish tokens.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add 0xDarkMatter/claude-mods --skill pypi-ops --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Pypi Ops?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/0xdarkmatter-pypi-ops)More formats (shields.io, HTML) on the badges page.
---
name: pypi-ops
description: "Publish Python packages to PyPI via OIDC Trusted Publishing (PEP 740 attestations, gh-action-pypi-publish) instead of stored tokens. Use for: invalid-publisher errors, pending-publisher 404s, uv publish/twine, TestPyPI, environment approval gates, and rotating/auditing publish tokens."
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. **Preflight** — `scripts/publish-preflight.sh --build .` (this skill).
3. **Bump → tag → push** — `git-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](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](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](references/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):
```bash
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
```bash
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](references/recovery-playbook.md) |
| 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](references/recovery-playbook.md).
## Local & manual publishing (uv / twine)
For a one-off or a non-CI environment. **Prefer `uv` in 2026** (faster, native):
```bash
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):
```bash
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](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](references/trusted-publishing.md#testpypi).
## 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:
```bash
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](references/trusted-publishing.md)
for the claim that must match.
## Reference files
| File | Load when |
|---|---|
| [references/trusted-publishing.md](references/trusted-publishing.md) | Setting up OIDC, pending vs project publisher, OIDC claim semantics, environments, TestPyPI, token→OIDC migration |
| [references/recovery-playbook.md](references/recovery-playbook.md) | A publish failed and you need the full failure-class catalogue + mechanics |
| [references/uv-publish.md](references/uv-publish.md) | Local/manual publishing, `uv build`/`uv publish`, `twine`, `~/.pypirc`, build backends |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!