Files changed: - .gitea/workflows/release.yml - AGENTS.md - CHANGES.md - DEVELOPMENT.md - README.md - VERSION - docs/version-model.md - instructions/dev/version-parts.md - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/kb_state.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/version.py
7.5 KiB
Why the stack version splits compatibility from migration
A stack version number looks like it answers one question. It actually answers two, and the two are independent of each other.
Two questions, not one
The first question is whether the new version is a drop-in replacement for the old one - whether
an existing instance can install it, and can also go back, without anyone doing hand-work. That
is what a version number is: a promise. The second question is whether the existing corpus in
kb/ needs to change shape to keep working under the new version. These sound like the same
question, because most of the time a change that breaks compatibility also happens to touch
content, and most of the time a change that leaves content untouched also happens to be
compatible. The correlation is real; it just is not a law. instructions/dev/version-parts.md
carries the actual test for telling them apart and the steps that follow from it - this page is
about why the split exists at all.
Why "kb/ untouched" is not proof of anything
The tempting shortcut is: if no page in kb/ had to change, the bump can't be that serious. This
is exactly backwards for a class of changes that live entirely outside the corpus - a renamed
release artefact, a Python import path, an environment variable, the URL an instance's own
updater points at. None of those touch a single page. All of them can strand an existing
instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack
that looks at itself; the compatibility question is about everything an instance depends on to
keep functioning, most of which the corpus never sees.
Reading compatibility off the leftmost non-zero component
Semantic versioning gives every component a job, but only one of them is where an existing
instance's tooling actually looks to decide "is this safe." On a 2.x stack that is MAJOR; on a
still-pre-1.0 0.x stack, by the same convention, it's MINOR - the leftmost slot that isn't
pinned to zero is the one an automated updater treats as the compatibility boundary. Bump
anything to its left, or bump that slot itself, and the promise changes. Everything to the right
of it can move as freely as the project likes without touching that promise. This is why the
question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about
how big the change is.
Downgrade is half the promise
It's natural to test compatibility by only asking "does the upgrade work." The other half - "can an instance that upgraded put the old version back and land where it started" - carries equal weight, and it's the half that's easy to forget because forward motion is what everyone is testing for anyway. A state file the old version can no longer parse, a generated index in a new shape, a stamp file that got renamed: none of these have to break the upgrade to break the downgrade. An instance that can go forward but not back has already lost the property a compatible version number is supposed to guarantee.
A promise made to a machine, not only to a person
A human reading a changelog can absorb "this technically isn't compatible but it's fine, just update those two things by hand." An instance's own update mechanism cannot. It reads a version number, decides whether to pull the new release, and has no channel for nuance - which is exactly why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the new version moves where updates come from, the very channel that would have told an instance to adjust is the channel that just broke. The version number isn't documentation aimed at a reader; it's an input consumed by code that has no other way to ask.
The 2.0.0 story
This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo,
the release artefact, and the Python package - and left every page in kb/ untouched. The first
instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That
reasoning was correct on its own terms and answered the wrong question. Three things broke
underneath it: every existing instance's update_url pointed at a repo path that no longer
existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact
name changed, breaking every download script and pin against it; and the import name changed,
breaking anything importing the package from outside the shipped tree. The corpus had nothing to
say about any of this, because none of it lived in the corpus.
What caught the mistake was a person looking at the diff and asking whether it really was a
drop-in replacement, not a validator. No check in docs verify or anywhere else confirms that a
version part was chosen correctly - it only confirms that a boundary-crossing bump documents
what it breaks. The 2.0.0 entry in CHANGES.md carries the corrected reasoning in full, and the
version bump that shipped it was --major --no-migration: boundary-crossing and untouched
corpus, at the same time, which is precisely the combination the two-question split exists to
make visible.
Why a number is only spent by a release
Everything above is about what a version number promises. A separate question turned out to matter just as much in practice: how many numbers get handed out along the way to making one release. For a while the answer was "one per bump," and that turned out to be the wrong grain entirely.
The two things that actually consume a version number are a release and CI's version gate - and
they disagree about granularity. The gate wants VERSION to move on every push that touches
stack-shaped paths, which is a commit-level question: has this tree changed since the last
push. A release wants to know something else: has this specific number been published, ever.
Handing out a fresh number per bump answers the gate's question by accident and the release's
question wrongly - it treats every bump as if it were about to ship, when most of them are steps
toward a release that hasn't happened yet. Four bumps in one session, on the same day, for the
same eventual release, produced four numbers that a version-check feed would have reported as
four different available upgrades, three of which were never real.
The fix is not to slow the gate down - it still wants VERSION to move every time, and it still
gets that. It's to stop treating every movement as a new number. Between two releases the stack
now carries one running candidate, escalating through -beta.N as bumps accumulate, and only
version release spends the number for real by fixing it and closing its changelog entry. A
number is proposed by a bump and consumed by a release; conflating the two was the actual defect,
not the arithmetic of any single bump.
This is also why a candidate never gets to a distributed instance. The promise a released version
makes - "install this, and it is exactly what its number says" - has no equivalent for something
still being decided during a single dev checkout's session. release.yml's only job with respect
to this is refusing to act on a suffixed VERSION at all: not because a beta is unsafe, but
because there is nothing yet to promise.
Where the procedure lives
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
steps for a boundary-crossing bump - the --breaking line, the migration document or
--no-migration reason, talking to the user before bumping - are one procedure, kept at one
place: instructions/dev/version-parts.md.