The first release of [Data Preprocessors](/projects/Data Preprocessors) took me about forty minutes. Bump the version in pyproject.toml. Remember to bump it in __init__.py too. Write the changelog. Build. Realise the build included a stale artefact from last time. Clean. Rebuild. Upload. Tag the commit. Push the tag.

The second release took forty minutes as well, minus the parts I forgot.

Every one of those steps is a step someone will skip when they are shipping a fix at 11pm. So the release became one command:

git tag v1.2.3 && git push --tags

Everything else is CI’s problem.

The version lives in exactly one place

Two sources of truth for a version number is one too many. The tag is the source; the package derives from it.

[tool.poetry]
name = "Data Preprocessors"
version = "0.0.0"          # placeholder - CI overwrites from the tag
- name: Derive version from tag
  run: |
    VERSION="${GITHUB_REF_NAME#v}"
    poetry version "$VERSION"
    echo "VERSION=$VERSION" >> "$GITHUB_ENV"

And in the package itself, read what was installed rather than hard-coding a second copy:

from importlib.metadata import version, PackageNotFoundError

try:
    __version__ = version("Data Preprocessors")
except PackageNotFoundError:      # running from a source checkout
    __version__ = "0.0.0+local"

Now pip show, __version__ and the git tag cannot disagree, because there is only one number and two views of it.

The changelog is a build artefact

Hand-written changelogs are accurate for about three releases. Then they lag, then they are wrong, then nobody reads them - which is worse than not having one, because people trusted it.

Conventional commit messages make the changelog derivable:

feat(tokenizer): add Bangla sentence splitter
fix(cleaner): preserve unicode combining marks
feat!: drop Python 3.8 support

CI groups commits since the previous tag into Features / Fixes / Breaking Changes and attaches the result to the GitHub release. The ! marker flags a breaking change, which is also the signal that the tag should be a major bump - and CI can check that, failing the release if a breaking commit is going out under a patch tag.

That check is the part that matters. Semantic versioning that relies on the releaser’s judgement at 11pm is semantic versioning in name only.

Reproducible builds, or you are just hoping

poetry.lock pins the fully resolved dependency graph, not just the direct constraints. CI installs from the lockfile, which means the wheel built in the pipeline is the wheel built on my laptop.

Without a lockfile, a transitive dependency releasing a broken patch version turns your release into a mystery: same code, same command, different result. With one, the resolution is a reviewed, committed artefact and dependency updates are a pull request you can read.

The pipeline

on:
  push:
    tags: ['v*']

jobs:
  test:
    strategy:
      matrix:
        python-version: ['3.9', '3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pipx install poetry && poetry install --sync
      - run: poetry run pytest -q
      - run: poetry run ruff check .

  release:
    needs: test
    permissions:
      id-token: write        # trusted publishing - no long-lived PyPI token
      contents: write
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }    # full history - the changelog needs it
      - run: poetry version "${GITHUB_REF_NAME#v}"
      - run: poetry build
      - uses: pypa/gh-action-pypi-publish@release/v1
      - run: gh release create "$GITHUB_REF_NAME" --generate-notes dist/*

Four details worth stealing:

needs: test. The release job cannot start until the full matrix passes. A broken release is not possible unless the tests are wrong.

fetch-depth: 0. Actions checks out a shallow clone by default, and a shallow clone has no history to build a changelog from. This one costs an afternoon exactly once.

Trusted publishing. OIDC instead of a long-lived PyPI token in repository secrets. Nothing to rotate, nothing to leak.

The same artefacts everywhere. Build once; publish that build to PyPI, attach it to the GitHub release, push the Docker image. Never rebuild per target - that is how “it works from PyPI but not from the image” happens.

Why this belongs on a backend engineer’s résumé

A library forces the discipline earlier than a service does. Once someone else can pip install your code, a breaking change stops being a refactor and becomes an event with consequences for people you will never meet.

But the machinery is identical to what a deployment pipeline needs: one source of truth for the version, artefacts built once and promoted, changes derivable from history, and a release procedure with no step a tired human can skip.

I now build the same thing for multi-tenant SaaS deployments. A small open-source package was the cheapest possible place to learn it.