Files
chemenu/instructions/dev/version-parts.md
T
torben abe5497cda
CI / verify (push) Successful in 49s
Release / release (push) Successful in 34s
docs: Faktenkorrekturen in der 4.4.0-Prosa; DEVELOPMENT.md ohne zweite Kommandobeschreibung (4.4.1-beta.1, #47)
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
2026-09-03 22:48:09 +02:00

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:

  1. 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 a 0.x stack it is MINOR. A bump that changes it is called boundary-crossing below, because that is the term version bump and docs verify use in their own messages.
  2. 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, and version bump --no-migration exists 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 VERSION itself, as an optional -beta.N suffix (4.4.0-beta.3). No second state file: the last release is read back out of CHANGES.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/--patch is max-wins escalation, not a step you can undo. A --patch bump 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.yml only acts on a suffix-free VERSION, so a distributed instance never sees a -beta. version at all, and its parser never has to know the suffix exists.
  • One CHANGES.md entry 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 notes therefore still prints exactly one entry per release, whatever a candidate's history of bumps looked like.
  • version bump opens or continues a candidate; version release fixes one. Only release strips the suffix and turns the entry into a real, closed release - see its own row in tools/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

  1. 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, VERSION and .wikitool-release.json over 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.

  2. 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 at The 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 broke
    The 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 breaks
    A 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 index The 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.

  3. Otherwise pick the compatible part:

    Change Part
    Fix, no interface change --patch
    New capability, drop-in in both directions --minor
  4. 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 --major on 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.

  5. 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 true
    

    The line, once written, stays in the entry across every later bump of the same candidate - a follow-up --major does not need to repeat --breaking, because the entry it would repeat it into is the same one. --breaking is refused on a bump that crosses nothing, and required on the one that does. docs verify checks 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.

  6. 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's migrates_to: field, matched against the candidate's base - a document targets the release the candidate will become, never a -beta.N form 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.

  7. Fix the candidate once it is ready to ship. version bump only ever opens or escalates one; nothing turns it into a release except tools/wikitool version release, which strips the -beta.N suffix and closes the entry - see its row in tools/CONTRACT.md. That is also the point to pass a summarising --title if the candidate collected several bump titles along the way; without one, the heading simply keeps whichever bump last set it.

  8. Write the entry's body. bump leaves 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 export never 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.