I Automated My Open Source Python Releases with GitHub Actions — Here’s the Exact Semantic Versioning Workflow

1 comment
(GitHub and Open Source) - Manual releases are a time sink. I built a fully automated GitHub Actions pipeline that handles semantic versioning, changelog generation, and PyPI publishing for my open source Python projects. Here's the exact workflow — no magic, just YAML.

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.

RESTful API Design: Hard-Won Lessons from Real Developer Projects

RESTful API Design: Hard-Won Lessons from Real Developer Projects

Are you building an API for a new project? Or struggling with a chaotic legacy API? Don’t worry—this… ...

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.

Why Top CTOs Hire Vietnamese Developers: The 2025 Offshoring Playbook

Why Top CTOs Hire Vietnamese Developers: The 2025 Offshoring Playbook

TL;DR – Why This Matters Vietnam produces 57,000+ IT graduates yearly. Hourly rates range $18–$35 for senior engineers.… ...

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:

  1. Go to your project on PyPI.
  2. Navigate to “Manage” > “Publishing”.
  3. 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:

  1. Parses all commits since the last tag.
  2. Determines the next version — `v2.0.0` in this case.
  3. Updates `__version__.py` or `pyproject.toml`.
  4. Generates a changelog entry grouping fixes, features, and breaking changes.
  5. Creates a Git tag and GitHub release.
  6. 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

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.