d29d400dd3
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
115 lines
7.5 KiB
Markdown
115 lines
7.5 KiB
Markdown
# 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](../instructions/dev/version-parts.md).
|