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:
- Developers or bots merge Conventional Commit messages to
main. - CI runs
python-semantic-releaseonmain. - Semantic release determines the next SemVer version.
- The package version is stamped in
pyproject.toml. CHANGELOG.mdis updated.- A release commit and tag are pushed.
- A GitHub release is created.
- Wheel and source distributions are attached.
- 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:
| Input | Example |
|---|---|
| Repository | acme-org/acme-sdk |
| Default branch | main |
| Package name | acme-api-client |
| Package directory | generated/acme-api-client |
| Version file | pyproject.toml |
| Version field | project.version |
| Tag format | sdk-v{version} |
| Build command | uv build |
| Distribution glob | dist/* |
| Changelog file | CHANGELOG.md |
| Publish target | PyPI or internal index |
| Generated SDK repository | yes 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.Recommended architecture
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-releaseowns 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 impact | Commit example |
|---|---|
| major | feat(api)!: remove legacy endpoint |
| minor | feat(sdk): add widget color field |
| patch | fix(client): handle empty response |
| none | chore(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 impact | Bot commit subject |
|---|---|
| major | feat(sdk)!: regenerate from REST API schema |
| minor | feat(sdk): regenerate from REST API schema |
| patch | fix(sdk): regenerate from REST API schema |
| none | chore(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_tomlat it - match
tag_formatto 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 buildThen run semantic-release in no-op mode:
uv run semantic-release --noop version \
--no-commit \
--no-tag \
--no-push \
--no-vcs-release \
--skip-buildExpected 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
maincreates 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