feat: changelog entries stay compact - one paragraph per topic, a size budget in docs verify and version release, version bump names required migration documents (#184)
CI / verify (push) Successful in 6m0s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 33s

Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-build/SKILL.md
- instructions/dev/version-parts.md
- instructions/migrate-corpus.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-06 16:48:02 +02:00
1 parent 2ddcd6be19
commit 044943ae51
12 files changed
+717 -169

No files matched your search

+25 -49
View File
@@ -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
(`## <version> - <date> - <title>`). `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`.
---
+10 -7
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
8.0.0
8.1.0-beta.1
+4 -2
View File
@@ -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/`
+48 -29
View File
@@ -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
+2 -1
View File
@@ -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
+20 -8
View File
@@ -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.
+79 -1
View File
@@ -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()
+69 -31
View File
@@ -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
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 "
+124
View File
@@ -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"]
+170 -2
View File
@@ -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 --------------------------------
+165 -38
View File
@@ -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)]), ""]