gabriel / musehub public
versioning.md markdown
219 lines 10.2 KB
Raw
sha256:43b5ad9422686d7119910384e280e85fcf78250346c551eea3b6977d2052880c docs: add versioning.md — PEP 440 vs SemVer 2.0, build chan… Sonnet 4.6 18 days ago

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}Nno 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.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 (rc15rc16 becomes dev17dev18, 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 <tag> 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-<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)
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.

  • deploy.md — the release runbook that puts this into practice
File History 1 commit
sha256:43b5ad9422686d7119910384e280e85fcf78250346c551eea3b6977d2052880c docs: add versioning.md — PEP 440 vs SemVer 2.0, build chan… Sonnet 4.6 18 days ago