I Automated My Open Source Python Releases with GitHub Actions — Here’s the Exact Semantic Versioning Workflow
Let’s be honest. Tagging a release, bumping the version, writing changelog entries, and pushing to PyPI is boring. Worse, it’s error-prone. I’ve lost count of how many times I pushed a `v1.2.3` tag that didn’t match `__version__.py`, or forgot to update the changelog until a user asked why a fix wasn’t released yet.
So I automated it. Completely.
Ship Leaner and Faster: Docker Optimization for Production Projects (With Real CI/CD Examples)
Ship Leaner and Faster: Docker Optimization for Production Projects (With Real CI/CD Examples) TL;DR: Most production Docker images… ...
Here’s the GitHub Actions workflow I now use for every Python open source project I maintain. It handles semantic versioning, changelog generation, GitHub release creation, and PyPI publishing — all from a single `git push`.
No more manual `bumpversion`. No more “oops, I forgot to tag.” No more terminal anxiety before a release.
How I Finally Nailed My React Project Setup in 2026 (And How You Can Too)
TL;DR: Setting up a React project in 2026 isn’t about just running npx create-react-app anymore. This guide walks… ...
—
The Problem: Why Manual Releases Suck
If you maintain a Python package on GitHub, you’ve felt this pain:
- You fix a bug, merge the PR, and then realize you need to manually update `__version__.py`, commit again, push a tag, write release notes, and run `twine upload`.
- Someone opens an issue asking “when is this getting released?” — and you don’t have a good answer because the process is friction.
- You accidentally push a `v1.0.1` tag when the fix should have been a minor bump. Now your versioning is out of whack.
I was spending 15–20 minutes per release. For a project with weekly releases, that’s over 13 hours a year. Time I could spend writing actual code or — you know — sleeping.
So I built a pipeline that does all of this in under 2 minutes. Here’s how.
—
The Toolchain: What You’ll Need
You don’t need a custom solution. The open source ecosystem has everything:
| Tool | Purpose |
|---|---|
| `python-semantic-release` | Automates version bumping, changelog generation, and tagging based on commit messages |
| GitHub Actions | Runs the automation on every push to `main` |
| PyPI trusted publishing | Securely pushes your package without storing API tokens |
| Conventional Commits | The commit message format that drives the version logic |
That’s it. Four tools. Zero manual steps.
We use `python-semantic-release` (v9.x) because it handles the full lifecycle: parse commits, determine the next version, update `__version__.py`, generate a changelog, create a Git tag, and push to PyPI. All driven by Conventional Commits (`fix:`, `feat:`, `BREAKING CHANGE:`).
—
The Exact GitHub Actions Workflow
Here’s the full YAML. Drop this into `.github/workflows/release.yml`:
yaml
name: Release
on:
push:
branches:
- main
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
concurrency: release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install python-semantic-release
- name: Semantic release
id: semantic
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
semantic-release publish
echo "version=$(semantic-release version --print)" >> $GITHUB_OUTPUT
- name: Publish to PyPI
if: steps.semantic.outputs.version != ''
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
Key details you can’t skip:
- `fetch-depth: 0` — Without this, `python-semantic-release` can’t see your full Git history and will fail to determine the correct version bump.
- `concurrency: release` — Prevents two pushes from racing each other and creating duplicate tags. Trust me, you want this.
- `GH_TOKEN` — The default `GITHUB_TOKEN` works fine, but it must have `contents: write` permission (set in the workflow or repo settings).
- `skip-existing: true` — Prevents the PyPI publish step from failing if the version already exists (handy for re-runs).
—
Setting Up PyPI Trusted Publishing
This is the part most people mess up. Don’t store PyPI API tokens in GitHub secrets. Use trusted publishing instead.
Here’s how:
- Go to your project on PyPI.
- Navigate to “Manage” > “Publishing”.
- Add a new trusted publisher with:
- Workflow name: `release.yml`
- Environment: leave blank (or use a GitHub environment if you want extra protection)
- Repository owner: your GitHub username or org
- Repository name: your repo name
That’s it. The `pypa/gh-action-pypi-publish` action will automatically authenticate using the `id-token: write` permission in the workflow.
No tokens. No secrets. No “my PyPI credentials leaked” panic.
—
Conventional Commits: The Secret Sauce
This whole pipeline depends on Conventional Commits. If your commit messages are random, the automation breaks.
Here’s the mapping `python-semantic-release` uses:
| Commit prefix | Version bump |
|---|---|
| `fix:` | Patch (1.0.0 → 1.0.1) |
| `feat:` | Minor (1.0.0 → 1.1.0) |
| `BREAKING CHANGE:` or `feat!:` | Major (1.0.0 → 2.0.0) |
| `docs:`, `chore:`, `refactor:`, `test:` | No release (skipped) |
Real example from my commit history:
feat: add async support to the data pipeline
fix: handle edge case where input is None
docs: update README with new configuration example
BREAKING CHANGE: rename `process()` to `run()` and change return signature
When these commits land on `main`, the workflow:
- Parses all commits since the last tag.
- Determines the next version — `v2.0.0` in this case.
- Updates `__version__.py` or `pyproject.toml`.
- Generates a changelog entry grouping fixes, features, and breaking changes.
- Creates a Git tag and GitHub release.
- Pushes to PyPI.
All without me touching a keyboard.
—
Configuring `pyproject.toml` for `python-semantic-release`
Add this to your `pyproject.toml`:
toml
[tool.semantic_release]
version_toml = "pyproject.toml:project.version"
version_variable = "src/my_package/__init__.py:__version__"
commit_message = "chore(release): {version}\n\nAutomatically generated by semantic-release"
tag_format = "v{version}"
build_command = "python -m build"
What each field does:
- `version_toml` — Tells the tool where to update the version in `pyproject.toml` (the `project.version` field).
- `version_variable` — Updates the `__version__` string in your package’s `__init__.py`. Keeps `import my_package; my_package.__version__` accurate.
- `commit_message` — The message used for the automated release commit. I include `{version}` for clarity.
- `tag_format` — I use `v{version}` so tags look like `v2.0.0`. Match this to your project’s convention.
- `build_command` — Runs `python -m build` to generate `sdist` and `wheel` before publishing.
—
What Happens When Something Goes Wrong?
Automation is great until it isn’t. Here are the three failure modes I’ve hit in production:
1. A non-release commit pushes and triggers the workflow.
The workflow runs, `python-semantic-release` sees no `fix:`, `feat:`, or `BREAKING CHANGE:` commits, and exits without doing anything. No tag, no release, no PyPI publish. Safe.
2. Two PRs merge in rapid succession.
The `concurrency: release` setting queues the second run. It waits for the first to finish, then runs with the updated history. Safe.
3. The commit history is shallow (missing `fetch-depth: 0`).
`python