Project Setup

Shipping Pre-Releases and Release Candidates of a Python CLI

Publish betas and release candidates of a Python CLI safely: PEP 440 pre-release versions, how pip, pipx and uv treat them, tag-driven CI, and tester install commands.

Updated

A big CLI release — a new command structure, a changed config format, a major dependency upgrade — benefits from a round of real-world use before everyone gets it. Pre-releases make that possible: you publish 2.0.0rc1 to the same PyPI project, the people who opt in try it, and everybody else keeps getting 1.9.x because installers skip pre-releases unless asked. The mechanism is simple, but it has sharp edges: version strings that are not valid PEP 440, CI that publishes a beta as if it were stable, testers who cannot work out how to install it, and update notifiers that nag stable users about betas. This guide covers the whole loop for a Python CLI. It belongs to the versioning and changelogs topic, next to semantic versioning policy for CLI tools.

Prerequisites

How pre-release versions sort

PEP 440 defines the pre-release segments Python packaging understands — aN (alpha), bN (beta), rcN (release candidate) — plus .devN for development snapshots and .postN for post-releases. They sort in a fixed order, and every installer uses that order:

How PEP 440 versions sort The ordering of development, alpha, beta, release candidate, final and post releases for one version number. How PEP 440 versions sort Dev snapshot 2.0.0.dev3 Alpha incomplete 2.0.0a1 Beta feature-complete 2.0.0b2 RC ship unless broken 2.0.0rc1 Final stable 2.0.0 Post stable fix 2.0.0.post1 lowest to highest; only the first four are pre-releases A final release always supersedes its candidates; .post releases count as stable.
2.0.0.dev3 < 2.0.0a1 < 2.0.0b2 < 2.0.0rc1 < 2.0.0 < 2.0.0.post1

Two consequences matter. A pre-release always sorts before its final release, so 2.0.0 supersedes every 2.0.0rcN automatically. And .post releases are not pre-releases — they count as stable and are installed by default, which makes them the right tool for a packaging-only fix and the wrong tool for anything experimental.

Write versions in the normalised form. 2.0.0-rc.1 (SemVer style) and v2.0.0rc1 are accepted by PyPI but normalised to 2.0.0rc1, which confuses scripts that compare strings; tag your repository with v2.0.0rc1 if you like, but put 2.0.0rc1 in metadata.

Who gets a pre-release?

Installers skip pre-releases unless one of three things is true: the user asks for them, the user's requirement names a pre-release explicitly, or no stable version satisfies the requirement at all.

When installers pick a pre-release How pip, pipx and uv decide whether to install a pre-release version of a command line tool. When installers pick a pre-release Request Result pipx install mytool newest stable (1.9.3) pipx install "mytool==2.0.0rc1" that pre-release uv tool install mytool --prerelease allow newest, rc included Only pre-releases satisfy the spec the pre-release Skipping pre-releases by default is what makes publishing them to PyPI safe.

That default is what makes publishing pre-releases to the main project safe. Testers opt in explicitly:

pipx install --pip-args=--pre mytool                 # newest, including pre-releases
pipx install "mytool==2.0.0rc1"                      # one exact pre-release
uv tool install mytool --prerelease allow            # newest, including pre-releases
uv tool install "mytool==2.0.0rc1"
uvx --prerelease allow mytool --version              # try it without installing

Pinning an exact version is the most predictable instruction to give testers: everyone reports against the same build, and going back is pipx install --force "mytool==1.9.3".

The recipe

1. Cut the pre-release

With uv managing the version:

uv version --bump major --bump rc        # 1.9.3 -> 2.0.0rc1
# ... later ...
uv version --bump rc                     # 2.0.0rc1 -> 2.0.0rc2
uv version --bump stable                 # 2.0.0rc2 -> 2.0.0

With git-derived versions, the tag is the version: git tag v2.0.0rc1 && git push --tags.

2. Let CI recognise it

The same tag-triggered workflow can publish pre-releases and final releases; it only needs to know which is which, so the GitHub release is marked correctly and stable-only steps — the Homebrew formula bump, the "latest" documentation deploy — are skipped:

# .github/workflows/release.yml (excerpt)
on:
  push:
    tags: ["v*"]

jobs:
  classify:
    runs-on: ubuntu-latest
    outputs:
      prerelease: ${{ steps.v.outputs.prerelease }}
    steps:
      - id: v
        run: |
          version="${GITHUB_REF_NAME#v}"
          if [[ "$version" =~ (a|b|rc|\.dev)[0-9]+$ ]]; then
            echo "prerelease=true" >> "$GITHUB_OUTPUT"
          else
            echo "prerelease=false" >> "$GITHUB_OUTPUT"
          fi

  github-release:
    needs: [classify, publish]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - run: >
          gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes
          ${{ needs.classify.outputs.prerelease == 'true' && '--prerelease' || '' }}
        env:
          GH_TOKEN: ${{ github.token }}

  homebrew:
    needs: [classify, publish]
    if: needs.classify.outputs.prerelease == 'false'
    runs-on: ubuntu-latest
    steps:
      - run: echo "bump the tap formula here"

Doing the classification in Python, with the same library installers use, avoids regex mistakes:

# scripts/is_prerelease.py
import sys

from packaging.version import Version

version = Version(sys.argv[1].removeprefix("v"))
print("true" if version.is_prerelease else "false")

Version.is_prerelease is true for alpha, beta, rc and dev versions, and false for post-releases — exactly the rule installers apply.

3. Tell testers what to do

