Skip to content

Releasing

A PyPI version number can never be reused, and a release cannot be edited after the fact — only yanked. Everything below exists to make the irreversible step the last one, and to make it boring.

One-time setup

PyPI publishing uses Trusted Publishing, so there is no API token in the repository and nothing to rotate or leak. It has to be registered on PyPI first.

For the first release, the project does not exist on PyPI yet, so register a pending publisher: PyPI → Your account → Publishing → Add a pending publisher.

Field Value
PyPI project name qmlkit
Owner Ziadt160
Repository qmlkit
Workflow release.yml
Environment pypi

Repeat the same on TestPyPI with environment testpypi.

Then create both environments in the GitHub repository (Settings → Environments): testpypi and pypi. Adding a required reviewer to pypi is worth it — it turns the final upload into something you approve by hand.

Cutting a release

  1. Update the changelog. Move ## [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD. Every entry should say what changed and why, not just what was added.

  2. Set the version in two places, which must agree or the workflow refuses: pyproject.toml (project.version) and src/qmlkit/__init__.py (__version__). Those are the only two - verify_install.py reads the packaging metadata and checks the module constant against it, rather than carrying a third copy that has to be remembered.

  3. Run the whole thing locally, in both interpreters — no single one can import all four backends:

ruff check src tests && ruff format --check src tests && mypy && pytest
C:/Users/pc/miniconda3/envs/spinq_env/python.exe -m pytest      # SpinQit, Python 3.10
  1. Verify the built artifact, not the source tree. An editable install imports out of src/ and keeps working even if a module never made it into the wheel:
python -m build && twine check dist/*
python -m venv /tmp/clean && /tmp/clean/bin/pip install dist/qmlkit-*.whl && /tmp/clean/bin/python scripts/verify_install.py

On Windows the venv puts them in Scripts rather than bin:

python -m venv $env:TEMP\clean
& "$env:TEMP\clean\Scripts\python.exe" -m pip install (Get-Item dist\qmlkit-*.whl)
& "$env:TEMP\clean\Scripts\python.exe" scripts\verify_install.py
  1. Push, then tag.
git push origin main
git tag v0.2.0
git push origin v0.2.0

The tag must match the packaged version exactly; the workflow checks and refuses otherwise, because a tag that disagrees with pyproject.toml would publish a version nobody asked for.

This step used to be the one that was easy to get wrong. qmlkit was developed as the qmlkit/ subdirectory of a lecture repository and published as a git subtree split of it, so release.yml existed only in the published copy and tagging upstream triggered nothing — while the split itself published whatever branch it was given, which twice nearly shipped a release missing half its fixes. The library moved to its own repository on 2026-09-13 and none of that applies any more.

  1. Watch the workflow. It runs the suite again on the tagged commit, builds, re-verifies the wheel in a clean environment, publishes to TestPyPI, and only then publishes to PyPI.

  2. Install it the way a stranger would, from a machine that has never seen the source:

pip install qmlkit && python -c "import qmlkit as qk; print(qk.__version__, qk.backend_report())"

If something goes wrong

  • Bad metadata or a broken README on TestPyPI — fix it, bump to the next patch version, tag again. Do not try to reuse the number.
  • Already published to PyPI and it is brokenyank the release rather than deleting it. Yanking leaves existing pins working while stopping new installs from resolving to it.
  • The tag does not match pyproject.toml — the workflow fails before publishing anything. Delete the tag, fix the version, tag again:
git push origin :refs/tags/v0.2.0   # delete it on the remote
git tag -d v0.2.0                   # and locally
  • Nothing happened when you pushed the tag — check it actually arrived with git ls-remote --tags origin, and that Actions is healthy. On 2026-09-13 two pushes to main created no workflow runs at all during a GitHub incident, and workflow_dispatch returned HTTP 500 while queueing the job anyway. A tag pushed into that is a release that may half-complete, and a half-completed release burns the version number on TestPyPI. Wait for Actions to be draining normally.

  • Never force-push over a published tag. It must keep pointing at the commit that produced the artifact on PyPI, or the provenance is a lie.