# Versioning Canonical reference for how version numbers work in this workspace: how Muse computes SemVer bumps from code structure, which spec governs which file, the four build channels, and the exact format for every context. If you're unsure which string to write, the cheat sheet below answers it in one lookup; everything after it is the reasoning, verified against the actual parsers and the actual source, not assumed. ## Cheat sheet — zero ambiguity | Context | Spec | Format | Example | |---|---|---|---| | `pyproject.toml` `version` (muse, musehub) | [PEP 440](https://peps.python.org/pep-0440/) canonical | `X.Y.Z`, `X.Y.ZrcN`, `X.Y.Z.devN` | `0.2.0rc16`, `0.2.0.dev1` | | `muse release add `, muse/git-style tags | [SemVer 2.0](https://semver.org/) canonical | `vX.Y.Z`, `vX.Y.Z-rc.N`, `vX.Y.Z-nightly.N` | `v0.2.0`, `v0.2.0-rc.1` | | `deploy/smoke_muse.sh --version`, tarball filename | PEP 440 canonical (read straight from `pyproject.toml`) | same as `pyproject.toml` | `muse-0.2.0rc16.tar.gz` | | `.muse/stability.toml` symbol declarations | Muse-internal (not a version string at all) | symbol addresses / fnmatch patterns | `muse/core/store.py::CommitRecord` | **Never** write a hyphen or a `v` into `pyproject.toml`. **Never** write a bare `0.2.0rc16` into a muse tag. These are not stylistic preferences — each is what the consuming tool (`pip`/`build`/`twine` vs. `muse/core/semver.py`) actually requires; see [Why two formats](#why-two-canonical-formats-both-load-bearing) below for the verified proof. **Channel maturity order:** `nightly < alpha < beta < rc < stable`. See [Build channels](#build-channels) for exact per-channel formats and [the sort-order gotcha](#a-real-gotcha-semver-has-no-reserved-stage-names) for why this order isn't self-evident from the strings alone under SemVer. **Nightly counter:** sequential auto-increment, starting at `dev1`. Not date-based, not timestamp-based. See [rationale](#nightly-numbering--decided-sequential-auto-increment-reset-to-1). --- ## SemVer is structurally computed here, not conventionally promised In most codebases, SemVer is a promise a human makes in a commit message or a changelog entry, checked by nobody. In this workspace it's a structural fact, computed automatically, per commit, from a full AST diff of the changed symbols — because Muse maintains a complete, addressable symbol graph for every commit, the same graph `muse code impact`/`muse code gravity`/`muse code deps` query for blast-radius analysis. The version number isn't asserted; it's derived. ### The pipeline ``` StructuredDelta ← domain plugin's AST-level diff of this commit │ ▼ UniversalInvisibleRules ← strips docs/tests/licenses/lockfiles/CI config — │ these never carry version signal, in any repo ▼ StabilityManifest ← .muse/stability.toml, if declared — │ explicit per-symbol stability + visibility ▼ ChangeClassifier ← per changed symbol: ChangeKind × VisibilityTier × │ StabilityTier → confidence + human-readable reason ▼ SemVerAggregator ← folds every classification to ONE bump for the commit, │ taking the highest-ranked result across all changes ▼ SemVerClassification ← {bump, confidence, breaking[], additive[], implementation[]} ``` Source: `muse/core/semver_classifier.py::classify_delta()`. Invoked automatically at `muse commit` and previewable via `muse diff --json` before you commit — both populate the commit's `sem_ver_bump` and `breaking_changes` fields directly from this pipeline, not from anything you type. ### Visibility tiers | Tier | Definition | Heuristic | |---|---|---| | `exported` | Part of the public API surface | Non-underscore-prefixed symbol, not matched by any invisible pattern | | `internal` | Crosses module boundaries, not exposed externally | Not produced by heuristics today — requires explicit `.muse/stability.toml` declaration | | `private` | Underscore-prefixed, file-local | Always `invisible` for versioning purposes | | `invisible` | Never a version signal | Docs, tests, licenses, lockfiles, CI config, build artifacts | ### Stability tiers | Tier | Meaning | Default | |---|---|---| | `stable` | Public contract commitment made | Only via `.muse/stability.toml [stable]` | | `unstable` | Public, still evolving, no contract made yet | **Default for every undeclared symbol** | | `experimental` | Explicitly provisional (feature-flagged, unadvertised) | Only via `.muse/stability.toml [experimental]` | ### The bump matrix | | breaking | additive | implementation | |---|---|---|---| | **stable** | **MAJOR** | MINOR | PATCH | | **unstable** (default) | MINOR | PATCH | PATCH | | **experimental** | PATCH | PATCH | PATCH | | **invisible** | none | none | none | Source: `_bump_for()` in `muse/core/semver_classifier.py`. Read the `unstable` row carefully — **a breaking change on an undeclared symbol is MINOR, not MAJOR.** This is intentional, not a bug: MAJOR is reserved for breaking a promise you actually made (`stable`). If you never declared a symbol stable, there was no promise to break — the correct signal is MINOR ("the surface changed shape"), not MAJOR ("we broke our word"). **The first MAJOR bump a repo ever produces is, by construction, the first time a declared-stable symbol breaks** — which is exactly what MAJOR should mean, and it can't happen by accident on undeclared code. Aggregation across multiple changed symbols in one commit takes the **highest-ranked bump** (`none < patch < minor < major`) — one breaking change on a stable symbol makes the whole commit MAJOR even if everything else in the diff was PATCH-level noise. ### Declaring stability — `.muse/stability.toml` Repos without this file work correctly — every symbol defaults to `unstable`, which is the correct posture for any new API that hasn't promised anything yet. ```toml [stable] symbols = ["muse/core/store.py::CommitRecord"] patterns = ["muse/core/store.py::*"] [unstable] symbols = ["muse/cli/commands/release.py::run_suggest"] [experimental] symbols = [] [invisible] # File-path patterns, added on top of the universal invisible set — # never replacing it. patterns = ["src/ts/**", "*.scss"] ``` `stable` is checked before `experimental`; explicit `unstable` and undeclared symbols are indistinguishable (both default). There is no `internal` section — that tier exists in the type system but has no manifest-driven heuristic yet. ### Pre-1.0 arithmetic — exact rule, not an approximation Verified against `muse/cli/commands/release.py::run_suggest()`: ```python if major == 0: if agg_bump == "major": suggested_tag = f"v0.{minor + 1}.0" # structural break → bump minor, don't cross 1.0 else: # minor or patch suggested_tag = f"v0.{minor}.{patch + 1}" # everything else → bump patch else: # standard semver: major/minor/patch bump their own component ``` This matches the SemVer spec's own guidance for `0.y.z` — anything can change at any time, so a structural MAJOR-class break inside a 0.x line bumps *minor*, not the major version, and never silently crosses the 1.0 boundary on its own. ### `muse release suggest` — the aggregation step Walks every commit since the last release, takes the **highest** `sem_ver_bump` across all of them, and proposes the next tag with full attribution: ```bash $ muse release suggest --json { "suggested_tag": "v0.3.0", "inferred_bump": "major", "pre_1_0_adjusted": true, "base_tag": "v0.2.0", "unreleased_count": 7, "drivers": [ { "commit_id": "sha256:...", "message": "feat: rework CommitRecord field layout", "sem_ver_bump": "major", "breaking_changes": ["muse/core/store.py::CommitRecord"] } ] } ``` `drivers` names the exact commits and exact symbols that produced the bump — the version number comes with a citation, not a vibe. If every unreleased commit carries `"none"`, `suggest` proposes nothing and exits 0 — there is nothing to release, and the tool says so instead of guessing. ### Why this is the reason SemVer matters here specifically The classifier's structural diffing runs against the same symbol addresses (`file.py::Symbol`) that back `muse code impact` — before committing a change you believe might be breaking, `muse code impact "file.py::Symbol"` shows you exactly who is affected, using the same graph the classifier used to decide the bump. The version number and the blast-radius query are two views into one underlying fact, not two independently-maintained claims that can drift apart. --- ## Why two canonical formats, both load-bearing **`pyproject.toml`'s `version` is consumed by `pip`, `build`, `twine`.** Those implement PEP 440. Its reference parser 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 parses, but the canonical form pip stores and displays is always `0.2.0rc16` — no hyphen, no dot before the number, no `v`. **`muse release add ` is consumed by `muse/core/semver.py`**, implementing SemVer 2.0 — a real requirement, since `muse release` answers "what channel is this" and "is this a breaking release" the way SemVer defines those questions. Its parser is strict and requires the hyphen SemVer's own 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 spelling is simultaneously PEP-440-canonical and SemVer-canonical. The resolution isn't picking one; it's using each spec's canonical form in its own context and never guessing at the boundary. ### Side by side | | PEP 440 | SemVer 2.0 | |---|---|---| | Governs | `pyproject.toml`, 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 canonically | Not formal grammar, but the universal tag convention (git, k8s, npm, cargo); muse's parser accepts it (`v?`) | | Reserved pre-release names | `a`, `b`, `rc` — exactly three, built-in precedence | None reserved — any identifier is valid; precedence is purely positional comparison | | Dev/nightly concept | `.devN` — reserved, sorts before `a` | No reserved concept; `nightly` is a free-text identifier you chose | | Precedence source | Built into the spec (`dev < a < b < rc < final`) | Purely lexical/numeric comparison of whatever you wrote | ### A real gotcha — SemVer has no reserved stage names ```python >>> sorted(['alpha', 'beta', 'nightly', 'rc']) ['alpha', 'beta', 'nightly', 'rc'] # alphabetical ``` Alphabetically `nightly` sorts **between** `beta` and `rc`. The real maturity order is `nightly < alpha < beta < rc` — nightly is rawer than alpha. A plain lexical sort on these four words ranks a nightly as more mature than a beta, which is backwards. PEP 440 doesn't have this problem — `dev` is a formally-reserved, always-earliest segment. `muse/core/semver.py` does not currently implement precedence comparison at all (it only parses and infers channel by prefix match), so this isn't a live bug — but it's the trap waiting for whoever adds sorting later, using these words as-is. --- ## Build channels Three are industry convention; `dev`/nightly is formally reserved by PEP 440. All four are recognized by `muse/core/semver.py`'s `ReleaseChannel` type — including `"rc"`, added alongside this doc after an audit found it missing (see [Fixed gap](#fixed-gap-rc-channel-was-silently-classified-as-stable)). | Channel | Definition | Answers | PEP 440 | SemVer | |---|---|---|---|---| | **Nightly** | Continuous, features still landing; shared for visibility only | — | `0.2.0.dev1` | `v0.2.0-nightly.1` | | **Alpha** | Feature-incomplete; core-team/trusted-circle testing | Does the fundamental approach work? | `0.2.0a1` | `v0.2.0-alpha.1` | | **Beta** | Feature-frozen, hardening; wider testing | Does it hold up under real usage diversity? | `0.2.0b1` | `v0.2.0-beta.1` | | **Release Candidate** | Feature-frozen, believed bug-free; only blocking fixes between numbers | Is this exact build shippable? | `0.2.0rc1` | `v0.2.0-rc.1` | | **Stable** | Shipped | — | `0.2.0` | `v0.2.0` | ### The pivot to nightly `rc1`–`rc16` in this workspace's history were nightly-*flavored* in practice — continuous feature delivery shared with stakeholders, new capabilities landing between numbers — which contradicts what `rc` means (feature-frozen, bug-fixes-only between numbers). That behavior matches **nightly** exactly, a channel already defined in `ReleaseChannel` but never previously used. Going forward: ongoing work shared before a real version bump is `nightly`; `rc` is reserved for the narrow window after features are actually frozen, right before a MAJOR.MINOR.PATCH bump. ### Nightly numbering — decided: sequential auto-increment, reset to 1 Considered sequential (`dev1`, `dev2`, ...), plain date (`dev20260704`), and a Unix timestamp (`dev1751654400`). **Decided: sequential**, on three grounds: 1. **Date-based collides under this workspace's actual cadence.** One work session produced five separate, meaningful CLI publishes in a single calendar day — date-based numbering gives all five the identical version string. Sequential has no such ceiling. 2. **A Unix timestamp is collision-free but costs readability for no offsetting benefit here.** `dev1751654400` can't be said or eyeballed, and — like the date option — still can't answer "how many nightlies so far" the way a sequential count does. 3. **"When" isn't lost by going sequential** — every commit already carries `committed_at` at second precision (`muse read --json`, `muse log`). A date or timestamp in the version number is a coarser, redundant copy of data already recorded precisely elsewhere. Revisit if concurrent publishers (multiple people/CI runners cutting nightlies simultaneously, needing no shared counter state) become real — 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 sequence was never actually tracked as nightly, so starting fresh avoids implying 16 builds existed under a name they never had. ### A channel pivot must bump the base version — not just the channel label **Rule:** when ongoing work switches to a new channel label under a base version that already has higher-precedence pre-release builds (`a`, `b`, `rc`) or a final release, the base version (`MAJOR.MINOR.PATCH`) must bump too — never reuse the same base version with a lower-precedence tag. **Why this is load-bearing, not stylistic:** PEP 440 defines `dev` as the *lowest*-precedence pre-release stage for a given version (`dev < a < b < rc < final`). If `rc1`–`rc16` and a new `dev1` all share the same base version `0.2.0`, then `0.2.0.dev1 < 0.2.0rc16` is true and *correct* per PEP 440 — even though `dev1` was built chronologically after `rc16`. Any tool that retains "the N highest-precedence releases" (e.g. `deploy/prune_releases.py`, see issue #128) will keep the three `rc` builds and prune the nightly — reproducing the exact failure this section's pivot narrative describes, immediately after every future pivot, regardless of how correct the sorting logic is. **The fix is not a smarter sort — sorting `0.2.0.dev1` above `0.2.0rc16` would be *wrong*, not a bug fix.** The fix is numbering discipline: the pivot from `rc16` to nightly should have started at `0.2.1.dev1` (or whatever the next real version bump is), not `0.2.0.dev1` — a `0.2.1` dev build correctly outranks every `0.2.0` build, pre-release or final, with no ambiguity. Apply this on every future channel pivot: bump the base version in the same commit that introduces the new channel label. --- ## Translating between the two formats for the same release Fixed, mechanical rule: strip `v`, 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` | --- ## Fixed gap: `rc` channel was silently classified as `stable` Writing this doc's first draft surfaced a real bug: `semver_channel()` checked `alpha`/`beta`/`nightly` prefixes but had no `rc` branch — a real release-candidate tag (`v0.2.0-rc.1`) fell through every check and was classified `"stable"`, making an rc indistinguishable from an actual stable release in any channel-based logic. **Fixed**: `"rc"` added to `ReleaseChannel`, `_CHANNEL_MAP`, `_CHANNELS`, and `semver_channel()`'s checks (ordered to match the maturity sequence above: nightly → alpha → beta → rc → stable). An existing test had literally codified the bug as expected behavior (`test_rc_defaults_to_stable`) — replaced with `test_rc_channel` asserting the correct classification, plus a new test confirming genuinely unrecognized prefixes still default to stable. Verified live: a fresh install correctly reports `channel: rc` for `v0.2.0-rc.1`. --- ## Terminology — "release candidate" is not deprecated by "pre-release" Two levels of one hierarchy, not competing words: - **"Pre-release"** — 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"** — one specific, named *stage* within that category, alongside alpha, beta, and nightly. Both specs define `rc` as first-class; neither deprecated it. "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 this doc's conventions feed into - `muse/core/semver_classifier.py` — the structural classification engine - `muse/core/semver.py` — SemVer parsing, formatting, and channel inference - `muse/cli/commands/release.py` — `muse release add`/`suggest`/`list`