Files
chemenu/instructions/dev/version-parts.md
T
torbenandClaude Opus 5.5 044943ae51
CI / verify (push) Successful in 6m0s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 33s
feat: changelog entries stay compact - one paragraph per topic, a size budget in docs verify and version release, version bump names required migration documents (#184)
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-build/SKILL.md
- instructions/dev/version-parts.md
- instructions/migrate-corpus.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-06 16:48:02 +02:00

19 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.

Contents

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, graded by --impact); 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 is left alone. version notes therefore still prints exactly one entry per release, whatever a candidate's history of bumps looked like.

  • The entry is layered, not one undifferentiated block. A long-running candidate can collect dozens of bumps, chronological and equally weighted, which is unreadable as a release announcement - 5.0.0 did this at ~1440 lines for one entry. So the entry reads, top to bottom, as four layers with different authors and different lifetimes:

    1. Heading, author, breaking/migration lines - written by version bump, anchored right above the bump list so the line an operator most needs to act on never sits beneath a list that can run long. The breaking line accumulates across a candidate's crossings - one reason on the marker line, bullets under a bare marker from the second onward - because a long-running candidate can break compatibility more than once and each break is its own thing to act on. The migration line does not: it answers one yes/no about the candidate as a whole, in one of two forms that never stand side by side. Where a required migration document targets the candidate's base, every bump writes **Migration:** required - instructions/migrations/<file>.md (several paths sorted and comma-separated) - the pointer from the release notes to what an operator has to run; otherwise --no-migration writes **Migration:** none required - <reason>, and --migration-required is its retraction path. Nothing retracts a breaking reason; a wrong one is rare enough, and the candidate is dev-local until release.
    2. The bump list, grouped **High/Medium/Low impact** (empty groups omitted) - rendered by version bump's --impact (default medium), corrected after the fact by version regrade. Flat and ungrouped, exactly as before this layering existed, when every bump is medium - the common case, and the shape every pre-existing region still is. This list is the bullet level: a smaller change gets nothing in the entry but its bump title.
    3. The release summary - a short paragraph, written once, by hand, when the candidate is ready to ship. version release refuses to close an entry with two or more bumps and no summary here; a one-bump entry is exempt, since there its own paragraph already reads as the summary.
    4. One ### section per topic, not per bump - for the larger changes only. A topic is what a reader would call one change; it often gathers several bumps, and its heading names the topic rather than repeating a bump title. A later bump on the same topic rewrites that section's paragraph instead of adding a second one. Whether a change earns a section or stays a bullet is the build session's judgment, with no tie to --impact.

    The budget, checked by docs verify and again by version release before it writes: the topmost versioned entry is at most 32 000 bytes (UTF-8) as version notes prints it, and every ### section is exactly one paragraph - no blank line in its body - of at most 1 000 bytes, its ### line excluded. A body runs to the next ### or ## line or the --- separator; blank lines at either end do not count. The total is there because an entry grows with every bump: the 8.0.0 candidate collected 81 bumps, one changeset each, reached 129 KB and failed as a release body, and no per-section rule alone would have stopped that. Older entries are not held to it - their release notes are already published. What needs more than one paragraph - the reasoning, the alternatives, the measurements - belongs in the issue, not in the entry.

  • 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

When a design names the version part (stack-dev step 4), and again before every tools/wikitool version bump (stack-build step 4). 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. Grade the bump while you are making it, with --impact high|medium|low (default medium) - the judgment is easiest right after you did the work, not weeks later staring at a chronological list. It is not final: version regrade corrects it before release if the candidate's overall shape changes the read on an earlier bump. Then 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 - and names a required one in the entry's migration line.
    • 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.

    A --no-migration answer can turn out wrong later in the same candidate, and that is not a hand-edit: a bump escalates, a second change lands under the same running number, and now content does have to move after all. Write the migration document first, then run version bump --migration-required - it replaces the **Migration:** none required line the earlier bump wrote with the required line naming the document, and refuses unless a required document already targets the new base. Any later bump would make the same replacement once the document exists, and docs verify reports a crossing entry whose migration line does not name it; the flag is the explicit form, which also refuses when there is nothing to replace. --no-migration itself is refused while a required document targets the base. The 5.0.0 candidate is the case this guards: it declared --no-migration for a TOC-verification change, then absorbed a schema removal that migrates 152 pages.

  7. Fix the candidate only when the user asks for a release. Whether a candidate ships is the user's call, never a session's: a work package being finished is not a reason, since the candidate model exists precisely so that one does not become one release. A session that bumps stops at the open -beta.N candidate; the next publish then carries it without triggering release.yml. Once the user does ask, review the graded list first, and regrade what reads wrong. Run tools/wikitool version regrade with no arguments - it lists every bump at its current grade, numbered in rendered order. A candidate that grew over several sessions often has a bump graded in isolation that reads differently once the whole shape is visible; version regrade 3 7 --impact high corrects one or several positions against a single read of that list, put the result in front of the user, and re-list to confirm. Only then run 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 prose - a section for a larger change, nothing for a smaller one. bump writes no prose on purpose. Decide per bump: a smaller change is done once its title is in the bump list. A larger one gets a ### section for its topic - one paragraph, in the budget of § The candidate model, on what changed and why, which is the one thing a future reader cannot reconstruct from the diff. If its topic already has a section, rewrite that paragraph to cover both bumps rather than adding another. The full rationale of a decision belongs in the issue tracker or the commit history; a paragraph pressing against its 1 000 bytes is a sign that part of it belongs there instead. The summary is a short paragraph (a few sentences) written once, at release time, right below the graded bump list: what this release is about, and why, for a reader who will not read the sections underneath. version release refuses to close an entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since there its own paragraph already reads as one.

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.