diff --git a/CHANGES.md b/CHANGES.md index d21f03e..83cec3a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 **Author:** Torben Nehmer diff --git a/INSTALL.md b/INSTALL.md index 9401493..f2a2737 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 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 -Migration". `version check` sagt das direkt (`state: update` vs. `state: migration`). +nicht, und ab `1.0.0` liest sich dieselbe Regel als das gewohnte „MAJOR bricht". `version check` +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 diff --git a/VERSION b/VERSION index 005119b..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.1 +2.5.0 diff --git a/instructions/dev/stack-dev/SKILL.md b/instructions/dev/stack-dev/SKILL.md index bf6aa01..df9d0c5 100644 --- a/instructions/dev/stack-dev/SKILL.md +++ b/instructions/dev/stack-dev/SKILL.md @@ -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 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. + [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 list grows without needing this skill file to change shape. 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` - 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 | |--------|------| | Fix, no interface change | `--patch` | - | New capability, backwards compatible | `--minor` | - | **Existing content must be migrated** | `--major` | + | New capability, still drop-in in both directions | `--minor` | + | **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 - [migrate-corpus.md](../../migrate-corpus.md) - or `--no-migration ""` when no content - actually has to change. `bump` refuses otherwise, and so does `docs verify`: an instance - learning that it must migrate, with nothing telling it how, is a dead end. + Content migration is one way to land in the last row, not the definition of it: a rename of + the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/` + entirely untouched. The full test, the catalogue of such breaks, and what to put in front of + 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 ""` + 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 ""` 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 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 code changes and the normal content skills' rules to the content changes - they are not 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 diff --git a/instructions/dev/version-parts.md b/instructions/dev/version-parts.md new file mode 100644 index 0000000..068fbaa --- /dev/null +++ b/instructions/dev/version-parts.md @@ -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 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 ""`, which writes a `**Breaking Change:**` line into the entry: + + ```bash + tools/wikitool version bump --major \ + --title "" \ + --breaking "" \ + --no-migration "" # 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 ""`, 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. diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 71feec7..a98ae2a 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -74,7 +74,7 @@ tools/wikitool --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 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 bump --major\|--minor\|--patch --title "<...>" [--no-migration ""] [--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 ""`, which records the reason in the entry | +| `version bump --major\|--minor\|--patch --title "<...>" [--breaking ""] [--no-migration ""] [--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 ""`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration ""`; 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 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 [--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 `` 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 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 ` 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 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 | diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index 7a70f52..1730d5a 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -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") def verify(): """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_version_changelog() + check_migration_for_boundary() + + check_breaking_change_for_boundary() ) if issues: diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index 8f82cb0..9a573b6 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -189,6 +189,11 @@ def bump_command( minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets 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"), + 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( None, "--no-migration", @@ -203,10 +208,14 @@ def bump_command( frontmatter and leaves the prose. `docs verify` afterwards enforces that the two agree, so a bump with no entry cannot reach a release. - A bump that crosses the compatibility boundary additionally requires a - migration document for the new version, or `--no-migration ""`. - An instance learning that it must migrate, with nothing telling it how, is - the gap this closes.""" + A bump that crosses the compatibility boundary - one whose new version is + not a drop-in replacement, whether or not any content moves - requires + `--breaking ""`, and on top of that either a migration + document for the new version or `--no-migration ""`. 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] if len(selected) != 1: fail("Pass exactly one of --major / --minor / --patch") @@ -239,6 +248,23 @@ def bump_command( crossing = new_version.compat_key != current.compat_key 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 \"\".\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: from chemenu import kb_state @@ -267,6 +293,7 @@ def bump_command( version_mod.insert_changes_entry( text, new_version, today_iso(), title.strip(), author, no_migration_reason=no_migration.strip() if no_migration else None, + breaking_reason=breaking.strip() if breaking else None, ), encoding="utf-8", ) diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index f069581..85450d8 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -237,6 +237,43 @@ def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch): 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): monkeypatch.setattr(docs_verify, "check_migration_for_boundary", lambda: ["unbridged"]) with pytest.raises(typer.Exit): diff --git a/tools/chemenu/tests/test_version_cmd.py b/tools/chemenu/tests/test_version_cmd.py index 8fbf921..c7028c2 100644 --- a/tools/chemenu/tests/test_version_cmd.py +++ b/tools/chemenu/tests/test_version_cmd.py @@ -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): version_cmd.bump_command( 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" 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): 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 "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 with pytest.raises(typer.Exit): 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" @@ -215,7 +215,7 @@ def test_bump_demands_exactly_one_part(tree, flags): def test_bump_refuses_an_empty_title(tree): with pytest.raises(typer.Exit): 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): 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" @@ -241,7 +241,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree): with pytest.raises(typer.Exit): version_cmd.bump_command( 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" @@ -255,7 +255,8 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree): encoding="utf-8", ) 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" @@ -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): version_cmd.bump_command( major=True, minor=False, patch=False, title="Breaking", + breaking="the release feed moved", no_migration="no distributed instance exists yet", dry_run=False, ) 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): version_cmd.bump_command( 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 --------------------------------------------------------- diff --git a/tools/chemenu/version.py b/tools/chemenu/version.py index bf775d2..4470755 100644 --- a/tools/chemenu/version.py +++ b/tools/chemenu/version.py @@ -10,11 +10,23 @@ ingest into a release. **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 -`1.0.0` on the same rule reads as the familiar "MAJOR means migration". Stating -it that way is what lets the 0.x era carry the migration signal at all - under -plain "MAJOR breaks" semantics every 0.x release would be indistinguishable -from every other, which is exactly the signal update detection needs. Nothing -about the mechanism changes at 1.0.0. +`1.0.0` on the same rule reads as the familiar "MAJOR breaks". Stating it that +way is what lets the 0.x era carry the signal at all - under a rule keyed to +the MAJOR component alone, every 0.x release would be indistinguishable from +every other, which is exactly the signal update detection needs. Nothing about +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, 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 # of a migration document, so the exact string is a contract between the two. 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 # with a date instead and are deliberately not matched - they are history, not # a claim about which version the tree is. @@ -332,15 +349,22 @@ def insert_changes_entry( title: str, author: str, no_migration_reason: Optional[str] = None, + breaking_reason: Optional[str] = None, ) -> str: """Add a heading for `version` above the newest existing entry. Only the skeleton: heading, date, author, and - when a compatibility - boundary is crossed without a migration - the line that says so. The - entry's actual content is written afterwards by whoever made the change, - which is also why `bump` refuses to invent a title. + boundary is crossed - the line saying what breaks, plus the line saying no + content has to change where that applies. The entry's actual content is + 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}", ""] + if breaking_reason: + lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""] if no_migration_reason: lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""] entry = "\n".join(lines) + "\n---\n\n"