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
284 lines
19 KiB
Markdown
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.
|