publishing.md
July 11, 2026 · View on GitHub
Publishing Releases
This is how to publish a Python package to PyPI from GitHub Actions, when using the simple-modern-uv template.
Thanks to
the dynamic versioning plugin and
the
publish.yml workflow,
you can simply create tagged releases (using standard format for the tag name, e.g.
v0.1.0) on GitHub and the tag will trigger a release build, which then uploads it to
PyPI.
First-Time Setup
This part is a little confusing the first time. Here is the simplest way to do it. For the purposes of this example replace OWNER and PROJECT with the right values.
Note: These steps assume you already have a GitHub repo with your code pushed. If not, create an empty GitHub repo (no README, no .gitignore, no license; the template already provides these) and push your code to it. See the README for details.
-
Get a PyPI account at pypi.org and sign in.
-
Pick a name for the project that isn’t already taken.
-
Go to
https://pypi.org/project/PROJECTto see if another project with that name already exists. -
If needed, update your
pyproject.tomlwith the correct name.
-
-
Authorize your repository to publish to PyPI:
-
Go to the publishing settings page.
-
Find “Trusted Publisher Management” and register your GitHub repo as a new “pending” trusted publisher.
-
Enter the project name, repo owner, repo name, and
publish.ymlas the workflow name. (You can leave the “environment name” field blank.)
-
-
Create a release on GitHub:
-
Commit code and make sure it’s running correctly.
-
Go to your GitHub project page, then click on Actions tab.
-
Confirm all tests are passing in the last CI workflow. (If you want, you can even publish this template when it’s empty as just a stub project, to try all this out.)
-
Go to your GitHub project page, click on Releases.
-
Fill in the tag and the release name. Select to create a new tag, and pick a version. A good option is
v0.1.0. (It’s wise to have it start with av.) -
Submit to create the release.
-
-
Confirm it publishes to PyPI
-
Watch for the release workflow in the GitHub Actions tab.
-
If it succeeds, you should see it appear at
https://pypi.org/project/PROJECT.
-
Publishing Subsequent Releases
Follow this checklist for each new release.
Pre-Release Checklist
-
Cut the release from an up-to-date
main:git checkout main git fetch --tags --prune origin git pull --ff-only origin main git status --short --branch # confirm a clean working treeFetch tags before any local build.
uv-dynamic-versioningderives the version from Git history; a tagless clone can incorrectly produce0.0.1.devNeven when releases exist upstream. -
Verify all changes are committed and pushed:
git log origin/main..HEAD # should be empty if pushed -
Run linting and tests locally:
make lint make test -
Confirm CI is passing on
main:gh run list --branch main --limit 3Or check the Actions tab on GitHub. The most recent run for the commit you’re about to tag must be green (a superseded older failure is fine).
-
Determine the new version number:
# These should identify the same latest release. git describe --tags --abbrev=0 gh release list --limit 1 NEW_TAG="vX.Y.Z" # Replace with the intended version.Use semantic versioning:
-
Patch (e.g.,
v0.5.8→v0.5.9): Bug fixes, minor changes -
Minor (e.g.,
v0.5.9→v0.6.0): New features; before 1.0, breaking changes also require a minor bump -
Major (e.g.,
v0.6.0→v1.0.0): Breaking changes after 1.0
-
Create the Release
-
Review changes since the last release:
# Get the last release tag: LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName') # View commits since last release: git log "${LAST_TAG}..HEAD" --oneline # View full diff: git diff "${LAST_TAG}..HEAD" -
Verify the tag-derived wheel version locally:
Build from an isolated clone with a local-only candidate tag. This proves the wheel reports the intended version without creating or pushing a repository tag:
( set -euo pipefail VERSION="${NEW_TAG#v}" CHECK_DIR="$(mktemp -d)" trap 'rm -rf -- "${CHECK_DIR:?}"' EXIT git clone --no-local --quiet . "${CHECK_DIR}/repo" git -C "${CHECK_DIR}/repo" tag "${NEW_TAG}" uv build --wheel --directory "${CHECK_DIR}/repo" WHEEL="${CHECK_DIR}/repo/dist/flexdoc-${VERSION}-py3-none-any.whl" test -f "${WHEEL}" unzip -p "${WHEEL}" "flexdoc-${VERSION}.dist-info/METADATA" \ | grep -Fx "Version: ${VERSION}" )The metadata check must print exactly
Version: X.Y.Z. Stop if the filename or metadata differs; do not create the GitHub release. -
Write the release notes in a file, then create the release:
Author the notes as plain Markdown in a file (see Release Notes Format below), then pass it with
--notes-file. Writing the notes in a file keeps the shell out of the way: release notes routinely contain backticks and$, which a shell heredoc would try to run as commands or expand as variables. End the notes with a concrete compare link built from the actual tags (substitute the realLAST_TAGandNEW_TAGvalues into the URL), e.g.https://github.com/OWNER/PROJECT/compare/v0.1.0...v0.2.0.# Edit release-notes.md in your editor, ending with the concrete compare link. gh release create "${NEW_TAG}" --title "${NEW_TAG}" --notes-file release-notes.mdAlternatively, use
--generate-notesfor GitHub’s auto-generated notes. -
Verify the release published successfully:
# Check the release workflow: gh run list --workflow=publish.yml --limit 1 # Verify on PyPI (may take a minute): # https://pypi.org/project/PROJECTOnce it appears on PyPI, smoke-test that the published artifact actually resolves and installs from PyPI. If your project exposes a CLI:
uvx --from PROJECT==X.Y.Z PROJECT --version
Releasing From a Remote Agent Session
Remote agent sessions cannot push tags or dispatch workflows, so steps 7-9 above are
replaced by the release-request path: after the pre-release checklist passes, update
.github/release-request/request.json (the tag) and .github/release-request/notes.md
(the release notes) and land them on main via PR. The merge triggers
.github/workflows/release.yml, which creates the tag and GitHub Release at the merged
commit and then calls publish.yml to test, build, and publish to PyPI.
Maintainer releases created with gh release create fire publish.yml directly and
never run release.yml.
Release Notes Format
Use this structure for release notes:
## What's Changed
### Bug Fixes
**Short title of fix**
Description of what was fixed and why it matters.
### New Features
**Short title of feature**
Description of the new capability.
### Breaking Changes
**Short title of breaking change**
Description of what changed and how to migrate.
### Full Changelog
https://github.com/OWNER/PROJECT/compare/vPREVIOUS...vNEW
Guidelines:
-
Use
## What's Changedas the top-level heading. -
Group changes under
### Bug Fixes,### New Features,### Breaking Changes, etc. as appropriate. -
Use
**bold**for short titles of individual changes. -
Include technical details only when helpful for users.
-
Always include the Full Changelog compare link at the end.
-
For small releases, a simple bullet list is acceptable instead of full sections.
This file was built with simple-modern-uv.