Take a Python package from "the code is ready" to "the new version is on PyPI"
with a repeatable, secured release pipeline: a deliberate versioning strategy,
bump + changelog + tag, OIDC trusted publishing, and a tag-triggered GitHub
Actions publish workflow with a human gate. This skill exists because release
automation fails in specific, repeatable ways: uv.lock desync after a version
bump, trusted-publisher exact-match mismatches producing invalid-publisher
errors, tags created with GITHUB_TOKEN that never trigger the publish
workflow, and commit-scraped changelogs nobody can read.
Commands below use uv/uvx. Without uv, substitute python3 -m build +
twine upload for build/publish, edit [project] version by hand, and run
pinned tools via pip install <pkg>==<version> — noted here once.
When NOT to use
pyproject.tomlmetadata, build backend choice, src layout, building or verifying wheels/sdists — the python-packaging skill, if installed, covers that.- Lint/type/test CI quality gates, matrices, required checks, general workflow hardening — the python-ci skill.
- Dependabot, pip-audit, secret scanning, SBOMs, CODEOWNERS — the python-supply-chain skill.
- Enforcing conventional-commit messages with git hooks — the python-precommit skill (this skill only consumes the convention).
- Writing announcement prose or marketing copy for a release — no skill needed.
Workflow
1. Take stock of the repo
Run the audit script first — it detects most of the failure modes this skill fixes:
python3 "${CLAUDE_SKILL_DIR}/scripts/check_release_setup.py" --repo .
Then confirm by hand:
- Package name in
[project]. Checkhttps://pypi.org/project/<name>/NOW — name collisions surface embarrassingly late, at first upload. - Where the version lives: static
project.version, a duplicated__init__.py__version__, or dynamic (hatch-vcs/uv-dynamic-versioning). - Existing tags and their format (
git tag --list 'v*'), existingCHANGELOG.md, existing publish workflow, and anyPYPI_API_TOKEN/UV_PUBLISH_TOKENsecrets to retire.
2. Choose a versioning strategy (deliberately — this is unsettled)
There is no community consensus; pick one on purpose and record it:
| Strategy | How | Choose when | Cost |
|---|---|---|---|
| Static (default here) | uv version --bump patch|minor|major rewrites project.version and refreshes uv.lock |
You want explicit, readable version history in git | Needs a bump commit; can drift from the tag if a bump is forgotten |
| Dynamic (git-tag-derived) | hatch-vcs or uv-dynamic-versioning computes the version from the tag at build time |
Tag and package version must never diverge; no bump commits wanted | Disables uv version introspection; adds a build-time plugin; opaque to newcomers |
Either way: pyproject.toml (or the tag) is the single source of truth. Do not
maintain a hand-written __version__ in __init__.py — read it at runtime:
from importlib.metadata import version
__version__ = version("your-package-name")
Configs for both strategies: references/versioning-and-changelog.md.
3. Choose the automation level (three philosophies)
The Python release-tool landscape is fragmented — there is no npm-style "just install semantic-release" default. Three workable levels:
| Level | Tool | Ship trigger | Best for |
|---|---|---|---|
| Human-cut, tag-driven (default here) | uv version + git tag; CI publishes on the tag |
Maintainer pushes a v* tag |
Most packages; no commit-discipline dependency |
| PR-gated automation | release-please — maintains a standing Release PR with version + changelog | Human merges the Release PR | Teams wanting automation with a review gate |
| Fully automated | python-semantic-release (PSR) from conventional commits | Every qualifying push to main | Strict conventional-commits discipline, high release cadence |
Default to human-cut unless the user asks for more automation: it is the only level with zero dependence on commit-message discipline, and every source on agentic/automated releases converges on automating the mechanical steps while keeping a human on "should this ship". Full PSR and release-please workflow YAML: references/publish-workflows.md.
4. Choose a changelog strategy
- Default: Keep a Changelog. Maintain
CHANGELOG.mdwith an## [Unreleased]section (subheadings from the spec vocabulary: Added / Changed / Deprecated / Removed / Fixed / Security). At release time, rename it to## [X.Y.Z] - YYYY-MM-DDand start a fresh Unreleased section. - Commit-scraped (PSR default, or
git-cliff): zero effort, but practitioners widely deride raw commit dumps as "technically correct and completely useless" for end users. If chosen, either post-process into user-facing prose before publishing, or accept the trade-off knowingly. - Never let tooling append dates or any extra text into the version string
itself —
2.1.0 (2026-01-07)breaks strict semver parsers downstream. Dates belong in the heading, outside the version.
5. Register the trusted publisher on PyPI
OIDC trusted publishing replaces long-lived API tokens: PyPI mints a short-lived (~15 min), package-scoped token for the exact workflow identity — nothing stored, nothing to rotate or exfiltrate. Configure it BEFORE the first workflow run:
- New (never-published) project: https://pypi.org/manage/account/publishing/ → "Add a new pending publisher". Existing project: project page → Settings → Publishing.
- Fill the four fields — validation is an exact, case-sensitive match:
- Owner (GitHub org/user) and repository name, exactly as GitHub reports them.
- Workflow filename — the bare filename with extension, e.g.
publish.yml(not a path, notpublish.yamlif the file is.yml). - Environment name:
pypi(recommended; must then matchenvironment:in the job).
- In the repo: Settings → Environments →
pypi→ add required reviewers. This is the human release gate — the publish job pauses for approval. (Required reviewers on environments: public repos on any plan; private repos only on GitHub Enterprise.) - After the first successful OIDC publish, revoke any leftover project-scoped API tokens — a compromised account can otherwise mint a token and publish around the OIDC flow entirely.
Full mechanics, limitations, and threat model: references/trusted-publishing.md.
6. Write the publish workflow
Tag-triggered, split into test → build → publish so the job holding the OIDC permission never checks out or executes project code:
# .github/workflows/publish.yml — filename must match the trusted-publisher entry
name: Publish
on:
push:
tags: ["v*.*.*"]
permissions: {} # deny-all default; each job requests only what it needs
jobs:
test:
# A tag push does NOT inherit the tagged commit's CI status — re-run the
# quality gate here (or reuse your CI via workflow_call) before publishing.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: uv sync --locked # fails loudly if uv.lock desynced from pyproject.toml
- run: uv run pytest
build:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: uv build
- run: uvx --from twine==7.0.0 twine check --strict dist/*
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest # GitHub-hosted: self-hosted is untested/unsupported by the PyPA action
environment: pypi # the gated environment — must match the PyPI publisher entry
permissions:
id-token: write # the only write permission publishing needs
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true # re-runs after partial failure become idempotent
Notes on this shape:
pypa/gh-action-pypi-publishgenerates PEP 740 attestations automatically under trusted publishing — no extra flags.uv publishalso auto-detects the OIDC token but does not generate attestations (it only uploads ones you created separately) — if provenance matters, use the PyPA action.- Every action is pinned to a full commit SHA with the tag in a comment.
Mutable tags get hijacked (tj-actions, March 2025) — and the publish workflow
is the highest-value target in the repo. Refresh a pin with:
gh api repos/<owner>/<repo>/commits/<tag> --jq .sha. - Keep the publish step in this directly-triggered file. Moving it behind
workflow_callindirection breaks the trusted-publisher exact match — PyPI validates against the top-level workflow's filename. - Do not add
contents: writehere; publishing needs none. If you also create GitHub Releases, do it in a separate job with its own minimal permission.
Re-run python3 "${CLAUDE_SKILL_DIR}/scripts/check_release_setup.py" — it verifies trigger,
permissions, environment, SHA pinning, and leftover token secrets.
7. Cut a release (the repeatable manual flow)
uv version --bump minor # rewrites pyproject.toml AND refreshes uv.lock
VERSION="$(uv version --short)"
# Roll CHANGELOG.md's Unreleased section into "## [$VERSION] - $(date +%F)"
git add pyproject.toml uv.lock CHANGELOG.md
git commit -m "chore(release): v${VERSION}"
git tag -a "v${VERSION}" -m "v${VERSION}"
git push origin main "v${VERSION}"
Then approve the pypi environment gate when the workflow pauses, and verify.
If anything other than uv version performed the bump (PSR, bump-my-version,
a hand edit), refresh and commit the lockfile in the same commit:
uv lock --upgrade-package "<package-name>"
git add uv.lock
For the first release ever, rehearse against TestPyPI (isolated index, its own trusted-publisher entry) before production — recipe in references/publish-workflows.md.
8. Verify the release
- Workflow run green; approval step recorded who released.
- Package page live on PyPI with the new version; file view shows attestations.
- Install from a clean environment:
uvx --isolated --from "<package-name>==${VERSION}" python -c "import <module>". python3 "${CLAUDE_SKILL_DIR}/scripts/check_release_setup.py"reports no errors (tag, version, and lockfile all agree).
Output spec — what done looks like
- A recorded versioning decision: static
project.version(default) or a configured dynamic-versioning plugin — never both, never a stray__version__. CHANGELOG.mdwith the released section and a fresh Unreleased skeleton.- A trusted publisher registered on PyPI matching the workflow exactly; no long-lived PyPI token secrets left in the repo.
.github/workflows/publish.yml— tag-triggered, deny-all default permissions,id-token: writeonly on the publish job, gatedpypienvironment, all actions SHA-pinned, tests gating the upload.- An annotated
vX.Y.Ztag on a commit wherepyproject.toml,uv.lock, and the tag agree on the version. - A green publish run and the version installable from PyPI.
Failure modes & gotchas
uv.lockdesync (the #1 automated-release breaker). A bump tool rewritesproject.version;uv.lockstill records the old version. Plainuv sync/uv runthen silently rewrite the lockfile (so the committed one stays stale), and CI'suv sync --lockedfails its freshness check. Fix: runuv lock --upgrade-package <name>between bump and build and commit the lockfile with the bump commit — regenerating it in the runner leaves every clone with a stale lock. For PSR, put it inbuild_command.- "Release no longer accepts new files." Since 2026-07-22 PyPI rejects new
files on a release older than 14 days — a wheel for a new Python version cannot
be added to last month's release; cut a post-release (
1.2.3.post1) or a patch version instead, and build the full wheel matrix in one publish run. - Upload rejected with an unsupported metadata version. Hatchling 1.32.0
(2026-08-11) emits Core Metadata 2.5 by default;
twine < 7.0.0andpypa/gh-action-pypi-publish < 1.14.2cannot upload it (1.30.0 was yanked for exactly this). Pin twine >= 7.0.0 (which also drops the never-standardized metadata 2.0) and the publish action >= v1.14.2 whenever the backend is current. invalid-publisher/ "unable to authenticate" despite correct-looking config. One field differs from reality: owner/repo casing, workflow filename (including.ymlvs.yaml), or environment name — or the publish step runs behindworkflow_call/reusable-workflow indirection, or on a self-hosted runner. Checklist in references/trusted-publishing.md.- Tag pushed by automation never triggers the publish workflow. Resources
created with the default
GITHUB_TOKENdo not trigger other workflows — a GitHub platform rule, not a bug in release-please/PSR. Use a fine-grained PAT or a GitHub App token for the tag-creating step. - PSR computes no bump or the wrong bump. Shallow checkout — PSR needs
fetch-depth: 0onactions/checkout. Addconcurrency: release(withcancel-in-progress: false) so racing pushes don't double-release. - Release hangs at the tagging step. Git is configured to sign tags (semantic-release issue #3065). Disable tag signing in CI.
- Partial failure leaves a dead tag. semantic-release/PSR tag and create
the GitHub Release before uploading and do not roll back if the upload fails.
skip-existing: truemakes re-runs idempotent; if atomicity matters, order the pipeline compute-version → publish → tag last. - Silent non-bumps under commit-driven automation. Non-conforming commit
messages simply produce no release (semantic-release issue #3642, closed
not-planned). For 0.x packages, set PSR's
allow_zero_version = true; the JS semantic-release declares 0.x semantics out of scope entirely. uv version --bumpside effects. It syncs the environment as a side effect and cannot update files beyondpyproject.toml(uv issues #15286, #13827) — another reason to drop hand-written__version__attributes.- Multi-index
uv publishfootguns.uv publishcannot find an index configured withexplicit = true(uv issue #9919); the indexurl(used by--check-urlto detect already-uploaded files) is distinct frompublish_url. Also, uv ≥ 0.11.22 uploads wheels before sdists — don't assume sdist-first ordering. - Branch protection blocks the release bot's bump commit. Give a dedicated
GitHub App a targeted ruleset bypass; do not hand the bot admin rights, and
protect tags with a ruleset so a released
vX.Y.Zcannot be re-pointed. - Trusted publishing relocates risk rather than removing it. The CI runner is the new trust boundary: a step injected before publish ships malware with valid attestations. Minimal permissions + SHA pinning (above) are part of the release design; repo-wide workflow hardening belongs to the python-ci and python-supply-chain skills.
Bundled resources
- references/trusted-publishing.md — OIDC mechanics, exact-match troubleshooting, PEP 740 attestations, limitations, threat model.
- references/versioning-and-changelog.md — static/dynamic configs, tool landscape (PSR, release-please, git-cliff, bump-my-version, …), PSR + uv config, changelog strategies, SemVer caveats.
- references/publish-workflows.md — complete
workflow variants: PSR push-to-main, release-please, TestPyPI rehearsal,
uv publishinstead of the PyPA action. scripts/check_release_setup.py— read-only audit of version/lockfile/tag agreement and publish-workflow hygiene; non-zero exit on errors.