Night Mode LabsBlue Book
Recipes

Python Package Versioning with Semantic Release

Automate Python package versions, changelogs, tags, releases, and artifacts from Conventional Commits.

This recipe shows how to release Python packages with python-semantic-release. It works for normal Python libraries, services that publish Python artifacts, and generated SDK repositories.

The goal is to remove manual version edits while keeping release intent visible in reviewed commits and pull requests.

When to use this recipe

Use this recipe when:

  • a Python package needs SemVer tags and release notes
  • maintainers already use or can adopt Conventional Commits
  • CI should create GitHub releases and package artifacts
  • package versions should be stamped into pyproject.toml
  • generated SDK releases should follow contract-aware SemVer labels

Do not use this as a substitute for API compatibility checks. Semantic release reads commit history. It does not understand OpenAPI, GraphQL, database, or wire-protocol compatibility by itself.

Target outcome

After implementation:

  1. Developers or bots merge Conventional Commit messages to main.
  2. CI runs python-semantic-release on main.
  3. Semantic release determines the next SemVer version.
  4. The package version is stamped in pyproject.toml.
  5. CHANGELOG.md is updated.
  6. A release commit and tag are pushed.
  7. A GitHub release is created.
  8. Wheel and source distributions are attached.
  9. PyPI or internal registry publishing runs when configured.

🤖 Reusable agent prompt

Use this prompt when you want an AI coding agent to add python-semantic-release to a Python package or generated SDK repository. It is written to produce a complete implementation with CI, release artifacts, and verification.

Remote verification can write to GitHub

This prompt includes optional end-to-end verification that may push branches, merge test changes, create Git tags, create GitHub releases, and publish package artifacts. Require explicit user approval before any remote write, merge, tag, release, or registry publish.

Inputs to collect first

Before running the prompt, infer as many values as possible from the repository and ask only for missing values, credentials, publishing settings, or choices with multiple valid options.

Inputs to resolve:

