SuperAPI AI Directory

python-release

Deployment & operations

View repository

Updated 10-16 03:14

About this skill

python-release

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.toml metadata, 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]. Check https://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*'), existing CHANGELOG.md, existing publish workflow, and any PYPI_API_TOKEN / UV_PUBLISH_TOKEN secrets 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.md with 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-DD and 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:

  1. New (never-published) project: https://pypi.org/manage/account/publishing/ → "Add a new pending publisher". Existing project: project page → Settings → Publishing.
  2. 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, not publish.yaml if the file is .yml).
    • Environment name: pypi (recommended; must then match environment: in the job).
  3. 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.)
  4. 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-publish generates PEP 740 attestations automatically under trusted publishing — no extra flags. uv publish also 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_call indirection breaks the trusted-publisher exact match — PyPI validates against the top-level workflow's filename.
  • Do not add contents: write here; 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.md with 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: write only on the publish job, gated pypi environment, all actions SHA-pinned, tests gating the upload.
  • An annotated vX.Y.Z tag on a commit where pyproject.toml, uv.lock, and the tag agree on the version.
  • A green publish run and the version installable from PyPI.

Failure modes & gotchas

  • uv.lock desync (the #1 automated-release breaker). A bump tool rewrites project.version; uv.lock still records the old version. Plain uv sync/ uv run then silently rewrite the lockfile (so the committed one stays stale), and CI's uv sync --locked fails its freshness check. Fix: run uv 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 in build_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.0 and pypa/gh-action-pypi-publish < 1.14.2 cannot 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 .yml vs .yaml), or environment name — or the publish step runs behind workflow_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_TOKEN do 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: 0 on actions/checkout. Add concurrency: release (with cancel-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: true makes 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 --bump side effects. It syncs the environment as a side effect and cannot update files beyond pyproject.toml (uv issues #15286, #13827) — another reason to drop hand-written __version__ attributes.
  • Multi-index uv publish footguns. uv publish cannot find an index configured with explicit = true (uv issue #9919); the index url (used by --check-url to detect already-uploaded files) is distinct from publish_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.Z cannot 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 publish instead 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.