gabriel / musehub public
versioning.md markdown
247 lines 11.7 KB
Raw
sha256:69ae50e537d929ac0661cd4da7f3e58719b13f80a1f799b072e874cd5ab6383e docs: lock in sequential auto-increment for nightly numbering 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.dev1
  • SemVer: v0.2.0-nightly.1

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.dev1 / v0.2.0-nightly.1), 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 numbering — decided: sequential auto-increment, reset to 1

Considered three options: sequential auto-increment (dev1, dev2, ...), plain date (dev20260704), and a literal Unix timestamp (dev1751654400). Decided: sequential.

  • Date-based fails under this workspace's actual cadence. A single work session has produced five separate, meaningful CLI publishes in one calendar day. Date-based numbering collides every time that happens — every publish that day would share the identical version string. Sequential numbering has no such ceiling; every distinct publish gets its own number regardless of how many happen per day.
  • A Unix timestamp solves the collision problem but at a cost, for no offsetting benefit here. It's provably collision-free in this single-agent, sequential-command workflow, but it's unreadable (dev1751654400 can't be said or eyeballed) and, like the date option, still can't answer "how many nightlies have there been" the way a sequential count can.
  • The "when was this built" information isn't lost by going sequential — every commit already carries committed_at with second-level precision (muse read --json, muse log). A date or timestamp embedded in the version number would just be a coarser, redundant copy of data already recorded precisely elsewhere.
  • If concurrent publishers (multiple people or CI runners cutting nightlies at the same time, needing no shared counter state) ever becomes real, this decision should be revisited — 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 rc1rc16 sequence was nightly-flavored in practice (continuous feature delivery, not feature-frozen bug-fixing) but was never actually labeled or tracked as nightly. Starting at dev1 marks the first real release cut under the corrected convention cleanly, rather than implying 16 prior builds existed under a name they never had.

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.dev1, 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.1, 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)
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

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:69ae50e537d929ac0661cd4da7f3e58719b13f80a1f799b072e874cd5ab6383e docs: lock in sequential auto-increment for nightly numbering Sonnet 4.6 18 days ago