feat: Versionsstelle als Kompatibilitaetsfrage, Breaking-Change-Vermerk erzwungen (2.5.0)
CI / verify (push) Successful in 54s
Release / release (push) Successful in 37s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.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
This commit is contained in:
2026-09-02 09:17:36 +02:00
parent 7fbb9a99df
commit 31662dc3ff
11 changed files with 431 additions and 32 deletions
+51
View File
@@ -20,6 +20,57 @@ their date-only headings.
--- ---
## 2.5.0 - 2026-09-02 - Versionsstelle: Kompatibilitaet statt Inhaltsmigration, Breaking-Change-Vermerk erzwungen
**Author:** Torben Nehmer
Die Doku des Stacks führte für die Wahl der Versionsstelle zwei Fragen zusammen, die nicht
dieselbe sind: *muss der Korpus migriert werden?* und *ist die neue Version ein
Drop-in-Ersatz?* An allen drei Stellen, die einer Session vor dem Bump begegnen - die Tabelle
in `stack-dev`, der Modul-Docstring in `version.py`, `INSTALL.md` § Version und Updates -
stand nur die erste. Nur `version bump --help` formulierte es richtig, also ausgerechnet die
Stelle, die man erst liest, wenn die Entscheidung gefallen ist. Bei `2.0.0` hat genau diese
Lücke zu `1.9.0` geführt (Gitea #26; der Eintrag zu `2.0.0` trägt den Fall im Volltext).
**Die Regel, jetzt an einer Stelle.** Neu ist
`instructions/dev/version-parts.md`: der Drop-in-Test in zwei Hälften - vorwärts (kopieren,
fertig, keine Handarbeit durch Nutzer oder Migrationsskript) und rückwärts (die alte Version
lässt sich noch zurückinstallieren) -, der Katalog der Brüche, die die Grenze bei völlig
unangetastetem `kb/` überschreiten (Update-Pfad, Artefaktname, Import-Name, Flags, Envvars,
Shape maschinengelesener Dateien), und der Fall `2.0.0` als Fallbeispiel. `instructions/dev/`,
also nicht in einer ausgelieferten Instanz - die Datei entscheidet, wer den Stack entwickelt.
Die ausgelieferten Artefakte tragen deshalb die Kurzform der Regel, aber keinen Verweis auf
sie: `instructions verify` meldet einen solchen Verweis, weil er nach `dist export` ins Leere
zeigen würde.
**Breaking Changes sind teuer, also genehmigungspflichtig.** `stack-dev` hat einen
Entscheidungspunkt bekommen: stellt sich eine Änderung als nicht drop-in heraus, wird nicht
aus eigener Initiative über die Grenze gebumpt. Vorgelegt werden der konkrete Bruch, was jede
bestehende Instanz von Hand tun muss, und die Alternativen - Bruch per Shim vermeiden,
aufschieben und mit dem nächsten bündeln, oder in einen kompatiblen und einen brechenden Teil
mit Deprecation-Fenster spalten -, dazu eine Empfehlung. Dann Freigabe abwarten.
**`--breaking` ist neu und bei jedem Grenzübertritt Pflicht.** Es schreibt eine
`**Breaking Change:**`-Zeile in den Eintrag, vor die Migrationszeile: das ist, was der Operator
einer bestehenden Instanz beim Lesen der Release-Notes zuerst braucht. `docs verify` prüft den
neuesten grenzüberschreitenden Eintrag darauf, analog zu `**Migration:** none required` und aus
demselben Grund - eine Prosa-Regel driftet, ein Marker nicht. Auf einem kompatiblen Bump wird
`--breaking` verweigert, damit es keine Gewohnheit wird. Beide Prüfungen bleiben getrennt:
`check_migration_for_boundary` fragt, ob der Inhalt sich bewegt, `check_breaking_change_for_boundary`,
ob dem Operator gesagt wurde, dass der Tausch keiner zum Drüberkopieren ist. Ein Rebranding
besteht die erste und fällt durch die zweite.
**Was ausdrücklich nicht passiert:** `docs verify` prüft weiterhin nicht, *ob die Stelle
richtig gewählt* wurde. Kein Validator unterscheidet ein umbenanntes Flag von einem neuen; das
bleibt Urteil, und dieser Eintrag existiert, weil das Urteil eine Anleitung braucht, keinen
Automaten.
Berührt: `instructions/dev/version-parts.md` (neu), `instructions/dev/stack-dev/SKILL.md`,
`tools/chemenu/version.py`, `tools/chemenu/commands/version_cmd.py`,
`tools/chemenu/commands/docs_verify.py`, `tools/CONTRACT.md`, `INSTALL.md`. Sechs neue Tests.
---
## 2.4.1 - 2026-09-02 - INSTALL-MCP.md: Menschendoku fuer den Leseserver, und sie wird mit ausgeliefert ## 2.4.1 - 2026-09-02 - INSTALL-MCP.md: Menschendoku fuer den Leseserver, und sie wird mit ausgeliefert
**Author:** Torben Nehmer **Author:** Torben Nehmer
+15 -2
View File
@@ -146,8 +146,21 @@ nicht erreichbarer Feed wird als Fehler gemeldet - **nie** als „aktuell".
**Was die Versionsnummer aussagt:** kompatibel ist, was in der *linkesten von Null **Was die Versionsnummer aussagt:** kompatibel ist, was in der *linkesten von Null
verschiedenen Stelle* übereinstimmt. `0.1.3 → 0.1.4` ist ein sicheres Update, `0.1.3 → 0.2.0` verschiedenen Stelle* übereinstimmt. `0.1.3 → 0.1.4` ist ein sicheres Update, `0.1.3 → 0.2.0`
verlangt eine Migration, und ab `1.0.0` liest sich dieselbe Regel als das gewohnte „MAJOR heißt nicht, und ab `1.0.0` liest sich dieselbe Regel als das gewohnte „MAJOR bricht". `version check`
Migration". `version check` sagt das direkt (`state: update` vs. `state: migration`). sagt das direkt (`state: update` vs. `state: migration`).
Was diese Stelle beantwortet, ist **ob die neue Version ein Drop-in-Ersatz ist** - ob sich die
Maschinerie einfach darüberkopieren lässt und ob die alte danach noch zurückkann. Ob *Inhalt*
migriert werden muss, ist eine **zweite, unabhängige Frage**. Ein MAJOR-Sprung kann eine leere
Migrationskette haben und trotzdem Handarbeit verlangen: umbenannter Release-Feed, umbenanntes
Artefakt, umbenannter Import- oder Kommandoname, geänderte Envvar - `kb/` bleibt dabei
unangetastet, das Update ist trotzdem keins zum Drüberkopieren. Der Abschnitt „Sonderfall:
Update von 1.x auf 2.0.0" unten ist genau dieser Fall.
Deshalb stehen in den Release-Notes eines MAJOR zwei getrennte Zeilen, und beide sind vor dem
Update zu lesen: **Breaking Change:** sagt, was aufhört zu funktionieren und was diese Instanz
dagegen tun muss; **Migration:** sagt, ob und wie der Korpus umgeschrieben wird (`none required`,
wenn nicht). `tools/wikitool version notes` druckt den Eintrag.
### Eine Instanz aktualisieren ### Eine Instanz aktualisieren
+1 -1
View File
@@ -1 +1 @@
2.4.1 2.5.0
+25 -7
View File
@@ -48,6 +48,9 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately [testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
empty machine; what the autouse fixture already neutralizes, and what a test still has to empty machine; what the autouse fixture already neutralizes, and what a test still has to
establish itself. Read it before adding or changing a test. establish itself. Read it before adding or changing a test.
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
put in front of the user before a breaking bump. Read it before step 3.
More instructions are added here incrementally as stack-development needs come up - this More instructions are added here incrementally as stack-development needs come up - this
list grows without needing this skill file to change shape. list grows without needing this skill file to change shape.
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`, 3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
@@ -59,18 +62,27 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
``` ```
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify` Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
fails a tree where they disagree. Pick the part by what an existing instance would have to do: fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
replacement** for the old one - not by whether content has to be migrated:
| Change | Part | | Change | Part |
|--------|------| |--------|------|
| Fix, no interface change | `--patch` | | Fix, no interface change | `--patch` |
| New capability, backwards compatible | `--minor` | | New capability, still drop-in in both directions | `--minor` |
| **Existing content must be migrated** | `--major` | | **Not a drop-in replacement** - any hand-work by the user or a migration script, or a downgrade that no longer works | `--major` |
A `--major` bump additionally needs a migration document for the new version - written per Content migration is one way to land in the last row, not the definition of it: a rename of
[migrate-corpus.md](../../migrate-corpus.md) - or `--no-migration "<reason>"` when no content the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
actually has to change. `bump` refuses otherwise, and so does `docs verify`: an instance entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
learning that it must migrate, with nothing telling it how, is a dead end. the user first are in [version-parts.md](../version-parts.md) - **read it before choosing
`--major`.**
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
is required on every boundary-crossing bump; on top of it, a migration document for the new
version - written per [migrate-corpus.md](../../migrate-corpus.md) - or
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
telling it how, is a dead end.
Then write the entry's body - `bump` deliberately leaves it empty, the same way `new` leaves Then write the entry's body - `bump` deliberately leaves it empty, the same way `new` leaves
the prose. the prose.
@@ -90,6 +102,12 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
- **Touches both stack code and wiki content in one session?** Apply this skill's rules to the - **Touches both stack code and wiki content in one session?** Apply this skill's rules to the
code changes and the normal content skills' rules to the content changes - they are not code changes and the normal content skills' rules to the content changes - they are not
mutually exclusive within a session, only per change. mutually exclusive within a session, only per change.
- **The change turns out not to be a drop-in replacement?** Do not bump across the boundary on
your own initiative. Every existing instance pays for a breaking change once, by hand, so the
user decides whether it is worth that: show them what breaks, what an instance has to do about
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
[version-parts.md](../version-parts.md) step 4 has the full shape.
## Scope ## Scope
+149
View File
@@ -0,0 +1,149 @@
---
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.
## 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 a boundary-crossing bump.** It is expensive in a way the
other two parts are not: every existing instance pays for it, once, by hand. Put in front of
them, 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 bump itself.** A boundary-crossing bump 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
```
`--breaking` is refused on a bump that crosses nothing, and required on 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). `bump` finds it by its `migrates_to:` field.
- 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.
7. **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.
+2 -2
View File
@@ -74,7 +74,7 @@ tools/wikitool <command> --help
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** | | `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate | | `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate | | `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
| `version bump --major\|--minor\|--patch --title "<...>" [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR requires instances to migrate. A MAJOR bump additionally requires a migration document targeting the new version, or `--no-migration "<reason>"`, which records the reason in the entry | | `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind. Read-only and **exempt from the Iteration Budget Gate** | | `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind. Read-only and **exempt from the Iteration Budget Gate** |
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every document whose `migrates_to` lies in `(kb_version, VERSION]`. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate | | `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every document whose `migrates_to` lies in `(kb_version, VERSION]`. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate | | `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
@@ -190,7 +190,7 @@ is atomic, and whether a retry is safe.
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand | | `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry | | `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo | | `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump with neither a migration document nor `--no-migration`, or `--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying | | `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise | | `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it | | `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a version that is not the next link in the chain | Yes - single file write | **Not idempotent**: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order | | `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a version that is not the next link in the chain | Yes - single file write | **Not idempotent**: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order |
+38
View File
@@ -473,6 +473,43 @@ def check_migration_for_boundary() -> list[str]:
] ]
def check_breaking_change_for_boundary() -> list[str]:
"""A version that crosses the compatibility boundary must say what breaks.
Separate from `check_migration_for_boundary`, because the two answer
different questions: that one asks whether the *content* has to move, this
one whether the operator was told the swap is not drop-in at all. A
boundary crossing with an untouched corpus - a renamed feed, artefact,
import name or flag - satisfies that check and still leaves every existing
instance with something to do by hand.
Only the newest entry is checked, for the same reason: older crossings are
history, and re-reporting them forever would make the check noise.
"""
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
version_path = config.ROOT / version_mod.VERSION_FILENAME
if not changes_path.is_file() or not version_path.is_file():
return [] # already reported by check_version_changelog
text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text)
previous = _second_changes_version(text)
if current is None or previous is None:
return [] # the first versioned entry has no predecessor to cross from
if current.compat_key == previous.compat_key:
return []
if version_mod.BREAKING_CHANGE_MARKER in (version_mod.changes_section(text, current) or ""):
return []
return [
f"{current} crosses the compatibility boundary from {previous}, so it is not a drop-in "
f"replacement - but its {version_mod.CHANGES_FILENAME} entry carries no "
f"`{version_mod.BREAKING_CHANGE_MARKER}` line saying what stops working. Add it "
"(`version bump --breaking` writes it; see instructions/dev/version-parts.md)"
]
@app.command("verify") @app.command("verify")
def verify(): def verify():
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, and version/changelog agreement.""" """Check the CLI/README command tables, contract presence, type-form drift, ignore rules, and version/changelog agreement."""
@@ -484,6 +521,7 @@ def verify():
+ check_ignored_content() + check_ignored_content()
+ check_version_changelog() + check_version_changelog()
+ check_migration_for_boundary() + check_migration_for_boundary()
+ check_breaking_change_for_boundary()
) )
if issues: if issues:
+31 -4
View File
@@ -189,6 +189,11 @@ def bump_command(
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"), minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"), patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"), title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
breaking: Optional[str] = typer.Option(
None,
"--breaking",
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
),
no_migration: Optional[str] = typer.Option( no_migration: Optional[str] = typer.Option(
None, None,
"--no-migration", "--no-migration",
@@ -203,10 +208,14 @@ def bump_command(
frontmatter and leaves the prose. `docs verify` afterwards enforces that frontmatter and leaves the prose. `docs verify` afterwards enforces that
the two agree, so a bump with no entry cannot reach a release. the two agree, so a bump with no entry cannot reach a release.
A bump that crosses the compatibility boundary additionally requires a A bump that crosses the compatibility boundary - one whose new version is
migration document for the new version, or `--no-migration "<reason>"`. not a drop-in replacement, whether or not any content moves - requires
An instance learning that it must migrate, with nothing telling it how, is `--breaking "<what stops working>"`, and on top of that either a migration
the gap this closes.""" document for the new version or `--no-migration "<reason>"`. An instance
learning that it must migrate, with nothing telling it what broke or how to
cross, is the gap these close. Which part to pass stays a judgment call
this command does not make - it enforces only that a crossing says what it
costs."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen] selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1: if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch") fail("Pass exactly one of --major / --minor / --patch")
@@ -239,6 +248,23 @@ def bump_command(
crossing = new_version.compat_key != current.compat_key crossing = new_version.compat_key != current.compat_key
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else "" boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
if crossing and not breaking:
fail(
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
f"instance must do about it>\".\n"
f"If that sentence is hard to write because nothing actually breaks - no hand-work "
f"on update, and the old version can still be put back - then the bump is probably "
f"not --{selected[0]}."
)
return
if breaking and not crossing:
fail(
f"--breaking only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if crossing and not no_migration: if crossing and not no_migration:
from chemenu import kb_state from chemenu import kb_state
@@ -267,6 +293,7 @@ def bump_command(
version_mod.insert_changes_entry( version_mod.insert_changes_entry(
text, new_version, today_iso(), title.strip(), author, text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None, no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
), ),
encoding="utf-8", encoding="utf-8",
) )
+37
View File
@@ -237,6 +237,43 @@ def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
assert docs_verify.check_migration_for_boundary() == [] assert docs_verify.check_migration_for_boundary() == []
def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkeypatch):
"""A crossing that migrates nothing still leaves hand-work behind, so the
migration check passing is not evidence that anyone was told."""
from chemenu import version as version_mod
_boundary_tree(
tmp_path, monkeypatch, "2.0.0", "1.4.0",
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
)
assert docs_verify.check_migration_for_boundary() == []
issues = docs_verify.check_breaking_change_for_boundary()
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
assert docs_verify.check_breaking_change_for_boundary() == []
def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
from chemenu import version as version_mod
_boundary_tree(
tmp_path, monkeypatch, "2.0.0", "1.4.0",
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
)
assert docs_verify.check_breaking_change_for_boundary() == []
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
monkeypatch.setattr(
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
)
with pytest.raises(typer.Exit):
docs_verify.verify()
def test_verify_raises_when_a_boundary_has_no_migration(monkeypatch): def test_verify_raises_when_a_boundary_has_no_migration(monkeypatch):
monkeypatch.setattr(docs_verify, "check_migration_for_boundary", lambda: ["unbridged"]) monkeypatch.setattr(docs_verify, "check_migration_for_boundary", lambda: ["unbridged"])
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
+50 -8
View File
@@ -183,7 +183,7 @@ def test_insert_changes_entry_lands_above_the_newest_entry():
def test_bump_writes_both_the_version_and_the_changelog_heading(tree): def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=True, patch=False, title="Something happened", major=False, minor=True, patch=False, title="Something happened",
no_migration=None, dry_run=False, breaking=None, no_migration=None, dry_run=False,
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8") changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -194,7 +194,7 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
def test_bump_dry_run_writes_nothing(tree): def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", no_migration=None, dry_run=True major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8") assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -207,7 +207,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
major, minor, patch = flags major, minor, patch = flags
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.bump_command( version_cmd.bump_command(
major=major, minor=minor, patch=patch, title="x", no_migration=None, dry_run=False major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, dry_run=False
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -215,7 +215,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
def test_bump_refuses_an_empty_title(tree): def test_bump_refuses_an_empty_title(tree):
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=False, patch=True, title=" ", no_migration=None, dry_run=False major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, dry_run=False
) )
@@ -227,7 +227,7 @@ def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
) )
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=False, patch=True, title="x", no_migration=None, dry_run=False major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, dry_run=False
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -241,7 +241,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.bump_command( version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking", major=True, minor=False, patch=False, title="Breaking",
no_migration=None, dry_run=False, breaking="the feed moved", no_migration=None, dry_run=False,
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -255,7 +255,8 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
encoding="utf-8", encoding="utf-8",
) )
version_cmd.bump_command( version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking", no_migration=None, dry_run=False major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
) )
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0" assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
@@ -263,6 +264,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
def test_no_migration_records_the_reason_in_the_changelog(tree): def test_no_migration_records_the_reason_in_the_changelog(tree):
version_cmd.bump_command( version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking", major=True, minor=False, patch=False, title="Breaking",
breaking="the release feed moved",
no_migration="no distributed instance exists yet", dry_run=False, no_migration="no distributed instance exists yet", dry_run=False,
) )
changes = (tree / "CHANGES.md").read_text(encoding="utf-8") changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -275,10 +277,50 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix", major=False, minor=False, patch=True, title="Fix",
no_migration="not needed", dry_run=False, breaking=None, no_migration="not needed", dry_run=False,
) )
# --- version bump: the breaking-change note --------------------------------
def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
"""The corpus question and the drop-in question are independent: a bump
can migrate nothing and still leave every instance with hand-work."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking=None, no_migration="kb/ keeps its shape", dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_breaking_records_what_stops_working_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking="update_url points at a repo path that no longer exists",
no_migration="kb/ keeps its shape", dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
assert "update_url points at a repo path that no longer exists" in changes
# The break comes before the migration note: it is what an operator acts on.
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(
version_mod.MIGRATION_NONE_MARKER
)
def test_breaking_is_refused_on_a_compatible_bump(tree):
"""A compatible bump that claims a break is describing itself wrongly -
one of the two is a mistake, and the command will not guess which."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="New command",
breaking="nothing, really", no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
# --- version notes --------------------------------------------------------- # --- version notes ---------------------------------------------------------
+32 -8
View File
@@ -10,11 +10,23 @@ ingest into a release.
**Compatibility is read off the leftmost non-zero component**, the rule Cargo's **Compatibility is read off the leftmost non-zero component**, the rule Cargo's
caret ranges use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from caret ranges use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from
`1.0.0` on the same rule reads as the familiar "MAJOR means migration". Stating `1.0.0` on the same rule reads as the familiar "MAJOR breaks". Stating it that
it that way is what lets the 0.x era carry the migration signal at all - under way is what lets the 0.x era carry the signal at all - under a rule keyed to
plain "MAJOR breaks" semantics every 0.x release would be indistinguishable the MAJOR component alone, every 0.x release would be indistinguishable from
from every other, which is exactly the signal update detection needs. Nothing every other, which is exactly the signal update detection needs. Nothing about
about the mechanism changes at 1.0.0. the mechanism changes at 1.0.0.
What that component answers is **whether the new version is a drop-in
replacement**: whether an instance can copy the new machinery over itself with
no hand-work and still put the old version back afterwards. Whether *content*
must be migrated is a **second, independent question**. It is one way to fail
the first - but a renamed release feed, artefact, import name, flag or envvar
fails it too, with `kb/` untouched, which is why `--no-migration` exists at all:
boundary-crossing bumps that migrate nothing are a real case, not an escape
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
records the break, `MIGRATION_NONE_MARKER` records the absence of the
migration. Which part a change earns stays a judgment call made before the
bump; this module only enforces that a crossing says what it costs.
Paths are resolved through `config.ROOT` at call time rather than at import, Paths are resolved through `config.ROOT` at call time rather than at import,
because the tests (and `dist export`'s own fixtures) relocate the root. because the tests (and `dist export`'s own fixtures) relocate the root.
@@ -64,6 +76,11 @@ _SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
# boundary that needs no content migration. `docs verify` accepts it in place # boundary that needs no content migration. `docs verify` accepts it in place
# of a migration document, so the exact string is a contract between the two. # of a migration document, so the exact string is a contract between the two.
MIGRATION_NONE_MARKER = "**Migration:** none required" MIGRATION_NONE_MARKER = "**Migration:** none required"
# Written into every CHANGES.md entry whose version crosses a compatibility
# boundary, migration or not: the swap is not drop-in, and the operator of an
# existing instance has to be told what stops working. `docs verify` checks the
# newest crossing carries it, so this string too is a contract between the two.
BREAKING_CHANGE_MARKER = "**Breaking Change:**"
# A changelog entry that names a version. Entries predating versioning start # A changelog entry that names a version. Entries predating versioning start
# with a date instead and are deliberately not matched - they are history, not # with a date instead and are deliberately not matched - they are history, not
# a claim about which version the tree is. # a claim about which version the tree is.
@@ -332,15 +349,22 @@ def insert_changes_entry(
title: str, title: str,
author: str, author: str,
no_migration_reason: Optional[str] = None, no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
) -> str: ) -> str:
"""Add a heading for `version` above the newest existing entry. """Add a heading for `version` above the newest existing entry.
Only the skeleton: heading, date, author, and - when a compatibility Only the skeleton: heading, date, author, and - when a compatibility
boundary is crossed without a migration - the line that says so. The boundary is crossed - the line saying what breaks, plus the line saying no
entry's actual content is written afterwards by whoever made the change, content has to change where that applies. The entry's actual content is
which is also why `bump` refuses to invent a title. written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
The break comes first: it is what an operator reading the release notes has
to act on, and the migration line only qualifies it.
""" """
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""] lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason: if no_migration_reason:
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""] lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
entry = "\n".join(lines) + "\n---\n\n" entry = "\n".join(lines) + "\n---\n\n"