InputExample
Repositoryacme-org/acme-sdk
Default branchmain
Package nameacme-api-client
Package directorygenerated/acme-api-client
Version filepyproject.toml
Version fieldproject.version
Tag formatsdk-v{version}
Build commanduv build
Distribution globdist/*
Changelog fileCHANGELOG.md
Publish targetPyPI or internal index
Generated SDK repositoryyes or no

For normal Python packages, the package directory and repository root may be the same. For generated SDKs, the package directory usually points to the generated client package.

Copy-paste implementation prompt

You are working in a Python package repository.

Before editing files, read the canonical recipe at:
https://bluebook.nightmode.dev/docs/python-package-versioning-semantic-release

Treat that page as the source of truth for the implementation order,
workflow structure, verification process, and code snippets. Follow the
numbered steps on the page deterministically and adapt the snippets only
where repository names, package names, paths, or existing project
conventions require it.

First inspect the repository and infer every input you can. Ask the user
only for missing values, credentials, publishing settings, or choices with
multiple valid options before editing files.

- Repository: <ORG>/<REPO>
- Default branch: <DEFAULT_BRANCH>
- Package name: <PACKAGE_NAME>
- Package directory: <PACKAGE_DIRECTORY_OR_REPO_ROOT>
- Version file: <VERSION_FILE>
- Version field: <VERSION_FIELD>
- Tag format: <TAG_FORMAT>
- Build command: <BUILD_COMMAND>
- Distribution glob: <DIST_GLOB>
- Changelog file: <CHANGELOG_FILE>
- Publish target: <PYPI_OR_INTERNAL_INDEX>
- Generated SDK repository: <YES_OR_NO>

Goal: implement production-ready Python package versioning and release
automation with python-semantic-release.

Use this architecture:

1. Pull requests run normal package CI only.
2. Release automation runs only after changes merge to the default branch.
3. Version numbers are derived from Conventional Commits.
4. python-semantic-release owns version stamping, changelog updates, tags,
   and GitHub releases.
5. Package build and publishing happen from the package repository, not
   from an upstream producer repository.
6. PyPI or internal registry publishing should be optional unless the
   project explicitly requires it.
7. Use least-privilege GitHub Actions permissions.

Implement the following:

1. Inspect the repository packaging and release layout:
   - identify the package `pyproject.toml`
   - identify the existing version field and value
   - identify the build backend
   - identify whether artifacts build from repo root or a subdirectory
   - identify existing changelog files
   - identify existing release tags and tag format
   - identify existing release workflows and publishing steps
2. Add `python-semantic-release` to development dependencies.
3. Add or update `CHANGELOG.md` with the semantic-release insertion marker:
   `<!-- version list -->`.
   - If a changelog already exists, preserve historical entries.
   - Add the insertion marker near the top instead of overwriting the file.
4. Configure `[tool.semantic_release]` in `pyproject.toml`:
   - `commit_parser = "conventional"`
   - `tag_format = "<TAG_FORMAT>"`
   - `version_toml = ["<VERSION_FILE>:<VERSION_FIELD>"]`
   - `commit_message = "chore(release): v{version}"` or a package-specific
     variant such as `"chore(release): SDK v{version}"`
   - `build_command = "<BUILD_COMMAND>"`
   - changelog file configuration
   - publish `dist_glob_patterns`
5. Add `.github/workflows/release.yml` that:
   - runs on push to `<DEFAULT_BRANCH>` and workflow_dispatch
   - uses concurrency to prevent overlapping releases
   - checks out the triggering branch
   - resets to `${{ github.sha }}`
   - runs `python-semantic-release/python-semantic-release@v10.5.3`
   - builds wheel and source distribution only when a release is created
   - uploads distributions to the GitHub release with
     `python-semantic-release/publish-action@v10.5.3`
   - uploads distributions as workflow artifacts when useful
   - publishes to PyPI or internal registry only when credentials are
     configured
6. Ensure `.github/workflows/ci.yml` or equivalent pull request CI runs:
   - format or lint
   - tests
   - package build
7. If this is a generated SDK repository:
   - do not publish artifacts from the API repository
   - ensure generated PR commits use Conventional Commit subjects that
     match the contract impact
   - map major impact to `feat(sdk)!: ...` with a `BREAKING CHANGE:`
     footer
   - map minor impact to `feat(sdk): ...`
   - map patch impact to `fix(sdk): ...`
   - map no release impact to `chore(sdk): ...`
8. Add tests or workflow checks that prove the release configuration loads.
9. Run local verification:
   - package lint
   - package tests
   - package build
   - `semantic-release --noop version --no-commit --no-tag --no-push
--no-vcs-release --skip-build`
10. If the repository has a stamped version but no matching release tag,
    propose a baseline tag plan before enabling automated releases.
11. Review the final diff for secrets, hardcoded credentials, and
    accidental generated artifacts.

Security and release-management requirements:

- Ask for explicit user approval before any remote write, merge, tag,
  release, or registry publish.
- Do not reset existing package versions.
- Do not overwrite existing changelog history.
- Do not change existing tag formats without asking.
- Do not remove existing signing, SBOM, provenance, approval, or registry
  publishing steps unless they are replaced with equivalent controls.
- Do not use personal access tokens unless the organization explicitly
  requires them.
- Do not print private keys, registry tokens, or GitHub tokens.
- Prefer PyPI trusted publishing with OIDC when available.
- Keep token-based publishing as an optional fallback.
- Do not require release workflows on pull requests.
- Keep release permissions scoped to the release job.
- Keep release commits generated by GitHub Actions or the configured bot.

Conventional Commit policy:

- `feat(...)` creates a minor release.
- `fix(...)` creates a patch release.
- `feat(...)!` or `BREAKING CHANGE:` creates a major release.
- `chore(...)` normally creates no release.

Verification steps:

1. Confirm CI passes on pull requests.
2. Confirm semantic-release no-op runs locally.
3. Merge a patch-level change and confirm a patch tag is created.
4. Confirm the GitHub release contains wheel and source distribution
   artifacts.
5. Confirm the changelog contains the release entry.
6. Confirm PyPI or internal registry publishing succeeds when configured.
7. Confirm dependency bots can detect the new package version.

Do not stop at writing workflow files. Run the local verification steps
and fix failures until release configuration is valid.

Common follow-up prompts

Use these if the first implementation is close but incomplete.

The release workflow is running on pull requests. Refactor it so release
automation only runs after merge to the default branch.
The package lives in a generated SDK subdirectory. Update version_toml,
build paths, dist_glob_patterns, and publish paths to use the generated
package directory.
Semantic-release is not creating a release. Inspect the commit history,
Conventional Commit parser settings, tag format, and current tags, then
fix the release configuration.
The GitHub release exists but artifacts are missing. Fix the build step,
distribution glob, and publish-action tag wiring.
Replace token-based PyPI publishing with trusted publishing where the
registry supports OIDC.

Keep release ownership inside the package repository.

For generated SDKs, split responsibilities:

  • API repository owns API checks and contract export.
  • SDK repository owns code generation, SDK checks, and package release.
  • Contract analysis maps API changes to SemVer intent.
  • Bot commits use Conventional Commit messages.
  • python-semantic-release owns version stamping and publishing.

This creates a clean chain:

The API repository should not depend on the generated SDK. Application consumers, integration tests, CLIs, and external clients depend on the SDK.

Implementation steps

Step 1: choose the version source

For most Python packages, stamp the version in pyproject.toml:

[project]
name = "acme-api-client"
version = "0.3.0"

Then configure semantic-release to update that value:

[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]

For generated SDK workspaces, the package may live under a generated subdirectory:

[tool.semantic_release]
version_toml = ["generated/acme-api-client/pyproject.toml:project.version"]

Use committed version files when consumers expect packages to expose a stable project.version. Use tag-derived versions, such as setuptools-scm, only when the project intentionally avoids version bump commits.

For brownfield projects, preserve the existing version source. Do not reset the package to 0.1.0 or move the version field unless the maintainers explicitly choose that migration.

Step 2: adopt Conventional Commits

Semantic release needs commit messages that encode release intent. Recommended defaults:

Release impactCommit example
majorfeat(api)!: remove legacy endpoint
minorfeat(sdk): add widget color field
patchfix(client): handle empty response
nonechore(ci): update workflow comments

For breaking changes, include a footer:

BREAKING CHANGE: Removed the legacy widget endpoint.

For generated SDKs, let the contract analyzer choose the commit shape:

Contract impactBot commit subject
majorfeat(sdk)!: regenerate from REST API schema
minorfeat(sdk): regenerate from REST API schema
patchfix(sdk): regenerate from REST API schema
nonechore(sdk): regenerate from REST API schema

This keeps the pull request reviewable while letting release automation use the same history developers see.

Step 3: add semantic-release configuration

Add python-semantic-release to the development dependencies:

[dependency-groups]
dev = [
  "python-semantic-release>=10.5.3",
]

Configure release behavior in pyproject.toml:

[tool.semantic_release]
allow_zero_version = true
build_command = "uv build"
commit_message = "chore(release): v{version}"
commit_parser = "conventional"
tag_format = "v{version}"
version_toml = ["pyproject.toml:project.version"]

[tool.semantic_release.changelog.default_templates]
changelog_file = "CHANGELOG.md"

[tool.semantic_release.publish]
dist_glob_patterns = ["dist/*"]

For a generated SDK package, use package-specific paths and tags:

[tool.semantic_release]
allow_zero_version = true
build_command = "cd generated/acme-api-client && uv build"
commit_message = "chore(release): SDK v{version}"
commit_parser = "conventional"
tag_format = "sdk-v{version}"
version_toml = ["generated/acme-api-client/pyproject.toml:project.version"]

[tool.semantic_release.changelog.default_templates]
changelog_file = "CHANGELOG.md"

[tool.semantic_release.publish]
dist_glob_patterns = ["generated/acme-api-client/dist/*"]

Create the changelog file with the insertion marker:

# Changelog

<!-- version list -->

For an existing changelog, do not overwrite historical entries. Add the insertion marker near the top and let semantic-release append future entries there. If the changelog has a custom format, preserve it and propose a custom semantic-release changelog template as a follow-up.

Step 4: add the release workflow

Release from the package repository after pull requests merge to main. Do not publish package artifacts from an upstream API repository.

name: Release Package

on:
  push:
    branches:
      - main
    paths:
      - pyproject.toml
      - uv.lock
      - src/**
      - generated/**
  workflow_dispatch:

concurrency:
  group: release-package-${{ github.ref_name }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  release:
    name: Version and publish package
    runs-on: ubuntu-latest
    timeout-minutes: 15

    permissions:
      contents: write
      id-token: write

    steps:
      - name: Checkout release branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.ref_name }}
          fetch-depth: 0

      - name: Pin checkout to workflow SHA
        run: git reset --hard ${{ github.sha }}

      - name: Run python-semantic-release
        id: release
        uses: python-semantic-release/python-semantic-release@v10.5.3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          git_committer_name: github-actions[bot]
          git_committer_email: 41898282+github-actions[bot]@users.noreply.github.com
          build: "false"

      - name: Install mise tools
        if: steps.release.outputs.released == 'true'
        uses: jdx/mise-action@v2
        with:
          install: true
          cache: true

      - name: Build distributions
        if: steps.release.outputs.released == 'true'
        run: |
          mise trust .mise.toml
          uv build

      - name: Upload distributions to GitHub release
        if: steps.release.outputs.released == 'true'
        uses: python-semantic-release/publish-action@v10.5.3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          tag: ${{ steps.release.outputs.tag }}

      - name: Publish to PyPI when configured
        if: steps.release.outputs.released == 'true'
        env:
          PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
        run: |
          if [ -z "${PYPI_API_TOKEN}" ]; then
            echo "PYPI_API_TOKEN is not configured; skipping PyPI publish."
            exit 0
          fi

          uv publish dist/* --token "${PYPI_API_TOKEN}"

For generated SDKs, change build and publish paths:

- name: Build distributions
  if: steps.release.outputs.released == 'true'
  run: |
    mise trust .mise.toml
    cd generated/acme-api-client
    uv build

- name: Publish to PyPI when configured
  if: steps.release.outputs.released == 'true'
  env:
    PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
  run: |
    if [ -z "${PYPI_API_TOKEN}" ]; then
      echo "PYPI_API_TOKEN is not configured; skipping PyPI publish."
      exit 0
    fi

    uv publish generated/acme-api-client/dist/* \
      --token "${PYPI_API_TOKEN}"

Use PyPI trusted publishing when possible. Keep token publishing only for internal package indexes or registries that do not support OIDC.

Brownfield migration guidance

Brownfield repositories often already have versions, changelogs, tags, and release workflows. Migrate those assets instead of replacing them.

Recommended approach:

  • keep the existing stamped version and point version_toml at it
  • match tag_format to existing release tags when possible
  • preserve existing changelog history and add the insertion marker near the top
  • preserve signing, SBOM, provenance, approval, and registry steps from existing release workflows
  • keep manual historical changelog entries below the semantic-release insertion marker
  • avoid mixing a tag-format migration with release automation rollout

If the project has a stamped version but no matching Git tag, propose a baseline tag plan before enabling automated releases. For example, if the current package version is 1.4.2 and the chosen tag format is v{version}, create v1.4.2 on the current release commit so semantic-release has a clean starting point.

If the project already has tags in a different format, ask before changing formats. Switching from v1.4.2 to sdk-v1.4.3 fragments release history and may confuse dependency bots or release dashboards.

Step 5: test release automation locally

Run normal package checks first:

mise run lint
mise run test
uv build

Then run semantic-release in no-op mode:

uv run semantic-release --noop version \
  --no-commit \
  --no-tag \
  --no-push \
  --no-vcs-release \
  --skip-build

Expected results:

  • configuration loads without errors
  • the current or next version is printed
  • no commits, tags, or releases are created
  • missing remote tokens only warn during local no-op runs

Step 6: automate consumer upgrades

Do not wire deploys directly to package publication. Treat released SDKs and libraries like normal dependencies.

Recommended defaults:

  • libraries use compatible ranges, such as acme-api-client>=0.3,<0.4
  • applications pin exact versions, such as acme-api-client==0.3.1
  • Renovate or Dependabot opens dependency upgrade pull requests
  • consumer CI validates the new package against its own codebase
  • major upgrades require human review and migration notes

This keeps package producers and consumers decoupled.

Branch protection

Require normal CI before merge. The release workflow should run only after code lands on main.

Recommended checks:

  • lint and format
  • unit tests
  • package build
  • generated SDK smoke tests, when applicable
  • vulnerability and license scans, when required

Do not require the release workflow on pull requests. It needs write permissions and should only run from trusted branches.

Troubleshooting

No release was created

Check that merged commits contain release-bearing Conventional Commit messages. chore(...) commits normally do not trigger releases.

The release bump is too small

Check for missing ! syntax or missing BREAKING CHANGE: footers. For generated SDKs, check that the contract analyzer mapped the schema change to the expected commit subject.

The version file did not change

Check version_toml. The path is relative to the repository root, and the key should point to the TOML field that contains the version.

The changelog was overwritten or empty

Restore the previous changelog from Git history, then add the semantic-release insertion marker near the top without deleting historical entries.

Ensure CHANGELOG.md contains the semantic-release insertion marker:

<!-- version list -->

Artifacts are missing from the GitHub release

Check dist_glob_patterns and the workflow build path. For generated SDKs, build artifacts often live under the generated package directory, not the repository root.

PyPI publishing failed

Prefer trusted publishing with OIDC. If using tokens, confirm the token is scoped to the package and is available as PYPI_API_TOKEN.

Existing version has no matching tag

Create a baseline tag before enabling automated releases, or ask the maintainers which historical commit should become the release baseline. Do not let semantic-release treat the entire repository history as one unreleased change set.

Definition of done

The recipe is complete when:

  • package CI passes on pull requests
  • semantic-release no-op runs locally
  • merge to main creates a version commit when commits warrant release
  • release tag matches the configured tag_format
  • GitHub release contains wheel and source distribution artifacts
  • changelog contains the release entry
  • optional PyPI or internal registry publishing succeeds
  • consumer dependency bots can discover the new package version

On this page