# Python Release

> Cuts and automates releases of a Python package — version bumps, changelogs, git tags, PyPI trusted publishing (OIDC), and the tag-triggered publish workflow with a gated environment. Use for 'cut a release', 'publish to PyPI', 'bump the version', 'automate releases', 'set up trusted publishing'. Not for packaging metadata, build backends, or non-release CI.

- Skill: `paldom/python-release` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add paldom/python-release`
- Raw SKILL.md: https://api.skillmd.com/api/skills/paldom/python-release/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: Paldom (https://skillmd.com/u/paldom)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/paldom/python-release

---


# 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:

```bash
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:

```python
from importlib.metadata import version
__version__ = version("your-package-name")
```

Configs for both strategies: [references/versioning-and-changelog.md](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](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](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:

```yaml
# .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)

```bash
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**:

```bash
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](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](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](references/trusted-publishing.md) — OIDC
  mechanics, exact-match troubleshooting, PEP 740 attestations, limitations,
  threat model.
- [references/versioning-and-changelog.md](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](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.

