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

284 lines
19 KiB
Markdown

---
type: types/instruction.md
name: version-parts
description: 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.
<!-- wikitool:toc -->
## Contents
- [The candidate model](#the-candidate-model)
- [When to run](#when-to-run)
- [Steps](#steps)
- [Decision points](#decision-points)
- [Scope](#scope)
- [Case study: 2.0.0](#case-study-200)
<!-- /wikitool:toc -->
## 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:
```bash
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](../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](../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.