# 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](https://peps.python.org/pep-0440/). PEP 440's reference parser (the `packaging` library) is lenient on input but always normalizes to one canonical string: ```python >>> 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 ` is consumed by `muse/core/semver.py`**, which implements [SemVer 2.0](https://semver.org/) — 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: ```python >>> 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: ```python >>> 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.dev17` - SemVer: `v0.2.0-nightly.17` ### 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.dev17` / `v0.2.0-nightly.17`), not `rc`. - `rc` is 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 counters increment by 1 per cut, same muscle memory as the current `rc` bump workflow (`rc15` → `rc16` becomes `dev17` → `dev18`, or reset to `dev1` at the start of a new dev cycle — pick one and note it in the release commit message; either is spec-compliant). Date-based numbering (`dev20260704`) is also valid PEP 440 (the segment just needs leading digits) if daily cadence matters more than sequence — worth deciding explicitly rather than drifting into it. ## 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.dev17`, `0.2.0rc1`, `0.2.0` | | Run `muse release add ` or create a muse/git-style release tag | SemVer 2.0 canonical, with `v` | `v0.2.0-nightly.17`, `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-.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) | |---|---|---| | 17th nightly toward 0.2.0 | `0.2.0.dev17` | `v0.2.0-nightly.17` | | 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 `rc` as 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](deploy.md) — the release runbook that puts this into practice