gabriel / musehub public
versioning.md markdown
410 lines 18.8 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago

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

rc1rc16 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 rc1rc16 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.

  • 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.pymuse release add/suggest/list
File History 3 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago