diff --git a/CHANGES.md b/CHANGES.md index 4a6d3ff..f65587f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,61 +1,37 @@ # Changelog This file tracks changes to the **wiki stack itself** - `AGENTS.md`, the -`instructions/` layer, `tools/wikitool`, and the contracts. It is distinct from -`kb/log.md`, which is the audit trail of *wiki content* operations (ingests, -queries, lints, page creates/updates) performed by the LLM against `kb/`. +`instructions/` layer, `tools/wikitool`, and the contracts - one entry per +release, newest first. It is distinct from `kb/log.md`, the audit trail of +*wiki content* operations against `kb/`. Entries below `0.1.0` predate +versioning and carry a date-only heading. -Previously each of `AGENTS.md` and `README.md` carried its own "Version -History" table. Those have been consolidated here so there is one place to -look for "what changed in the tooling/schema, and when." From now on, -document any change to the stack (schema, instructions, `wikitool` commands, -contracts) as a new entry at the top of this file instead of editing inline -version history tables. +`wikitool version bump` opens and updates the running candidate's entry and +`wikitool version release` closes it; the topmost entry is what +`wikitool version notes` prints as the release notes. What an entry contains, +who writes which part, and the size budget it is held to are defined in one +place only: `instructions/dev/version-parts.md` § The candidate model. -Since `0.1.0` an entry's heading also carries the stack version it describes -(`## - - `). `wikitool version bump` writes that -heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest -versioned entry disagree. Entries below `0.1.0` predate versioning and keep -their date-only headings. +--- -Since `4.4.0` the stack carries **one running candidate** between two -releases rather than a fresh version per bump - see -`instructions/dev/version-parts.md`. While a candidate is open its heading -names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and -every bump of that same candidate updates this one entry in place rather than -opening another: the heading's version/date/title move, and the bump's -`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under -the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines - -written and read by `wikitool version bump`, never by hand. +## 8.1.0-beta.1 - 2026-10-06 - Changelog kompakt: ein Absatz pro Thema, Größenbudget, Migrationsverweis -`**Breaking Change:**` accumulates, because one candidate can cross the -compatibility boundary more than once and each crossing is a separate thing an -operator has to act on: one reason stays on the marker line, a second and -further ones move to bullets beneath a bare marker. `**Migration:**` does not - -it answers one yes/no question about the candidate as a whole, so a later -answer replaces the earlier one. +**Author:** Torben Nehmer -That list is graded, not a flat chronological dump: each bump carries an -impact (`--impact high|medium|low`, default `medium`), and the list renders -grouped under `**High/Medium/Low impact**` headings - except when every bump -so far is `medium`, where it stays flat with no headings at all, exactly as -it always did before grading existed. `wikitool version regrade` corrects a -grade after the fact, against a single read of the whole list. Below the -list comes a short summary paragraph, written once at release time, and below -that one `### <bump title>` changeset per bump, in chronological order - -`wikitool version release` refuses to close a candidate that collected two or -more bumps and has no summary there (a one-bump candidate is exempt, since its -single changeset already reads as one). This layering exists because a -long-running candidate's bump list, left flat and ungraded, grows unreadable -as a release announcement - the concrete case that forced it was `5.0.0`, one -entry across roughly 1440 lines. +<!-- wikitool:bumps --> +- Changelog kompakt: ein Absatz pro Thema, Größenbudget, Migrationsverweis +<!-- /wikitool:bumps --> -`wikitool version release` is what closes a candidate: it strips the suffix -and turns the entry into an ordinary, suffix-free one, leaving the bump list, -summary and changesets as the record of what happened. A distributed instance -never sees a `-beta.` version at all (`release.yml` only ever releases a fixed -one), so the suffix and everything below the heading are a dev-checkout -concern - readable here, never shipped as something to parse. +### Changelog-Einträge mit Größenbudget und Migrationsverweis (#184) + +Ein Eintrag bekommt `###`-Abschnitte pro Thema statt pro Bump; eine kleinere Änderung steht nur +als Bump-Titel in der Liste. `docs verify` und `version release` halten den obersten Eintrag bei +höchstens 32 000 Bytes und jeden `###`-Abschnitt bei genau einem Absatz von höchstens 1 000 Bytes, +die Überschrift nicht mitgezählt; ältere Einträge bleiben ungeprüft. Zielt ein erforderliches +Migrationsdokument auf die Basis des Kandidaten, schreibt jeder `version bump` die Zeile +`**Migration:** required - <pfad>` anstelle von `none required`, und `docs verify` meldet einen +grenzüberschreitenden Eintrag, der sein Dokument nicht nennt. Die Formregeln stehen nur noch in +`instructions/dev/version-parts.md`. --- diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5f78467..d01653b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -90,11 +90,11 @@ eine Sitzung ihn tatsächlich durchläuft: `medium`) gruppiert den Eintrag; `tools/wikitool version regrade` korrigiert eine Note später, wenn der Gesamteindruck des Kandidaten den Blick auf einen früheren Bump ändert. -2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading, - Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf. - Breaking-/Migration-Zeile). Darunter kommen zwei Autorenanteile: eine kurze Zusammenfassung - (ein paar Sätze, worum es in diesem Release geht) direkt unter der Liste, und darunter je Bump - ein eigener `### <Bump-Titel>`-Changeset-Absatz. Details dazu in +2. **Der Eintrag bekommt seine Prosa - nur wo sie nötig ist.** `bump` schreibt das Skelett + (Heading, Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf. + Breaking- und Migrationszeile). Für eine kleinere Änderung genügt ihr Bump-Titel; eine größere + bekommt einen Absatz zu ihrem Thema, und vor dem Release kommt eine kurze Zusammenfassung + dazu. Form und Größenbudget, das `docs verify` und `version release` prüfen, stehen in [instructions/dev/version-parts.md](instructions/dev/version-parts.md) § The candidate model. 3. **Verify laufen lassen, bevor irgendetwas gepublished wird:** @@ -115,7 +115,8 @@ eine Sitzung ihn tatsächlich durchläuft: optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat, der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der Kandidat zwei oder mehr Bumps gesammelt hat und die Zusammenfassung aus Schritt 2 noch fehlt - - ein Kandidat mit genau einem Bump ist davon ausgenommen. Committet und pusht nichts + ein Kandidat mit genau einem Bump ist davon ausgenommen -, und ebenso, wenn der Eintrag sein + Größenbudget überschreitet. Committet und pusht nichts (Invariante 5 in [AGENTS.md](AGENTS.md)). 5. **Publish bewegt `VERSION` auf `main`.** @@ -136,7 +137,9 @@ eine Sitzung ihn tatsächlich durchläuft: **CI setzt den Tag, nie eine Sitzung** - das hält Invariante 5 intakt. Die Release-Notiz ist der `CHANGES.md`-Eintrag; Gitea speichert auf MySQL höchstens 65535 - Bytes, und der Job verweigert ab 60000, bevor er einen Tag anlegt. Scheitert der Job, nachdem + Bytes, und der Job verweigert ab 60000, bevor er einen Tag anlegt. Das ist nur das letzte + Netz: `docs verify` hält den obersten Eintrag schon während des Kandidaten bei höchstens + 32000 Bytes (Schritt 2). Scheitert der Job, nachdem `VERSION` schon auf `main` steht, hilft weder ein Push (die Version steigt nicht noch einmal) noch ein Re-run (er nimmt die Workflow-Datei des gescheiterten Commits): Ursache beheben, publishen und `release.yml` per `workflow_dispatch` auf `main` starten. Die Prüfung auf ein diff --git a/VERSION b/VERSION index ae9a76b..571d032 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0 +8.1.0-beta.1 diff --git a/instructions/dev/stack-build/SKILL.md b/instructions/dev/stack-build/SKILL.md index b81a32e..17113eb 100644 --- a/instructions/dev/stack-build/SKILL.md +++ b/instructions/dev/stack-build/SKILL.md @@ -59,8 +59,10 @@ session, or a fresh one after `/clear` - assume the second, and work from the bo model. Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify` - fails a tree where they disagree. Then write the entry's body - `bump` deliberately leaves it - empty, the same way `new` leaves the prose. A `--major` bump needs `--breaking` and either a + fails a tree where they disagree. Then decide the entry's prose: for a smaller change its bump + title is the whole entry; a larger one gets a one-paragraph `###` section for its topic, or + rewrites the one its topic already has. The form and the budget `docs verify` holds it to are + `instructions/dev/version-parts.md` § The candidate model. A `--major` bump needs `--breaking` and either a migration document or `--no-migration`; `instructions/dev/version-parts.md` has all of it. Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/` diff --git a/instructions/dev/version-parts.md b/instructions/dev/version-parts.md index 0c219c0..7ce638b 100644 --- a/instructions/dev/version-parts.md +++ b/instructions/dev/version-parts.md @@ -70,22 +70,37 @@ a new one, and only `version release` turns it into something the release workfl reason on the marker line, bullets under a bare marker from the second onward - because a long-running candidate can break compatibility more than once and each break is its own thing to act on. The migration line does not: it answers one yes/no about the candidate as - a whole, and `--migration-required` is its retraction path. Nothing retracts a breaking - reason; a wrong one is rare enough, and the candidate is dev-local until release. + a whole, in one of two forms that never stand side by side. Where a required migration + document targets the candidate's base, every bump writes + `**Migration:** required - instructions/migrations/<file>.md` (several paths sorted and + comma-separated) - the pointer from the release notes to what an operator has to run; + otherwise `--no-migration` writes `**Migration:** none required - <reason>`, and + `--migration-required` is its retraction path. Nothing retracts a breaking reason; a wrong + one is rare enough, and the candidate is dev-local until release. 2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by `version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`. Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the - common case, and the shape every pre-existing region still is. + common case, and the shape every pre-existing region still is. **This list is the bullet + level:** a smaller change gets nothing in the entry but its bump title. 3. **The release summary** - a short paragraph, written once, by hand, when the candidate is ready to ship. `version release` refuses to close an entry with two or more bumps and no - summary here; a one-bump entry is exempt, since there the bump's own changeset already reads - as the summary. - 4. **The changesets**, one `### <bump title>` heading per bump, in chronological order - the - detail a reader follows into from the graded list above. A changeset is a few sentences, - not the full rationale; what needs more than that belongs in the issue tracker, not here. + summary here; a one-bump entry is exempt, since there its own paragraph already reads as + the summary. + 4. **One `###` section per topic, not per bump** - for the larger changes only. A topic is + what a reader would call one change; it often gathers several bumps, and its heading names + the topic rather than repeating a bump title. A later bump on the same topic rewrites that + section's paragraph instead of adding a second one. Whether a change earns a section or + stays a bullet is the build session's judgment, with no tie to `--impact`. - The list is the index into the changesets, which is why the bump list's title text and a - changeset's `###` heading are the same string. + **The budget, checked by `docs verify` and again by `version release` before it writes:** the + topmost versioned entry is at most 32 000 bytes (UTF-8) as `version notes` prints it, and every + `###` section is **exactly one paragraph** - no blank line in its body - of at most 1 000 bytes, + its `###` line excluded. A body runs to the next `### ` or `## ` line or the `---` separator; + blank lines at either end do not count. The total is there because an entry grows with every + bump: the 8.0.0 candidate collected 81 bumps, one changeset each, reached 129 KB and failed as + a release body, and no per-section rule alone would have stopped that. Older entries are not + held to it - their release notes are already published. What needs more than one paragraph - + the reasoning, the alternatives, the measurements - belongs in the issue, not in the entry. - **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release` strips the suffix and turns the entry into a real, closed release - see its own row in `tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own. @@ -181,7 +196,8 @@ the three-line test below is usually enough. - Content must change → write the migration document under `instructions/migrations/` per [migrate-corpus.md](../migrate-corpus.md). The escalation bump finds it by the document's `migrates_to:` field, matched against the candidate's **base** - a document targets the - release the candidate will become, never a `-beta.N` form of it. + release the candidate will become, never a `-beta.N` form of it - and names a required one + in the entry's migration line. - Content need not change → `--no-migration "<reason>"`, which records that in the entry. Both are also needed by `docs verify`, for the same reason: an instance that learns it must @@ -190,14 +206,15 @@ the three-line test below is usually enough. **A `--no-migration` answer can turn out wrong later in the same candidate**, and that is not a hand-edit: a bump escalates, a second change lands under the same running number, and now - content does have to move after all. Write the migration document first, then retract the line - with `version bump --migration-required` - it removes the `**Migration:** none required` line - the earlier bump wrote, and refuses unless a document already targets the new base. Nothing - else takes that statement back: the line is machine-written (invariant 1), `docs verify` is - satisfied by its bare presence, and the escalation checks in this step run only on the bump - that *first* crosses the boundary - so a candidate that keeps a stale `none required` line is - never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for - a TOC-verification change, then absorbed a schema removal that migrates 152 pages. + content does have to move after all. Write the migration document first, then run + `version bump --migration-required` - it replaces the `**Migration:** none required` line the + earlier bump wrote with the `required` line naming the document, and refuses unless a required + document already targets the new base. Any later bump would make the same replacement once + the document exists, and `docs verify` reports a crossing entry whose migration line does not + name it; the flag is the explicit form, which also refuses when there is nothing to replace. + `--no-migration` itself is refused while a required document targets the base. The 5.0.0 + candidate is the case this guards: it declared `--no-migration` for a TOC-verification + change, then absorbed a schema removal that migrates 152 pages. 7. **Fix the candidate only when the user asks for a release.** Whether a candidate ships is the user's call, never a session's: a work package being finished is not a reason, since the @@ -215,16 +232,18 @@ the three-line test below is usually enough. candidate collected several bump titles along the way; without one, the heading simply keeps whichever bump last set it. -8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both - empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at - release time, right below the graded bump list: what this release is about, and why, for a - reader who will not read the changesets underneath. `version release` refuses to close an - entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since - there the bump's changeset already reads as one. Each **changeset**, under its own - `### <bump title>` heading, is a few sentences on what changed and why - it is the one thing a - future reader cannot reconstruct from the diff, but it is not the place for the full rationale - of a decision; that belongs in the issue tracker or the commit history, and a changeset that - is growing past a paragraph or two is a sign it belongs there instead. +8. **Write the entry's prose - a section for a larger change, nothing for a smaller one.** `bump` + writes no prose on purpose. Decide per bump: a smaller change is done once its title is in + the bump list. A larger one gets a `###` section for its topic - one paragraph, in the budget + of § The candidate model, on what changed and why, which is the one thing a future reader + cannot reconstruct from the diff. If its topic already has a section, rewrite that paragraph + to cover both bumps rather than adding another. The full rationale of a decision belongs in + the issue tracker or the commit history; a paragraph pressing against its 1 000 bytes is a + sign that part of it belongs there instead. The **summary** is a short paragraph (a few + sentences) written once, at release time, right below the graded bump list: what this + release is about, and why, for a reader who will not read the sections underneath. + `version release` refuses to close an entry that collected two or more bumps and has no + summary - a one-bump entry is exempt, since there its own paragraph already reads as one. ## Decision points diff --git a/instructions/migrate-corpus.md b/instructions/migrate-corpus.md index 2cff4fd..5d8c4e7 100644 --- a/instructions/migrate-corpus.md +++ b/instructions/migrate-corpus.md @@ -114,7 +114,8 @@ declared by a migration document under `instructions/migrations/`. A single page A migration that a distributed instance must also run is a `manual: true` instruction under `instructions/migrations/<version>-<slug>.md`, carrying `migrates_to:` and `migration_kind:`. `tools/wikitool migrate status` builds the outstanding chain from those files, and `version -bump` refuses a compatibility-breaking release that has none. +bump` refuses a compatibility-breaking release that has none - and names a required one by path +in that release's changelog entry, which is where an operator reading the release notes finds it. Write it for a reader who has the new machinery and the old content, and who is not you: what changed, which pages are affected, how to tell a migrated page from an unmigrated one, and what diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 986e58b..455c1fc 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -2504,6 +2504,8 @@ Check the docs that mirror the code. - 0 success - 1 A command, contract, or type-form mismatch +- 1 `VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing entry lacks its breaking line, its migration, or the migration document's path +- 1 The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` sections has more than one paragraph or a paragraph over 1 000 bytes - 1 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale - 1 A type-spec's own frontmatter fails its schema - 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter @@ -2517,6 +2519,8 @@ Check the docs that mirror the code. **ON FAILURE** - A command, contract, or type-form mismatch -> Fix the documentation it names, then re-run +- `VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing entry lacks its breaking line, its migration, or the migration document's path -> Run `version bump`, which writes all of them, or fix whichever is wrong +- The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` sections has more than one paragraph or a paragraph over 1 000 bytes -> Shorten or merge the named sections - a smaller change keeps only its bump title - then re-run - The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale -> Run `docs contract --apply`, then re-run - A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new - A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block @@ -2543,6 +2547,8 @@ Check the docs that mirror the code. - Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check. - Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference. - `INSTALL.md` carries one generated `<!-- wikitool:prerequisites -->` region per platform value of `tools/prerequisites.txt` (`prerequisites-<platform>` for a platform-specific one), each current; and the `<!-- setup-question: <key> -->` markers in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a question the agent asks is never one the human guide leaves out, nor the reverse. +- `VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that crosses the compatibility boundary from the last release carries `**Breaking Change:**`, has a migration document or `**Migration:** none required`, and its `**Migration:**` line names every required migration document targeting it. +- The newest versioned `CHANGES.md` entry is at most 32 000 bytes (UTF-8) as `version notes` prints it, and each of its `###` sections is exactly one paragraph of at most 1 000 bytes, heading line excluded. Older entries and a changelog with no versioned entry are not checked. - Read-only. **SEE ALSO** @@ -3145,7 +3151,8 @@ Raise or continue the one running candidate between two releases. - 1 `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions - 1 An escalation to a boundary crossing without `--breaking`, or with neither a migration document targeting the new base nor `--no-migration` - 1 `--breaking` or `--no-migration` on a bump that crosses nothing -- 1 `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base +- 1 `--no-migration` while a required migration document targets the new base +- 1 `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to replace, or without a required migration document already targeting the new base **ON FAILURE** @@ -3153,12 +3160,13 @@ Raise or continue the one running candidate between two releases. - `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions -> Nothing was written - fix whichever is wrong, then retry - An escalation to a boundary crossing without `--breaking`, or with neither a migration document targeting the new base nor `--no-migration` -> Nothing was written - add what the error asks for, or, if nothing actually breaks, choose a smaller part - `--breaking` or `--no-migration` on a bump that crosses nothing -> Nothing was written - drop the flag and retry -- `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base -> Nothing was written - write the migration document or fix the combination, then retry +- `--no-migration` while a required migration document targets the new base -> Nothing was written - drop `--no-migration`, or remove the document if it is wrong, then retry +- `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to replace, or without a required migration document already targeting the new base -> Nothing was written - write the migration document or fix the combination, then retry **NEVER** - Never re-run after an uncertain outcome without first reading `VERSION` and the top of `CHANGES.md` - a second run escalates or continues the candidate again. -- Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump list, breaking and migration lines); `--migration-required` is the only way to take the migration line back. +- Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump list, breaking and migration lines); `--migration-required` is the only way to take a `none required` line back. **NOTES** @@ -3166,11 +3174,12 @@ Raise or continue the one running candidate between two releases. - `--major`/`--minor`/`--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. - The first bump of a candidate opens its `CHANGES.md` entry - heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact` (default `medium`). Every later bump of the same candidate updates that entry in place: one entry per candidate, not one per bump. - The bump list renders grouped under `**High/Medium/Low impact**` headings, empty groups omitted - except while every bump is `medium`, where it stays one flat list. `version regrade` corrects a grade after the fact. -- Writes the heading, the bump list and the breaking/migration lines; the entry's prose is left to the author. +- Writes the heading, the bump list and the breaking/migration lines; the entry's prose - a summary and one-paragraph `###` sections, one per topic - is left to the author. +- Whenever a required migration document (`migrates_to:` equal to the candidate's base) exists, every bump writes or refreshes `**Migration:** required - <paths>`, the documents' paths sorted and comma-separated, in place of any `none required` line. The two forms never stand side by side. - Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) 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. - The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`. Both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. - On a later crossing of the same candidate, a further `--breaking` **joins** the reasons already recorded (flat on the marker line while there is one, bullets under a bare marker from the second on; repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single migration line. -- `--migration-required` retracts the running candidate's `--no-migration` line; it needs a migration document already targeting the new base. Nothing retracts a recorded `--breaking` reason. +- `--migration-required` replaces the running candidate's `--no-migration` line with the `required` line; it needs a required migration document already targeting the new base. Nothing retracts a recorded `--breaking` reason. - Enforces that a crossing documents itself, never that the part was chosen correctly. - Not idempotent: every successful run escalates or continues the candidate again. - `--dry-run` reports the step without writing. @@ -3256,13 +3265,15 @@ Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHA - 0 success - 1 `VERSION` is already a release - there is no running candidate to fix - 1 `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions -- 1 Two or more bumps and no summary paragraph above the changesets +- 1 Two or more bumps and no summary paragraph above the `###` sections +- 1 The entry is over 32 000 bytes, or a `###` section has more than one paragraph or a paragraph over 1 000 bytes - the error names each with its size **ON FAILURE** - `VERSION` is already a release - there is no running candidate to fix -> After an uncertain run this means it already ran; otherwise there is nothing to release - `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions -> Fix whichever is wrong, then retry -- Two or more bumps and no summary paragraph above the changesets -> Write a short summary paragraph right below the bump list, then retry +- Two or more bumps and no summary paragraph above the `###` sections -> Write a short summary paragraph right below the bump list, then retry +- The entry is over 32 000 bytes, or a `###` section has more than one paragraph or a paragraph over 1 000 bytes - the error names each with its size -> Nothing was written - shorten or merge the named sections (a smaller change keeps only its bump title), then retry **NEVER** @@ -3273,7 +3284,8 @@ Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHA - Strips `VERSION`'s `-beta.N` suffix - the candidate's base becomes the release - and closes the candidate's `CHANGES.md` entry. - Without `--title` the heading keeps whichever bump last set it; `--title` replaces it - the normal case for a candidate that collected several bumps, whose entry wants a summarising heading rather than the most recent one. - Leaves the entry's machine-managed bump list untouched, as the record of what happened. -- From two bumps on, requires a summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading. A candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too. +- From two bumps on, requires a summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `###` section heading. A candidate with exactly one bump is exempt, since there its own paragraph already is the summary. `--dry-run` runs this check too. +- Refuses an entry that would exceed its budget once released: at most 32 000 bytes (UTF-8) as `version notes` prints it, and every `###` section exactly one paragraph of at most 1 000 bytes, heading line excluded. The same check as `docs verify`, run on the released heading; `--dry-run` runs it too. - Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. - Not idempotent: a second run fails once the suffix is gone. diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index df1cf70..44fdadf 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -27,7 +27,9 @@ start committing derived output. A fifth has the same shape as the fourth: `VERSION` is not documentation either, but it is the one number a release stamps into every distributed instance, and a version raised without a changelog entry ships release notes -that describe the previous release. +that describe the previous release. The same entry is also held to a size +budget, because it becomes the release body, and a body that grew with every +bump once failed to publish at all. A sixth checks a *reference* rather than a copy: no document `dist export` ships may cite an issue number, because the board those numbers live on @@ -1059,6 +1061,61 @@ def check_migration_for_boundary() -> list[str]: ] +def check_migration_line_names_documents() -> list[str]: + """A crossing entry's `**Migration:**` line names every required migration + document that targets it. + + `check_migration_for_boundary` is satisfied by a document's bare + existence, so a release could ship with notes that never point at the + procedure an operator has to run. `version bump` writes the pointer; this + holds an entry to it. Same scope as the two boundary checks beside it: the + newest entry, against the last release. + """ + from chemenu import kb_state + + 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 = version_mod.last_release(text) + if current is None or previous is None or current.compat_key == previous.compat_key: + return [] + + named = set(version_mod.migration_paths(version_mod.changes_section(text, current) or "")) + missing = sorted( + m.relative_path + for m in kb_state.load_migrations() + if m.target == current.base and m.is_required and m.relative_path not in named + ) + if not missing: + return [] + return [ + f"{current} crosses the compatibility boundary from {previous}, and " + f"{', '.join(missing)} targets it as a required migration - but its " + f"{version_mod.CHANGES_FILENAME} entry's `{version_mod.MIGRATION_MARKER}` line does not " + "name it. Run `version bump` again (it writes the line), or remove the document if it " + "does not belong to this release" + ] + + +def check_changelog_budget() -> list[str]: + """The topmost versioned `CHANGES.md` entry stays inside its size budget. + + The entry becomes the release body, and the 8.0.0 one failed there at + 129 KB. The rule and its numbers live in `version_mod.budget_issues`, which + `version release` also calls before it writes; this runs it in every + session and in CI, long before a release is cut. Older entries are not + checked - their release notes are already published. + """ + changes_path = config.ROOT / version_mod.CHANGES_FILENAME + if not changes_path.is_file(): + return [] # already reported by check_version_changelog + return version_mod.budget_issues(changes_path.read_text(encoding="utf-8")) + + def check_breaking_change_for_boundary() -> list[str]: """A version that crosses the compatibility boundary must say what breaks. @@ -1146,6 +1203,14 @@ def check_breaking_change_for_boundary() -> list[str]: "platform-specific one), each current; and the `<!-- setup-question: <key> -->` markers " "in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a " "question the agent asks is never one the human guide leaves out, nor the reverse.", + "`VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that " + "crosses the compatibility boundary from the last release carries `**Breaking " + "Change:**`, has a migration document or `**Migration:** none required`, and its " + "`**Migration:**` line names every required migration document targeting it.", + "The newest versioned `CHANGES.md` entry is at most 32 000 bytes (UTF-8) as `version " + "notes` prints it, and each of its `###` sections is exactly one paragraph of at most " + "1 000 bytes, heading line excluded. Older entries and a changelog with no versioned " + "entry are not checked.", "Read-only.", ), failures=( @@ -1153,6 +1218,17 @@ def check_breaking_change_for_boundary() -> list[str]: cause="A command, contract, or type-form mismatch", reaction="Fix the documentation it names, then re-run", ), + cli_contract.Failure( + cause="`VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing " + "entry lacks its breaking line, its migration, or the migration document's path", + reaction="Run `version bump`, which writes all of them, or fix whichever is wrong", + ), + cli_contract.Failure( + cause="The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` " + "sections has more than one paragraph or a paragraph over 1 000 bytes", + reaction="Shorten or merge the named sections - a smaller change keeps only its bump " + "title - then re-run", + ), cli_contract.Failure( cause="The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale", reaction="Run `docs contract --apply`, then re-run", @@ -1228,7 +1304,9 @@ def verify(): + check_ignored_content() + check_version_changelog() + check_migration_for_boundary() + + check_migration_line_names_documents() + check_breaking_change_for_boundary() + + check_changelog_budget() + check_no_issue_references() + check_toc_regions() + check_reference_targets() diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index 37113ff..2217ab5 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -474,7 +474,11 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str "groups omitted - except while every bump is `medium`, where it stays one flat list. " "`version regrade` corrects a grade after the fact.", "Writes the heading, the bump list and the breaking/migration lines; the entry's prose " - "is left to the author.", + "- a summary and one-paragraph `###` sections, one per topic - is left to the author.", + "Whenever a required migration document (`migrates_to:` equal to the candidate's base) " + "exists, every bump writes or refreshes `**Migration:** required - <paths>`, the " + "documents' paths sorted and comma-separated, in place of any `none required` line. " + "The two forms never stand side by side.", "Compatibility follows the **leftmost non-zero component** of the candidate's base, " "which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a " "compatible capability, MAJOR a version that is **not a drop-in replacement** - any " @@ -489,9 +493,9 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str "reasons already recorded (flat on the marker line while there is one, bullets under a " "bare marker from the second on; repeating a reason verbatim is a no-op), while a " "further `--no-migration` **replaces** the single migration line.", - "`--migration-required` retracts the running candidate's `--no-migration` line; it " - "needs a migration document already targeting the new base. Nothing retracts a " - "recorded `--breaking` reason.", + "`--migration-required` replaces the running candidate's `--no-migration` line with " + "the `required` line; it needs a required migration document already targeting the new " + "base. Nothing retracts a recorded `--breaking` reason.", "Enforces that a crossing documents itself, never that the part was chosen correctly.", "Not idempotent: every successful run escalates or continues the candidate again.", "`--dry-run` reports the step without writing.", @@ -517,9 +521,14 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str cause="`--breaking` or `--no-migration` on a bump that crosses nothing", reaction="Nothing was written - drop the flag and retry", ), + cli_contract.Failure( + cause="`--no-migration` while a required migration document targets the new base", + reaction="Nothing was written - drop `--no-migration`, or remove the document if it " + "is wrong, then retry", + ), cli_contract.Failure( cause="`--migration-required` combined with `--no-migration`, on a bump with no " - "running candidate, with no `--no-migration` line to retract, or without a " + "running candidate, with no `--no-migration` line to replace, or without a required " "migration document already targeting the new base", reaction="Nothing was written - write the migration document or fix the " "combination, then retry", @@ -537,7 +546,7 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str "of `CHANGES.md` - a second run escalates or continues the candidate again.", "Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump " "list, breaking and migration lines); `--migration-required` is the only way to take " - "the migration line back.", + "a `none required` line back.", ), see_also=( "`wikitool version regrade` - corrects an `--impact` grade", @@ -602,11 +611,13 @@ def bump_command( `--no-migration` **replaces**: whether content has to change is one question about the candidate as a whole, not one per crossing. - A later bump of the same candidate that finds out `--no-migration` was - wrong after all retracts it with `--migration-required` - write the - migration document first, then re-run with this flag instead of - `--no-migration`. There is no other way to take the line back: it is - machine-written, and invariant 1 forbids hand-editing it.""" + Wherever a required migration document targets the candidate's base, + every bump writes `**Migration:** required - <paths>` - the pointer the + release notes give an operator - and a `none required` line cannot stand + beside it. A later bump that finds out `--no-migration` was wrong after + all states so with `--migration-required`: write the migration document + first, then re-run with this flag instead of `--no-migration`. Both lines + are machine-written, and invariant 1 forbids hand-editing them.""" 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") @@ -669,10 +680,15 @@ def bump_command( ) return - if crossing and not was_already_crossing and not no_migration: - from chemenu import kb_state + from chemenu import kb_state - if not any(m.target == new_version.base for m in kb_state.load_migrations()): + migrations = kb_state.load_migrations() + required_documents = sorted( + m.relative_path for m in migrations if m.target == new_version.base and m.is_required + ) + + if crossing and not was_already_crossing and not no_migration: + if not any(m.target == new_version.base for m in migrations): fail( f"{current} -> {new_version} crosses the compatibility boundary, so every existing " f"instance must migrate - but no migration document targets {new_version.base}.\n" @@ -687,6 +703,13 @@ def bump_command( f"{current} -> {new_version} does not." ) return + if no_migration and required_documents: + fail( + f"--no-migration says no content has to change, but {', '.join(required_documents)} " + f"targets {new_version.base} as a required migration - drop --no-migration (the bump " + "then names the document), or remove the document if it is wrong." + ) + return if migration_required and no_migration: fail("--migration-required and --no-migration contradict each other on the same bump.") @@ -705,12 +728,10 @@ def bump_command( f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do." ) return - from chemenu import kb_state - - if not any(m.target == new_version.base for m in kb_state.load_migrations()): + if not required_documents: fail( - f"--migration-required retracts the no-migration line, so a migration document must " - f"target {new_version.base} first - write one under " + f"--migration-required replaces the no-migration line with one naming the migration " + f"documents, so a required one must target {new_version.base} first - write one under " f"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md " f"(see instructions/migrate-corpus.md), then re-run with --migration-required." ) @@ -726,7 +747,7 @@ def bump_command( 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, - migration_required=migration_required, + migration_documents=required_documents, impact=chosen_impact, ), encoding="utf-8", newline="\n", @@ -759,9 +780,13 @@ def bump_command( "Leaves the entry's machine-managed bump list untouched, as the record of what " "happened.", "From two bumps on, requires a summary paragraph (at least 200 non-whitespace " - "characters) between the bump list and the first `### <bump title>` changeset " - "heading. A candidate with exactly one bump is exempt, since there its own changeset " - "already is the summary. `--dry-run` runs this check too.", + "characters) between the bump list and the first `###` section heading. A candidate " + "with exactly one bump is exempt, since there its own paragraph already is the " + "summary. `--dry-run` runs this check too.", + "Refuses an entry that would exceed its budget once released: at most 32 000 bytes " + "(UTF-8) as `version notes` prints it, and every `###` section exactly one paragraph " + "of at most 1 000 bytes, heading line excluded. The same check as `docs verify`, run " + "on the released heading; `--dry-run` runs it too.", "Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto " "`main`, which `release.yml` reacts to.", "Not idempotent: a second run fails once the suffix is gone.", @@ -778,9 +803,15 @@ def bump_command( reaction="Fix whichever is wrong, then retry", ), cli_contract.Failure( - cause="Two or more bumps and no summary paragraph above the changesets", + cause="Two or more bumps and no summary paragraph above the `###` sections", reaction="Write a short summary paragraph right below the bump list, then retry", ), + cli_contract.Failure( + cause="The entry is over 32 000 bytes, or a `###` section has more than one " + "paragraph or a paragraph over 1 000 bytes - the error names each with its size", + reaction="Nothing was written - shorten or merge the named sections (a smaller change " + "keeps only its bump title), then retry", + ), ), examples=( "tools/wikitool version release --dry-run", @@ -819,10 +850,12 @@ def release_command( Refuses when `VERSION` is already a release: there is no running candidate to fix. Also refuses - Gitea #95 - when the candidate collected two or more bumps and its entry still has no summary paragraph above the - individual changesets: a release note that is only a chronological bump - list is exactly the thing this refusal exists to stop shipping. A - candidate with exactly one bump is exempt - there, the bump's own - changeset already is the summary.""" + `###` sections: a release note that is only a chronological bump list is + exactly the thing this refusal exists to stop shipping. A candidate with + exactly one bump is exempt - there, its own paragraph already is the + summary. And refuses - Gitea #184 - an entry over its size budget + (`version_mod.budget_issues`), checked on the text as released, so the + release body `release.yml` posts is the text that passed.""" try: current = version_mod.read_version() except VersionError as exc: @@ -865,16 +898,21 @@ def release_command( return new_version = current.base + released = version_mod.release_entry(text, today_iso(), title.strip() if title else None) + over_budget = version_mod.budget_issues(released) + if over_budget: + fail( + "The released entry would exceed its size budget - nothing was written:\n" + + "\n".join(f"- {issue}" for issue in over_budget) + ) + return if dry_run: success(f"Dry run: {current} -> {new_version} (release). Nothing written.") return version_mod.write_version(new_version) - changes.write_text( - version_mod.release_entry(text, today_iso(), title.strip() if title else None), - encoding="utf-8", newline="\n", - ) + changes.write_text(released, encoding="utf-8", newline="\n") success( f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the " f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and " diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index 3e99158..24ba0d6 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -623,6 +623,130 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch): assert docs_verify.check_breaking_change_for_boundary() == [] +def _required_migration(root, target: str = "2.0.0", obligation: str = "required") -> str: + path = root / "instructions" / "migrations" / f"{target}-retype.md" + path.write_text( + f"---\ntype: types/instruction.md\nname: {target}-retype\ndescription: Retype.\n" + f"manual: true\nmigrates_to: {target}\nobligation: {obligation}\n---\n", + encoding="utf-8", + ) + return f"instructions/migrations/{target}-retype.md" + + +def test_this_repos_migration_line_names_its_documents(): + assert docs_verify.check_migration_line_names_documents() == [] + + +def test_a_crossing_entry_that_does_not_name_its_migration_document_is_reported( + tmp_path, monkeypatch +): + """The document's bare existence satisfies `check_migration_for_boundary`; + the release notes still have to point an operator at it.""" + from chemenu import version as version_mod + + root = _boundary_tree( + tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0", + marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n", + ) + document = _required_migration(root) + assert docs_verify.check_migration_for_boundary() == [] + issues = docs_verify.check_migration_line_names_documents() + assert any(document in issue and "2.0.0-beta.1" in issue for issue in issues) + + +def test_a_crossing_entry_naming_its_migration_document_passes(tmp_path, monkeypatch): + root = _boundary_tree( + tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0", + marker="**Migration:** required - instructions/migrations/2.0.0-retype.md\n\n", + ) + _required_migration(root) + assert docs_verify.check_migration_line_names_documents() == [] + + +def test_an_offered_migration_document_need_not_be_named(tmp_path, monkeypatch): + root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0") + _required_migration(root, obligation="offered") + assert docs_verify.check_migration_line_names_documents() == [] + + +def test_a_compatible_entry_is_not_held_to_naming_documents(tmp_path, monkeypatch): + root = _boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0") + _required_migration(root, target="1.5.0") + assert docs_verify.check_migration_line_names_documents() == [] + + +# --- the changelog budget (Gitea #184) -------------------------------------- + + +def _budget_tree(tmp_path, monkeypatch, sections: str, older: str = "Body.\n"): + """A changelog whose topmost entry carries `sections` below its summary, + above an older entry carrying `older`.""" + _versioned_tree( + tmp_path, monkeypatch, "2.0.0\n", + "# Changelog\n\nPreamble.\n\n---\n\n" + f"## 2.0.0 - 2026-10-06 - New\n\n**Author:** Someone\n\nSummary.\n{sections}\n---\n\n" + f"## 1.0.0 - 2026-09-01 - Old\n\n**Author:** Someone\n\n{older}", + ) + + +def test_this_repos_newest_changelog_entry_is_inside_its_budget(): + """The 8.0.0 entry, condensed by hand to 22 KB in 15 one-paragraph + sections (largest 895 bytes), is the reference shape - it must pass.""" + assert docs_verify.check_changelog_budget() == [] + + +def test_an_entry_over_32000_bytes_is_reported(tmp_path, monkeypatch): + sections = "".join(f"\n### Topic {n}\n\n{'x' * 900}\n" for n in range(40)) + _budget_tree(tmp_path, monkeypatch, sections) + issues = docs_verify.check_changelog_budget() + assert len(issues) == 1 + assert "2.0.0" in issues[0] and "32000" in issues[0] + + +def test_a_section_with_two_paragraphs_is_reported(tmp_path, monkeypatch): + _budget_tree(tmp_path, monkeypatch, "\n### Two\n\nFirst.\n\nSecond.\n") + issues = docs_verify.check_changelog_budget() + assert len(issues) == 1 + assert "### Two" in issues[0] and "2 paragraphs" in issues[0] + + +def test_a_paragraph_over_1000_bytes_is_reported_in_bytes_not_characters(tmp_path, monkeypatch): + """501 characters, 1002 bytes: the limit counts what Gitea's column counts.""" + _budget_tree(tmp_path, monkeypatch, f"\n### Long\n\n{'ä' * 501}\n") + issues = docs_verify.check_changelog_budget() + assert len(issues) == 1 + assert "### Long" in issues[0] and "1002 bytes" in issues[0] + + +def test_a_paragraph_of_exactly_1000_bytes_passes_and_its_heading_is_not_counted( + tmp_path, monkeypatch +): + long_heading = "A heading that is itself quite long " * 3 + _budget_tree( + tmp_path, monkeypatch, + f"\n### {long_heading}\n\n\n{'ä' * 500}\n\n\n### Wrapped\n\nOne paragraph\non two lines.\n", + ) + assert docs_verify.check_changelog_budget() == [] + + +def test_an_older_entry_over_both_limits_is_not_reported(tmp_path, monkeypatch): + older = "".join(f"### Old {n}\n\n{'z' * 1500}\n\nSecond paragraph.\n\n" for n in range(30)) + _budget_tree(tmp_path, monkeypatch, "\n### Fine\n\nShort.\n", older=older) + assert docs_verify.check_changelog_budget() == [] + + +def test_a_changelog_with_no_versioned_entry_has_no_budget_to_check(tmp_path, monkeypatch): + """A distributed instance's stub carries no versioned entry at all.""" + _versioned_tree(tmp_path, monkeypatch, "8.0.0\n", "# Changelog\n\nStub.\n") + assert docs_verify.check_changelog_budget() == [] + + +def test_verify_raises_when_the_changelog_is_over_budget(monkeypatch): + monkeypatch.setattr(docs_verify, "check_changelog_budget", lambda: ["too long"]) + with pytest.raises(typer.Exit): + docs_verify.verify() + + def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch): monkeypatch.setattr( docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"] diff --git a/tools/chemenu/tests/test_version_cmd.py b/tools/chemenu/tests/test_version_cmd.py index 70b4a47..c4c655b 100644 --- a/tools/chemenu/tests/test_version_cmd.py +++ b/tools/chemenu/tests/test_version_cmd.py @@ -654,6 +654,64 @@ def test_release_passes_with_a_single_bump_and_no_summary(tree): assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0" +# --- version release: the size budget (Gitea #184) -------------------------- + + +def _append_sections(tree: Path, *bodies: str) -> None: + """Append `### Topic N` sections to the open candidate's entry, the way + an author writes them below the summary.""" + path = tree / "CHANGES.md" + text = path.read_text(encoding="utf-8") + top_start = text.index("## 1.1.0-beta.") + next_entry = text.index("\n---\n", top_start) + sections = "".join(f"\n### Topic {n}\n\n{body}\n" for n, body in enumerate(bodies, start=1)) + path.write_text(text[:next_entry] + sections + text[next_entry:], encoding="utf-8") + + +@pytest.mark.parametrize( + "bodies", + [ + ("x" * 900,) * 40, # every paragraph fits, the entry does not + ("First paragraph.\n\nSecond paragraph.",), + ("y" * 1001,), + ], + ids=["entry-over-32000", "two-paragraphs", "paragraph-over-1000"], +) +def test_release_refuses_an_entry_over_budget_and_writes_nothing(tree, bodies): + version_cmd.bump_command( + major=False, minor=True, patch=False, title="Only bump", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + _append_sections(tree, *bodies) + version_before = (tree / "VERSION").read_bytes() + changes_before = (tree / "CHANGES.md").read_bytes() + with pytest.raises(typer.Exit) as excinfo: + version_cmd.release_command(title=None, dry_run=False) + assert excinfo.value.exit_code == 1 + assert (tree / "VERSION").read_bytes() == version_before + assert (tree / "CHANGES.md").read_bytes() == changes_before + + +def test_release_dry_run_also_refuses_an_entry_over_budget(tree): + version_cmd.bump_command( + major=False, minor=True, patch=False, title="Only bump", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + _append_sections(tree, "y" * 1001) + with pytest.raises(typer.Exit): + version_cmd.release_command(title=None, dry_run=True) + + +def test_release_passes_an_entry_inside_its_budget(tree): + version_cmd.bump_command( + major=False, minor=True, patch=False, title="Only bump", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + _append_sections(tree, "y" * 1000, "One paragraph,\nwrapped over two lines.") + version_cmd.release_command(title=None, dry_run=False) + assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0" + + # --- version bump ---------------------------------------------------------- @@ -827,6 +885,9 @@ def test_migration_required_retracts_the_no_migration_line(tree): ) changes = (tree / "CHANGES.md").read_text(encoding="utf-8") assert version_mod.MIGRATION_NONE_MARKER not in changes + assert _migration_lines(tree) == [ + "**Migration:** required - instructions/migrations/2.0.0-retype.md" + ] assert version_mod.BREAKING_CHANGE_MARKER in changes # untouched by the retraction assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2" @@ -876,7 +937,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree): ) -def test_insert_changes_entry_migration_required_clears_the_no_migration_line(): +def test_insert_changes_entry_migration_documents_replace_the_no_migration_line(): text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n" first = version_mod.insert_changes_entry( text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone", @@ -884,13 +945,120 @@ def test_insert_changes_entry_migration_required_clears_the_no_migration_line(): ) second = version_mod.insert_changes_entry( first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone", - migration_required=True, + migration_documents=["instructions/migrations/2.0.0-retype.md"], ) assert version_mod.MIGRATION_NONE_MARKER not in second + assert "**Migration:** required - instructions/migrations/2.0.0-retype.md\n" in second assert version_mod.BREAKING_CHANGE_MARKER in second assert "the feed moved" in second +# --- version bump: naming the migration documents (Gitea #184) -------------- + + +def _migration_lines(tree: Path) -> list[str]: + changes = (tree / "CHANGES.md").read_text(encoding="utf-8") + section = version_mod.changes_section(changes, version_mod.top_changes_version(changes)) + return [line for line in section.splitlines() if line.startswith(version_mod.MIGRATION_MARKER)] + + +def test_bump_names_a_required_migration_document_and_a_second_bump_keeps_it(tree): + _migration_document(tree, "2.0.0") + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="every page is retyped", no_migration=None, migration_required=False, + impact=None, dry_run=False, + ) + first = _migration_lines(tree) + assert first == ["**Migration:** required - instructions/migrations/2.0.0-retype.md"] + + version_cmd.bump_command( + major=False, minor=False, patch=True, title="Follow-up", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + assert _migration_lines(tree) == first + + +def test_bump_lists_several_migration_documents_sorted(tree): + _migration_document(tree, "2.0.0", slug="zeta") + _migration_document(tree, "2.0.0", slug="alpha") + _migration_document(tree, "3.0.0", slug="later") # targets another base: not named + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="every page is retyped", no_migration=None, migration_required=False, + impact=None, dry_run=False, + ) + assert _migration_lines(tree) == [ + "**Migration:** required - instructions/migrations/2.0.0-alpha.md, " + "instructions/migrations/2.0.0-zeta.md" + ] + + +def test_a_later_bump_names_a_document_written_after_the_crossing(tree): + """A plain bump finding a required document replaces `none required` - + the two lines exclude each other, and the document is the fact.""" + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, + impact=None, dry_run=False, + ) + _migration_document(tree, "2.0.0") + version_cmd.bump_command( + major=False, minor=False, patch=True, title="Follow-up", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + assert _migration_lines(tree) == [ + "**Migration:** required - instructions/migrations/2.0.0-retype.md" + ] + + +def test_a_bump_drops_the_line_once_its_document_no_longer_targets_the_base(tree): + _migration_document(tree, "2.0.0") + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="every page is retyped", no_migration=None, migration_required=False, + impact=None, dry_run=False, + ) + (tree / "instructions" / "migrations" / "2.0.0-retype.md").unlink() + version_cmd.bump_command( + major=False, minor=False, patch=True, title="Follow-up", + breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False, + ) + changes = (tree / "CHANGES.md").read_text(encoding="utf-8") + assert _migration_lines(tree) == [] + assert "\n\n\n" not in changes + + +def test_no_migration_is_refused_while_a_required_document_targets_the_base(tree): + _migration_document(tree, "2.0.0") + before = (tree / "CHANGES.md").read_text(encoding="utf-8") + with pytest.raises(typer.Exit): + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, + impact=None, dry_run=False, + ) + assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" + assert (tree / "CHANGES.md").read_text(encoding="utf-8") == before + + +def test_an_offered_migration_document_is_not_named_as_required(tree): + """An `obligation: offered` document is an improvement an instance may + decline - 6.0.0 shipped one beside a `none required` line, correctly.""" + (tree / "instructions" / "migrations" / "2.0.0-optional.md").write_text( + "---\ntype: types/instruction.md\nname: 2.0.0-optional\n" + "description: Optional.\nmanual: true\nmigrates_to: 2.0.0\n" + "migration_kind: assisted\nobligation: offered\n---\n\n# M\n", + encoding="utf-8", + ) + version_cmd.bump_command( + major=True, minor=False, patch=False, title="Breaking", + breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, + impact=None, dry_run=False, + ) + assert _migration_lines(tree) == [f"{version_mod.MIGRATION_NONE_MARKER} - kb/ untouched"] + + # --- version bump: the breaking-change note -------------------------------- diff --git a/tools/chemenu/version.py b/tools/chemenu/version.py index 0558ccc..54a9130 100644 --- a/tools/chemenu/version.py +++ b/tools/chemenu/version.py @@ -25,7 +25,8 @@ 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 +migration (and `MIGRATION_REQUIRED_MARKER`, in its place, names the documents +where there is one). 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, @@ -88,10 +89,19 @@ _BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME) _BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME) _BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL) +# The one line in a CHANGES.md entry that answers whether content has to +# change. It takes exactly one of the two forms below, never both: a bump +# writing either replaces whichever is there. +MIGRATION_MARKER = "**Migration:**" # Written into a CHANGES.md entry whose version crosses a compatibility # 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" +MIGRATION_NONE_MARKER = f"{MIGRATION_MARKER} none required" +# Written by every `version bump` whose candidate base a required migration +# document targets, followed by the documents' paths - the pointer an operator +# follows from the release notes to what they have to run. `docs verify` checks +# a crossing entry names every such document here. +MIGRATION_REQUIRED_MARKER = f"{MIGRATION_MARKER} 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 @@ -703,12 +713,22 @@ def bump_entries(section: str) -> list[tuple[str, str]]: SUMMARY_MIN_CHARS = 200 _CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE) +# Gitea #184: the 8.0.0 entry grew to 129 KB - one `###` changeset per bump, +# 80 of them - and failed as a release body twice over: Linux's argument limit +# and Gitea's 65 535-byte column. A length rule per changeset alone could not +# have stopped it, since the count grows with the bumps; so the topmost entry +# has a total, and each `###` section is one bounded paragraph per *topic*. +# Both measured in UTF-8 bytes, the unit both limits that failed count in. +# `ENTRY_MAX_BYTES` is half Gitea's column; the paragraph is measured without +# its `###` line, which is a title, not the text the budget exists to bound. +ENTRY_MAX_BYTES = 32_000 +CHANGESET_MAX_BYTES = 1_000 + def summary_prose(section: str) -> str: """The text between the bumps region (or, for an entry with none, the - heading) and the first `### <bump title>` changeset heading - the - candidate's own summary of what it did, as opposed to the per-bump detail - below it. + heading) and the first `### ` section heading - the candidate's own + summary of what it did, as opposed to the per-topic paragraphs below it. """ close = section.find(_BUMPS_CLOSE) if close != -1: @@ -721,6 +741,75 @@ def summary_prose(section: str) -> str: return section[start:end] +def changesets(section: str) -> list[tuple[str, list[str]]]: + """Every `### ` section of one entry as `(heading text, body lines)`. + + A body runs to the next `### ` or `## ` line, or to a `---` separator, + whichever comes first; blank lines at either end of it are dropped, so + what remains is exactly the text the paragraph budget measures. + """ + lines = section.split("\n") + found: list[tuple[str, list[str]]] = [] + index = 0 + while index < len(lines): + if not lines[index].startswith("### "): + index += 1 + continue + heading = lines[index][4:].strip() + index += 1 + body: list[str] = [] + while index < len(lines) and not ( + lines[index].startswith(("### ", "## ")) or lines[index].strip() == "---" + ): + body.append(lines[index]) + index += 1 + while body and not body[0].strip(): + body.pop(0) + while body and not body[-1].strip(): + body.pop() + found.append((heading, body)) + return found + + +def budget_issues(text: str) -> list[str]: + """Where the topmost versioned entry of `text` exceeds its size budget. + + Measured on the text `version notes` prints for that entry - which is what + `release.yml` posts as the release body. Only the topmost entry is held to + it: older ones are already published as release notes, and a changelog + with no versioned entry (a distributed instance's stub) has nothing to + check. Each `### ` section is exactly one paragraph - no blank line in its + body - of at most `CHANGESET_MAX_BYTES`, heading line excluded. + """ + top = top_changes_version(text) + if top is None: + return [] + section = changes_section(text, top) or "" + issues: list[str] = [] + size = len(section.encode("utf-8")) + if size > ENTRY_MAX_BYTES: + issues.append( + f"{top}'s {CHANGES_FILENAME} entry is {size} bytes, over its {ENTRY_MAX_BYTES}-byte " + "budget - shorten or merge `###` paragraphs; a smaller change keeps only its bump title" + ) + for heading, body in changesets(section): + if any(not line.strip() for line in body): + paragraphs = 1 + sum( + 1 for previous, line in zip(body, body[1:]) if line.strip() and not previous.strip() + ) + issues.append( + f"{top}'s section `### {heading}` has {paragraphs} paragraphs - each `###` " + "section is exactly one paragraph; rewrite it as one" + ) + body_size = len("\n".join(body).encode("utf-8")) + if body_size > CHANGESET_MAX_BYTES: + issues.append( + f"{top}'s section `### {heading}` is {body_size} bytes, over the " + f"{CHANGESET_MAX_BYTES}-byte paragraph budget - move the rationale into the issue" + ) + return issues + + def regrade(text: str, version: "Version", updates: dict[int, str]) -> str: """Change the impact grade of one or more of the topmost entry's bump titles, addressed by their 1-based position in `bump_entries`'s rendered @@ -759,11 +848,13 @@ def regrade(text: str, version: "Version", updates: dict[int, str]) -> str: def _set_marker_line(section: str, marker: str, line: str) -> str: """Add or replace the one-line `marker ...` paragraph in `section`. - Used for the no-migration line, which - unlike the bumps list and unlike + Used for the migration line, which - unlike the bumps list and unlike the breaking-change paragraph below - is **not** accumulated: it answers one yes/no question about the candidate as a whole ("does content have to - change?"), so a second answer replaces the first rather than joining it, - and `_clear_marker_line` is its retraction path. + change?"), so a second answer replaces the first rather than joining it. + Called with the shared `MIGRATION_MARKER`, so a `none required` line and a + `required - <paths>` line replace each other and never stand side by side; + `_clear_marker_line` removes either. Anchored just above the bumps region (not below it, as before Gitea #95): with a graded, potentially 30-line list, the line an operator most needs to act on stayed the deepest thing in the entry otherwise. @@ -845,17 +936,56 @@ def _add_breaking_reason(section: str, reason: str) -> str: def _clear_marker_line(section: str, marker: str) -> str: - """Remove the one-line `marker ...` paragraph from `section`, if present. + """Remove the one-line `marker ...` paragraph from `section`, if present, + together with the blank line that separated it from the next paragraph. - The retraction counterpart to `_set_marker_line`. A candidate that - recorded `--no-migration` and later turns out to need one after all has no - other way to take that statement back - the line is machine-managed, and - invariant 1 forbids hand-editing it. + The counterpart to `_set_marker_line`: it drops a `required - <paths>` + line whose documents no longer target the candidate's base - the line is + machine-managed, and invariant 1 forbids hand-editing it. """ - pattern = re.compile(rf"^{re.escape(marker)}.*\n?", re.MULTILINE) + pattern = re.compile(rf"^{re.escape(marker)}.*\n?(?:[ \t]*\n)?", re.MULTILINE) return pattern.sub("", section, count=1) +def migration_required_line(paths: list[str]) -> str: + """The `**Migration:** required - <paths>` line, paths sorted and + comma-separated so the same set always renders byte-identically.""" + return f"{MIGRATION_REQUIRED_MARKER} - {', '.join(sorted(paths))}" + + +_MIGRATION_REQUIRED_RE = re.compile( + rf"^{re.escape(MIGRATION_REQUIRED_MARKER)}(?: - (.*))?$", re.MULTILINE +) + + +def migration_paths(section: str) -> list[str]: + """The document paths an entry's `**Migration:** required` line names, in + written order - empty when the entry carries no such line.""" + match = _MIGRATION_REQUIRED_RE.search(section) + if not match or not match.group(1): + return [] + return [path.strip() for path in match.group(1).split(",") if path.strip()] + + +def _record_migration( + section: str, no_migration_reason: Optional[str], migration_documents: list[str] +) -> str: + """Bring the migration line in line with this bump's answer. + + A required document targeting the base is a fact about the candidate, so + its `required` line is written on every bump that finds one - replacing a + `none required` line, since content does have to change after all. Without + one, an explicit `--no-migration` reason writes the `none required` form; + with neither, a `required` line left over from documents that have since + stopped targeting the base is dropped, and a `none required` line stays. + """ + if migration_documents: + return _set_marker_line(section, MIGRATION_MARKER, migration_required_line(migration_documents)) + if no_migration_reason: + return _set_marker_line(section, MIGRATION_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}") + return _clear_marker_line(section, MIGRATION_REQUIRED_MARKER) + + def _entry_span(text: str) -> tuple[int, int]: """Start/end offsets of the topmost entry, heading included.""" match = re.search(r"^## ", text, re.MULTILINE) @@ -874,22 +1004,18 @@ def _update_open_candidate( title: str, breaking_reason: Optional[str], no_migration_reason: Optional[str], - migration_required: bool = False, + migration_documents: list[str], impact: str = DEFAULT_IMPACT, ) -> str: """Move the topmost entry's heading to `version`/`date`/`title`, append - `(impact, title)` to its machine-managed bump list, and record the - breaking/no-migration lines only where this call supplies them - see - `insert_changes_entry`. + `(impact, title)` to its machine-managed bump list, record a breaking + reason only where this call supplies one, and bring the migration line in + line with this bump's answer - see `insert_changes_entry`. The two are recorded differently on purpose: a `breaking_reason` **joins** whatever crossings the candidate already recorded (`_add_breaking_reason`), - a `no_migration_reason` **replaces** the single line that answers whether - content has to change (`_set_marker_line`). - - `migration_required` retracts an earlier `--no-migration` line instead of - setting one - the two are mutually exclusive on a single bump, enforced by - the caller (`version_cmd.bump_command`), not here.""" + while the migration line is one answer about the whole candidate that a + later bump **replaces** (`_record_migration`).""" start, end = _entry_span(text) section = text[start:end] @@ -904,10 +1030,7 @@ def _update_open_candidate( if breaking_reason: section = _add_breaking_reason(section, breaking_reason) - if no_migration_reason: - section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}") - elif migration_required: - section = _clear_marker_line(section, MIGRATION_NONE_MARKER) + section = _record_migration(section, no_migration_reason, migration_documents) return text[:start] + section + text[end:] @@ -920,7 +1043,7 @@ def insert_changes_entry( author: str, no_migration_reason: Optional[str] = None, breaking_reason: Optional[str] = None, - migration_required: bool = False, + migration_documents: Optional[list[str]] = None, impact: str = DEFAULT_IMPACT, ) -> str: """Open a new entry above the newest existing one, or - when the topmost @@ -937,32 +1060,36 @@ def insert_changes_entry( compatibility boundary is crossed - the line saying what breaks (one crossing, so the flat one-line form; a candidate that crosses again accumulates bullets there, see `_add_breaking_reason`), plus the - line saying no content has to change where that applies, and then the + migration line where there is an answer to give, and then the machine-managed bump list (started with this one `(impact, title)` pair, for a candidate). The break comes first, above the bump list rather than below it (Gitea #95): it is what an operator reading the release notes has to act on, the migration line only qualifies it, and neither should sit beneath a list that can run to dozens of graded entries. The entry's - actual prose - the release summary, and each bump's own changeset - is - written afterwards by whoever made the change, which is also why `bump` - refuses to invent a title. + actual prose - the release summary and the one-paragraph `###` sections, + one per topic rather than one per bump - is written afterwards by whoever + made the change, which is also why `bump` refuses to invent a title. - `migration_required` only has anything to retract on an already-open - candidate, so a fresh entry ignores it - there is no earlier - `--no-migration` line in a skeleton that was just opened. + `migration_documents` are the paths of the required migration documents + targeting `version.base`; where there are any, they are the migration + line (`**Migration:** required - <paths>`), replacing a `none required` + line - see `_record_migration`. """ + documents = list(migration_documents or []) top = top_changes_version(text) if top is not None and top.is_prerelease: return _update_open_candidate( text, version, date, title, breaking_reason=breaking_reason, no_migration_reason=no_migration_reason, - migration_required=migration_required, impact=impact, + migration_documents=documents, impact=impact, ) lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""] if breaking_reason: lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""] - if no_migration_reason: + if documents: + lines += [migration_required_line(documents), ""] + elif no_migration_reason: lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""] if version.is_prerelease: lines += [_bumps_block([(impact, title)]), ""]