Release workflow
Walk through the full release cycle — bump, build, twine check, upload to TestPyPI, tag, and write a CHANGELOG entry.
- Use twine upload to publish a package to TestPyPI
- Create a GitHub tag and release from the command line
- Write a CHANGELOG entry following Keep a Changelog format
A release is not a single action — it is an ordered sequence of steps that, if performed out of order or with a step skipped, produces a broken or inconsistent state. Automating the sequence is the long-term goal; understanding it step by step is the prerequisite.
The release sequence
# 1. Bump the version in pyproject.toml
# Edit [project] version = "0.2.0"
# 2. Update CHANGELOG (see below)
# 3. Commit the bump
git add pyproject.toml CHANGELOG.md
git commit -m "Release 0.2.0"
# 4. Build both artifacts
python -m build
# 5. Check the artifacts before uploading
twine check dist/*
# 6. Upload to TestPyPI first
twine upload --repository testpypi dist/*
# 7. Verify the install from TestPyPI
pip install -i https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
my-tool==0.2.0
# 8. Upload to real PyPI
twine upload dist/*
# 9. Tag the release
git tag -a v0.2.0 -m "Release 0.2.0"
git push origin main --tagsEach step has a specific failure mode. twine check catches missing README
rendering and bad classifiers before they reach PyPI. The TestPyPI install
confirms the package actually installs and the entry point works. Only then do
you touch the real registry.
CHANGELOG format
The Keep a Changelog format is the most widely adopted convention:
# Changelog
## [Unreleased]
## [0.2.0] - 2025-06-01
### Added
- `--output-format json` flag for machine-readable output.
- `scan --recursive` flag to traverse subdirectories.
### Changed
- Progress bar now shows elapsed time.
### Fixed
- Crash when an empty file is encountered (#42).
## [0.1.0] - 2025-04-15
### Added
- Initial release with `scan` and `report` subcommands.The [Unreleased] section at the top accumulates changes as they land. When
you release, rename [Unreleased] to the version and date, then add a new
empty [Unreleased] above it.
The five categories — Added, Changed, Deprecated, Removed, Fixed — cover every meaningful type of change. Security vulnerabilities get a dedicated Security section and should always be in the release notes.
GitHub releases
A GitHub Release is the user-facing announcement, attached to a git tag. Create
it from the CLI using the gh tool:
gh release create v0.2.0 dist/*.whl dist/*.tar.gz \
--title "0.2.0" \
--notes-file CHANGELOG.mdThis creates the tag (if it does not exist), attaches the wheel and sdist as
release assets, and populates the release body from the CHANGELOG. Users who
prefer to download directly rather than use pip get a single, canonical place
to do so.
Never delete a version from PyPI and re-upload the same version number. Once a
name and version pair is published, PyPI yank support (pip install my-tool!=0.2.0) is the correct response to a broken release — not deletion.
Deletion would allow a different (malicious) package to claim the same version
string.
Store your PyPI token in the TWINE_PASSWORD environment variable (and set
TWINE_USERNAME=__token__). Most CI systems (GitHub Actions, GitLab CI) have
built-in secret injection that keeps the token out of logs.
Where to go next
Next: lab — publish to TestPyPI — take the click tool from the intermediate track, wire up the full pyproject.toml, and walk through the complete release sequence against TestPyPI.