Files
chemenu/instructions/dev/version-parts.md
T
torben d29d400dd3
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
Files changed:
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- README.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/kb_state.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-03 22:19:41 +02:00

194 lines
12 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 (`2.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`. Five bumps with no release in between used to mean five numbers, four of which nothing
ever consumed - the release-granularity CI's version gate wants (`VERSION` must move on every
stack-touching push) was being paid at bump granularity instead. 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.
- **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 section
below describes - `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:
```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.
- 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](../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.