Versioning — PEP 440, SemVer 2.0, and Build Channels
This workspace runs two Python packages (muse, musehub) and one
independent VCS release-tracking system (muse release, backed by
muse/core/semver.py). Each is governed by a different, real, external
spec — and the two specs use genuinely incompatible canonical string
formats for the same concept. This is not an inconsistency to eliminate;
it's a fact about the tooling that needs a documented mapping so nobody
has to rediscover it.
Why two formats — the tooling reality
pyproject.toml's version field is consumed by pip, build, and
twine. Those tools implement PEP 440.
PEP 440's reference parser (the packaging library) is lenient on input
but always normalizes to one canonical string:
>>> from packaging.version import Version
>>> for v in ['0.2.0rc16', '0.2.0-rc16', '0.2.0-rc.16', 'v0.2.0rc16', '0.2.0.rc16']:
... print(v, '->', str(Version(v)))
0.2.0rc16 -> 0.2.0rc16
0.2.0-rc16 -> 0.2.0rc16
0.2.0-rc.16 -> 0.2.0rc16
v0.2.0rc16 -> 0.2.0rc16
0.2.0.rc16 -> 0.2.0rc16
Every spelling above is accepted, but the canonical form pip will
display, store, and expect is always 0.2.0rc16 — no hyphen, no dot
before the number, no v.
muse release add <tag> is consumed by muse/core/semver.py, which
implements SemVer 2.0 — a real requirement, not a
style choice, because it needs to answer "is this a breaking change?" and
"what channel is this?" the way SemVer defines those questions. Its
parser is strict, and it requires the hyphen SemVer's grammar mandates:
>>> from muse.core.semver import parse_semver
>>> parse_semver('0.2.0rc16')
ERROR: Version '0.2.0rc16' is not valid semver (expected vMAJOR.MINOR.PATCH[-pre][+build]).
>>> parse_semver('v0.2.0-rc.16')
{'major': 0, 'minor': 2, 'patch': 0, 'pre': 'rc.16', 'build': ''}
These two canonical strings are mutually exclusive. No single spelling is simultaneously PEP-440-canonical and SemVer-canonical. The fix isn't picking one — it's using each spec's canonical form in its own context, and never guessing at the boundary.
PEP 440 vs SemVer 2.0 — side by side
| PEP 440 | SemVer 2.0 | |
|---|---|---|
| Governs | pyproject.toml version, PyPI, pip, build, twine |
muse release add, muse tags, muse/core/semver.py |
| Canonical pre-release syntax | X.Y.Z{a\|b\|rc}N — no separator |
X.Y.Z-{identifier} — hyphen required |
| Release-candidate example | 0.2.0rc16 |
0.2.0-rc.16 |
v prefix |
Accepted as input, always stripped in canonical form | Not part of the formal grammar, but a near-universal tag convention (git, k8s, npm, cargo) — muse's parser explicitly accepts it (v?) |
| Reserved pre-release stage names | a (alpha), b (beta), rc (release candidate) — exactly three, with built-in precedence |
None reserved — any string is a valid identifier; precedence is computed by comparing identifiers, not by meaning |
| Developmental/nightly concept | .devN — a fourth, formally reserved segment, sorts before alpha |
No reserved concept — nightly is just a free-text identifier you choose |
| Precedence source | Built into the spec (dev < a < b < rc < final) | Purely lexical/numeric comparison of whatever identifiers you wrote |
A real gotcha this comparison surfaces
Because SemVer doesn't reserve stage names, precedence is only as correct as the words you pick happen to sort. Compare:
>>> sorted(['alpha', 'beta', 'nightly', 'rc'])
['alpha', 'beta', 'nightly', 'rc'] # alphabetical
Alphabetically, nightly sorts between beta and rc. But the real
maturity order is nightly < alpha < beta < rc — nightly is the rawest,
most continuous channel, earlier than alpha. If anything ever did a plain
lexical sort on these four channel words, it would rank a nightly build
as more mature than a beta. This is exactly the kind of mistake PEP 440
prevents by reserving dev as a formally-earlier-than-everything
segment. muse/core/semver.py does not currently do precedence
comparison at all — it only parses and infers channel by prefix match
— so this isn't a live bug today, but it's a trap waiting for whoever
adds sorting later. Worth remembering if that day comes.
The four build channels
Three of these are industry convention (not mandated by either spec);
one (dev/nightly) is formally reserved by PEP 440. All four are
recognized by muse/core/semver.py's ReleaseChannel type.
Nightly — earliest, continuous
Cut automatically (or manually, frequently) while features are still actively landing. Shared for visibility, not for validation — nobody should conclude anything about stability from a nightly. This is what churning builds and sharing them with stakeholders while still adding features actually is, precisely.
- PEP 440:
0.2.0.dev1 - SemVer:
v0.2.0-nightly.1
Alpha — feature-incomplete
Some planned functionality doesn't exist yet. Tested by the core team or a very small trusted circle. Answers: does the fundamental approach work at all?
- PEP 440:
0.2.0a1 - SemVer:
v0.2.0-alpha.1
Beta — feature-complete, hardening
Everything planned for the release exists now; no new functionality gets added during beta. Tested by a wider audience specifically to surface bugs that don't show up inside the dev team's own environment. Answers: does the complete thing hold up under real usage diversity?
- PEP 440:
0.2.0b1 - SemVer:
v0.2.0-beta.1
Release Candidate — frozen, final verification
Feature-frozen and believed bug-free. The only thing that happens between one rc and the next is fixing a blocking bug — never adding anything. Answers: is this exact build good enough to become the stable release?
- PEP 440:
0.2.0rc1 - SemVer:
v0.2.0-rc.1
Stable
- PEP 440:
0.2.0 - SemVer:
v0.2.0
The pivot to nightly
Historically this workspace has cut sequentially-numbered rc builds
(0.2.0rc15, 0.2.0rc16, ...) for what is actually continuous feature
delivery shared with stakeholders as it lands — new phases and
capabilities landing between one "rc" number and the next. That's not
what a release candidate is: an rc, by definition, freezes features and
only fixes blocking bugs between numbers.
What was actually happening matches nightly exactly — a channel this
codebase's own ReleaseChannel type already defines
(Literal["stable", "beta", "alpha", "nightly"]) but has never used in
practice. Going forward:
- Ongoing work shared with stakeholders before a real version bump uses
the nightly channel and format (
0.2.0.dev1/v0.2.0-nightly.1), notrc. rcis reserved for the moment features are actually frozen and the only remaining question is "is this specific build shippable" — typically a short window right before a MAJOR.MINOR.PATCH bump, not the default day-to-day state.
Nightly numbering — decided: sequential auto-increment, reset to 1
Considered three options: sequential auto-increment (dev1, dev2, ...),
plain date (dev20260704), and a literal Unix timestamp
(dev1751654400). Decided: sequential.
- Date-based fails under this workspace's actual cadence. A single work session has produced five separate, meaningful CLI publishes in one calendar day. Date-based numbering collides every time that happens — every publish that day would share the identical version string. Sequential numbering has no such ceiling; every distinct publish gets its own number regardless of how many happen per day.
- A Unix timestamp solves the collision problem but at a cost, for no
offsetting benefit here. It's provably collision-free in this
single-agent, sequential-command workflow, but it's unreadable
(
dev1751654400can't be said or eyeballed) and, like the date option, still can't answer "how many nightlies have there been" the way a sequential count can. - The "when was this built" information isn't lost by going
sequential — every commit already carries
committed_atwith second-level precision (muse read --json,muse log). A date or timestamp embedded in the version number would just be a coarser, redundant copy of data already recorded precisely elsewhere. - If concurrent publishers (multiple people or CI runners cutting nightlies at the same time, needing no shared counter state) ever becomes real, this decision should be revisited — that's the one scenario sequential doesn't handle for free. Not the case today.
Counter starts at dev1, not continuing from rc16. The prior
rc1–rc16 sequence was nightly-flavored in practice (continuous
feature delivery, not feature-frozen bug-fixing) but was never actually
labeled or tracked as nightly. Starting at dev1 marks the first real
release cut under the corrected convention cleanly, rather than implying
16 prior builds existed under a name they never had.
Decision table — which format, when
| I am about to... | Use this format | Example |
|---|---|---|
Bump pyproject.toml's version (muse or musehub) |
PEP 440 canonical | 0.2.0.dev1, 0.2.0rc1, 0.2.0 |
Run muse release add <tag> or create a muse/git-style release tag |
SemVer 2.0 canonical, with v |
v0.2.0-nightly.1, v0.2.0-rc.1, v0.2.0 |
| Write a version in prose, a commit message, or a chat message | Either — say what you mean: "pre-release" for the category, "nightly"/"alpha"/"beta"/"release candidate" for the specific stage | "cutting a nightly", "this rc" |
Pass --version to deploy/smoke_muse.sh |
PEP 440 canonical (matches what's in pyproject.toml) |
--version 0.2.0rc13 |
Reference the tarball filename (muse-<version>.tar.gz) |
PEP 440 canonical (this is literally read from pyproject.toml) |
muse-0.2.0rc16.tar.gz |
Translating between the two for the same release
Given a release's stage and number, the two canonical strings for
the same release are always related by this fixed, mechanical rule:
strip v, and join the stage letter/word directly to the number with no
separator (PEP 440) vs. keep the hyphen and a dot before the number
(SemVer).
| Release | PEP 440 (pyproject.toml) |
SemVer 2.0 (muse tag) |
|---|---|---|
| 1st nightly toward 0.2.0 | 0.2.0.dev1 |
v0.2.0-nightly.1 |
| 1st alpha toward 0.2.0 | 0.2.0a1 |
v0.2.0-alpha.1 |
| 1st beta toward 0.2.0 | 0.2.0b1 |
v0.2.0-beta.1 |
| 1st release candidate toward 0.2.0 | 0.2.0rc1 |
v0.2.0-rc.1 |
| Stable 0.2.0 | 0.2.0 |
v0.2.0 |
Known gap this doc surfaced
muse/core/semver.py::semver_channel() currently checks for alpha,
beta, and nightly prefixes but has no rc branch — a real
release-candidate tag (v0.2.0-rc.1) falls through every check and gets
classified as "stable". Any release-channel logic that branches on this
today cannot currently distinguish a release candidate from an actual
stable release. Tracked for a fix alongside adopting this doc's
conventions.
Terminology — you don't need to ditch "release candidate"
"Pre-release" and "release candidate" are not competing words for the same thing — they're two levels of one hierarchy:
- "Pre-release" is the category — SemVer's formal name for
anything with a
-suffix; PEP 440's formal name for the{dev, a, b, rc}segment group. - "Release candidate" / "rc" is one specific, named stage within
that category, alongside alpha, beta, and (now) nightly — and both
specs explicitly define
rcas a first-class stage name. It was never deprecated by either spec.
"This is a pre-release" and "specifically, it's a release candidate" are both correct, simultaneously, under both specs.
Related
- deploy.md — the release runbook that puts this into practice