Files changed: - CHANGES.md - DEVELOPMENT.md - VERSION - docs/version-model.md - instructions/dev/version-parts.md
12 KiB
type, name, description
| type | name | description |
|---|---|---|
| types/instruction.md | version-parts | Which part of the stack version a change bumps - the compatibility question (is the new version a drop-in replacement?) separated from the migration question (must existing content change?), plus what to do before a breaking bump. |
Pick the version part for a stack change
Two questions decide a version bump, and they are not the same question:
- Is the new version a drop-in replacement for the old one? This is what the version
number itself says. Compatibility is read off the leftmost non-zero component - on this
stack (
4.x) that is MAJOR, on a0.xstack it is MINOR. A bump that changes it is called boundary-crossing below, because that is the termversion bumpanddocs verifyuse in their own messages. - Must existing content be migrated? This is a consequence a boundary crossing may or
may not have.
kb/staying untouched does not make a change compatible, andversion bump --no-migrationexists precisely because boundary-crossing bumps with an untouched corpus are a real case.
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once already (see the case study at the end), which is why this file exists.
The candidate model
Between two releases the stack carries one running candidate, not a fresh version per
bump. Before that, every bump minted a number and a release: CI's version gate requires
VERSION to move on every stack-touching push, and release.yml fires on every VERSION
move, so releases were being cut at commit granularity. 2026-09-03 produced four of them in
six hours (4.3.0 through 4.3.3) for one arc of work - all four real, none of them a
meaningful unit to anyone downstream. A candidate closes that gap without touching the gate:
VERSION still moves on every bump, it just escalates the same number instead of handing out
a new one, and only version release turns it into something the release workflow acts on.
- State lives in
VERSIONitself, as an optional-beta.Nsuffix (4.4.0-beta.3). No second state file: the last release is read back out ofCHANGES.md(the newest entry with no suffix), and the escalation stage is the difference between the candidate's base and that release - derived, not stored. --major/--minor/--patchis max-wins escalation, not a step you can undo. A--patchbump on a candidate already at MINOR only advances its bump count (N); nothing ever steps a candidate back down. Declaring the part is still your judgment call, made the same way the steps below describe -escalate()only ever raises it further.- A candidate is never released. Pre-release is a dev-checkout state;
release.ymlonly acts on a suffix-freeVERSION, so a distributed instance never sees a-beta.version at all, and its parser never has to know the suffix exists. - One
CHANGES.mdentry per candidate, not per bump. The first bump of a candidate opens it (heading, date, author, and a machine-managed<!-- wikitool:bumps -->list seeded with that bump's--title); every later bump of the same candidate updates that entry in place - heading, date and the bumps list all move, but the entry's own prose (written below the skeleton, by hand) is left alone.version notestherefore still prints exactly one entry per release, whatever a candidate's history of bumps looked like. version bumpopens or continues a candidate;version releasefixes one. Onlyreleasestrips the suffix and turns the entry into a real, closed release - see its own row intools/CONTRACT.md. Nothing else does, and nothing auto-fixes a candidate on its own.
When to run
Before every tools/wikitool version bump - the stack-dev skill's step 3 sends you here.
Read it in full the first time a change looks like it might be boundary-crossing; afterwards
the three-line test below is usually enough.
Steps
-
Apply the drop-in test. The bump is boundary-crossing if either half fails:
- Forward: an existing instance can install the new machinery by copying
tools/,types/,instructions/,AGENTS.md,VERSIONand.wikitool-release.jsonover itself, and everything that worked before still works - with no hand-work by the user and no migration script. Any step beyond the copy, however small, fails this half. - Backward: having installed the new version, the user can put the old one back and be where they started. A state file the old version cannot read, a rewritten corpus, a renamed stamp - anything that makes the downgrade fail or leave a broken instance - fails this half.
Content migration is one way to fail the forward half, not the definition of it.
- Forward: an existing instance can install the new machinery by copying
-
Check the catalogue when the answer still feels like a judgment call. Each of these crosses the boundary with
kb/entirely untouched:What changed Why the swap is not drop-in The update path - update_url, the release feed, the repo it points atThe instance cannot repair its own .wikitool-release.json: it is machine-written, and invariant 1 forbids the hand-edit. The channel that would have told it to update is the channel that brokeThe release artefact's name Every download script and every pin against it breaks The Python package's import name from <old> import ...outside the shipped tree breaksA command, subcommand, or flag that was removed or renamed Scripts, CI workflows and instruction files calling the old spelling break An environment variable's name An instance configured through it silently loses the configuration The shape of a machine-read file - .wikitool-kb.json,.wikitool-release.json, a generated indexThe old version cannot read what the new one wrote, so the downgrade half fails even if the upgrade half passed A type-spec's required fields Existing pages stop validating - this one crosses and needs a content migration The catalogue is illustrative, not exhaustive. When something is not on it, go back to step 1.
-
Otherwise pick the compatible part:
Change Part Fix, no interface change --patchNew capability, drop-in in both directions --minor -
Stop and talk to the user before the bump that first escalates a candidate past the boundary. It is expensive in a way the other two parts are not: every existing instance pays for it, once, by hand. That escalation happens exactly once per candidate - a later bump that keeps the candidate at the same stage (another
--majoron one already there, say) does not re-cross anything and needs no second conversation. Put in front of the user, in this order:- What breaks, concretely - which file, which name, which call site.
- What each existing instance must do, as the steps they would actually run.
- The alternatives, so the break is a choice and not a side effect:
- Avoid it - keep the old name as an alias, read both file shapes, accept both flag spellings. A compatibility shim carried for one release is usually cheaper than a migration everyone runs.
- Defer and batch it - hold the break until the next boundary crossing, so instances pay once instead of twice.
- Split it - ship the compatible half now, the breaking half later behind a deprecation window that the changelog announces in advance.
- Your recommendation, with the trade-off named.
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
-
Record the break in the escalation bump itself. The bump that first crosses the boundary requires
--breaking "<what breaks>", which writes a**Breaking Change:**line into the entry:tools/wikitool version bump --major \ --title "<what changed>" \ --breaking "<what stops working, and what an instance must do about it>" \ --no-migration "<why no page has to change>" # only if that is trueThe line, once written, stays in the entry across every later bump of the same candidate - a follow-up
--majordoes not need to repeat--breaking, because the entry it would repeat it into is the same one.--breakingis refused on a bump that crosses nothing, and required on the one that does.docs verifychecks the newest boundary-crossing entry still carries the line. Write it for the operator of an instance that has not read this repository: what stops working, and what they do about it. -
Then answer the migration question separately. Boundary-crossing and content-migrating are independent:
- Content must change → write the migration document under
instructions/migrations/per migrate-corpus.md. The escalation bump finds it by the document'smigrates_to:field, matched against the candidate's base - a document targets the release the candidate will become, never a-beta.Nform of it. - Content need not change →
--no-migration "<reason>", which records that in the entry.
Both are also needed by
docs verify, for the same reason: an instance that learns it must migrate, with nothing telling it how, is a dead end. Like--breaking, both persist across later bumps of the same candidate without being repeated. - Content must change → write the migration document under
-
Fix the candidate once it is ready to ship.
version bumponly ever opens or escalates one; nothing turns it into a release excepttools/wikitool version release, which strips the-beta.Nsuffix and closes the entry - see its row intools/CONTRACT.md. That is also the point to pass a summarising--titleif the candidate collected several bump titles along the way; without one, the heading simply keeps whichever bump last set it. -
Write the entry's body.
bumpleaves it empty on purpose. A boundary-crossing entry earns a paragraph that says why this is breaking - it is the one thing a future reader cannot reconstruct from the diff, and it is what the next session in this position will read instead of guessing.
Decision points
- The change ships no code - only
README.md,INSTALL.md,EVALS.md, or.gitea/? No bump at all; CI's version gate is scoped to what changes behaviour. - A break you can see coming but are not making yet? File it as an issue and let it accumulate. Boundary crossings are cheaper in batches, and step 4's "defer" alternative is only real if someone wrote the break down.
- Unsure between MINOR and boundary-crossing? It is boundary-crossing. The cost of an unnecessary MAJOR is one extra release note; the cost of a MINOR that actually breaks is an instance whose update path fails while its version number promised it would not.
- The break only affects this repository, not a distributed instance - something under
instructions/dev/, say? Then it is not a stack break at all:dist exportnever shipped it. Judge by what an exported instance sees.
Scope
Applies to the stack version in VERSION - tools/, types/, instructions/, AGENTS.md
and the contracts. It says nothing about the content shape in .wikitool-kb.json, which is
advanced by wikitool migrate done and described by migrate-corpus.md,
and nothing about wiki content operations, which are logged in kb/log.md and carry no version
at all.
Choosing the part remains a judgment call, deliberately: docs verify checks that a
boundary-crossing entry documents its break and its migration, never that the part was chosen
correctly. No validator can tell a renamed flag from a new one.
Case study: 2.0.0
The Chemenu rebranding renamed the repo, the release artefact and the Python package. No page
in kb/ changed, so the first attempt was 1.9.0 - the migration question, answered correctly,
substituted for the compatibility question, which was never asked. Three things broke: every
existing instance's update_url pointed at a repo path that no longer existed and could not be
hand-repaired; the artefact name changed; the import name changed. The correct bump was
--major --no-migration, and the CHANGES.md entry for 2.0.0 carries the reasoning in full
under "Warum das trotzdem MAJOR ist". The error was caught by the user, not by the
documentation - which is what step 4 is for.