A short, pinned post in the release notes or issue tracker beats a long explanation:

mytool 2.0.0rc1 is out for testing.
  Install:  uv tool install "mytool==2.0.0rc1"   (or pipx install "mytool==2.0.0rc1")
  Go back:  uv tool install "mytool==1.9.3" --force
  Changes:  https://github.com/acme/mytool/releases/tag/v2.0.0rc1
  Report:   https://github.com/acme/mytool/issues/new?labels=2.0-rc
A tester tries the release candidate Terminal session installing a pinned release candidate, seeing the pre-release warning, and rolling back to the stable version. A tester tries the release candidate bash $ uv tool install "mytool==2.0.0rc1" Installed 1 executable: mytool $ mytool --version mytool 2.0.0rc1 note: release candidate — report problems at github.com/acme/mytool/issues $ uv tool install "mytool==1.9.3" --force Installed 1 executable: mytool Exact pins make testing and rolling back equally easy.

Running a pre-release cycle

The mechanics are the easy part; a pre-release only pays off if people actually use it and you learn something. A cycle that works for most CLI projects looks like this:

  1. Freeze the scope. Decide what the release contains and merge it. Everything else waits for the next version.
  2. Publish b1 to a small group — your own team, a few power users — and use it yourself for daily work. Most problems surface in the first two days of real use.
  3. Publish rc1 when you would be happy to ship it. Announce it more widely: the issue tracker, the project's chat, the README. From here, only fixes go in.
  4. Set a date. "2.0.0 ships on the 14th unless a blocking issue is reported" gives testers a reason to try it now and gives you a reason to stop polishing.
  5. Promote without rebuilding behaviour. The final release should be the last candidate's code with only the version changed. If anything else changed, that is another candidate.

Two signals tell you the candidate is ready: the last few days produced no new bug reports from active testers, and the upgrade path from the current stable version has been exercised — config files migrated, old flags warning as expected, local state such as a SQLite database migrated without loss. A major version with breaking changes deserves a short upgrade guide published with the first candidate, not after the final release, because testers are exactly the people who will read it.

If nobody tests the pre-releases, consider why before skipping them next time: the install instructions may be too hard, the announcement too quiet, or the tool used mostly in CI where pre-releases are never picked up. An opt-in environment variable or config flag that makes your own CI jobs run the newest candidate is a cheap way to get continuous real-world coverage.

UX considerations

  • Make the version visible. mytool --version should print 2.0.0rc1 exactly, and bug report templates should ask for it. Exposing version info and build metadata shows how.
  • Warn on first run of a pre-release. A single stderr line — "you are running a release candidate; report problems at …" — sets expectations and gives testers the link they need.
  • Do not announce pre-releases to stable users. An update check must skip pre-releases for users on a stable version, and offer them to users already on one; checking PyPI for a newer version implements that policy.
  • Keep the release candidate honest. An rc should be what you intend to ship. New features after rc1 deserve a new beta, not rc2, so testers know what they are testing.
  • Changelog the pre-releases under the final version's heading, so the final release notes summarise everything without a separate page per candidate.

Testing the behaviour

The classification script and the version ordering are worth a couple of tests, because mistakes here publish a beta as stable:

# tests/test_prerelease.py
import subprocess
import sys

import pytest
from packaging.version import Version


@pytest.mark.parametrize("tag,expected", [
    ("v2.0.0rc1", "true"), ("v2.0.0b3", "true"), ("v2.0.0a1", "true"),
    ("v2.0.0.dev4", "true"), ("v2.0.0", "false"), ("v2.0.0.post1", "false"),
])
def test_classification(tag, expected):
    out = subprocess.run([sys.executable, "scripts/is_prerelease.py", tag],
                         capture_output=True, text=True, check=True).stdout.strip()
    assert out == expected


def test_final_release_supersedes_candidates():
    assert Version("2.0.0") > Version("2.0.0rc9") > Version("2.0.0b1")


def test_semver_style_normalises():
    assert str(Version("2.0.0-rc.1")) == "2.0.0rc1"

For a full rehearsal, publish a pre-release to TestPyPI and install it with uv tool install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ --prerelease allow mytool in a clean environment — the same path testers will take, minus the real index.

Conclusion

Pre-releases let a CLI's riskiest changes meet real users before everyone gets them, and installers' default of skipping them makes publishing to the main PyPI project safe. Use normalised PEP 440 versions, let CI classify tags with packaging and mark GitHub releases accordingly, skip stable-only distribution steps, give testers exact install-and-rollback commands, and make sure update notices never push betas at stable users.

Frequently asked questions

Should pre-releases go to TestPyPI instead?

TestPyPI is for rehearsing the publishing process; it is periodically cleaned, lacks most dependencies, and testers would need extra index flags. Real pre-releases belong on PyPI, where installers already know to skip them by default.

Do pre-releases of my dependencies get installed for my users?

Only if your requirements name a pre-release or no stable version satisfies them. If your CLI needs a dependency's beta, publish your own pre-release that requires it, and wait for the dependency's final release before your stable one.

What about nightly builds?

Use .devN versions — for example a CI job that publishes 2.1.0.dev20261002 from main every night. They sort before alphas, are skipped by default, and make it obvious in bug reports that someone is on a snapshot. Consider a separate index for them if the volume would clutter PyPI's release history.

Can I delete a broken pre-release?

You can yank it, which hides it from resolvers while keeping exact pins working, or delete it — but the version number can never be reused. Publish the next candidate instead; numbers are cheap.