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 canonical | X.Y.Z, X.Y.ZrcN, X.Y.Z.devN |
0.2.0rc16, 0.2.0.dev1 |
muse release add <tag>, muse/git-style tags |
SemVer 2.0 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 below for the
verified proof.
Channel maturity order: nightly < alpha < beta < rc < stable. See
Build channels for exact per-channel formats and
the sort-order gotcha 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.
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.
[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():
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:
$ 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:
>>> 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 <tag> 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:
>>> 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
>>> 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).
| 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:
- 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.
- A Unix timestamp is collision-free but costs readability for no
offsetting benefit here.
dev1751654400can'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. - "When" isn't lost by going sequential — every commit already
carries
committed_atat 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
rcas 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 — the release runbook this doc's conventions feed into
muse/core/semver_classifier.py— the structural classification enginemuse/core/semver.py— SemVer parsing, formatting, and channel inferencemuse/cli/commands/release.py—muse release add/suggest/list