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)
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:
1 parent
2ddcd6be19
commit
044943ae51
12 files changed
+717
-169
No files matched your search
+25
-49
@@ -1,61 +1,37 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
This file tracks changes to the **wiki stack itself** - `AGENTS.md`, the
|
This file tracks changes to the **wiki stack itself** - `AGENTS.md`, the
|
||||||
`instructions/` layer, `tools/wikitool`, and the contracts. It is distinct from
|
`instructions/` layer, `tools/wikitool`, and the contracts - one entry per
|
||||||
`kb/log.md`, which is the audit trail of *wiki content* operations (ingests,
|
release, newest first. It is distinct from `kb/log.md`, the audit trail of
|
||||||
queries, lints, page creates/updates) performed by the LLM against `kb/`.
|
*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
|
`wikitool version bump` opens and updates the running candidate's entry and
|
||||||
History" table. Those have been consolidated here so there is one place to
|
`wikitool version release` closes it; the topmost entry is what
|
||||||
look for "what changed in the tooling/schema, and when." From now on,
|
`wikitool version notes` prints as the release notes. What an entry contains,
|
||||||
document any change to the stack (schema, instructions, `wikitool` commands,
|
who writes which part, and the size budget it is held to are defined in one
|
||||||
contracts) as a new entry at the top of this file instead of editing inline
|
place only: `instructions/dev/version-parts.md` § The candidate model.
|
||||||
version history tables.
|
|
||||||
|
|
||||||
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
|
## 8.1.0-beta.1 - 2026-10-06 - Changelog kompakt: ein Absatz pro Thema, Größenbudget, Migrationsverweis
|
||||||
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.
|
|
||||||
|
|
||||||
`**Breaking Change:**` accumulates, because one candidate can cross the
|
**Author:** Torben Nehmer
|
||||||
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.
|
|
||||||
|
|
||||||
That list is graded, not a flat chronological dump: each bump carries an
|
<!-- wikitool:bumps -->
|
||||||
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
- Changelog kompakt: ein Absatz pro Thema, Größenbudget, Migrationsverweis
|
||||||
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
<!-- /wikitool:bumps -->
|
||||||
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 version release` is what closes a candidate: it strips the suffix
|
### Changelog-Einträge mit Größenbudget und Migrationsverweis (#184)
|
||||||
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
|
Ein Eintrag bekommt `###`-Abschnitte pro Thema statt pro Bump; eine kleinere Änderung steht nur
|
||||||
never sees a `-beta.` version at all (`release.yml` only ever releases a fixed
|
als Bump-Titel in der Liste. `docs verify` und `version release` halten den obersten Eintrag bei
|
||||||
one), so the suffix and everything below the heading are a dev-checkout
|
höchstens 32 000 Bytes und jeden `###`-Abschnitt bei genau einem Absatz von höchstens 1 000 Bytes,
|
||||||
concern - readable here, never shipped as something to parse.
|
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
@@ -90,11 +90,11 @@ eine Sitzung ihn tatsächlich durchläuft:
|
|||||||
`medium`) gruppiert den Eintrag; `tools/wikitool version regrade` korrigiert eine Note später,
|
`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.
|
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,
|
2. **Der Eintrag bekommt seine Prosa - nur wo sie nötig ist.** `bump` schreibt das Skelett
|
||||||
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
|
(Heading, Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
|
||||||
Breaking-/Migration-Zeile). Darunter kommen zwei Autorenanteile: eine kurze Zusammenfassung
|
Breaking- und Migrationszeile). Für eine kleinere Änderung genügt ihr Bump-Titel; eine größere
|
||||||
(ein paar Sätze, worum es in diesem Release geht) direkt unter der Liste, und darunter je Bump
|
bekommt einen Absatz zu ihrem Thema, und vor dem Release kommt eine kurze Zusammenfassung
|
||||||
ein eigener `### <Bump-Titel>`-Changeset-Absatz. Details dazu in
|
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.
|
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) § The candidate model.
|
||||||
|
|
||||||
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
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,
|
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
|
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 -
|
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)).
|
(Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
5. **Publish bewegt `VERSION` auf `main`.**
|
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.
|
**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
|
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)
|
`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,
|
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
|
publishen und `release.yml` per `workflow_dispatch` auf `main` starten. Die Prüfung auf ein
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ session, or a fresh one after `/clear` - assume the second, and work from the bo
|
|||||||
model.
|
model.
|
||||||
|
|
||||||
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
||||||
fails a tree where they disagree. Then write the entry's body - `bump` deliberately leaves it
|
fails a tree where they disagree. Then decide the entry's prose: for a smaller change its bump
|
||||||
empty, the same way `new` leaves the prose. A `--major` bump needs `--breaking` and either a
|
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.
|
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/`
|
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
a whole, in one of two forms that never stand side by side. Where a required migration
|
||||||
reason; a wrong one is rare enough, and the candidate is dev-local until release.
|
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
|
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`.
|
`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
|
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
|
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
|
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
|
summary here; a one-bump entry is exempt, since there its own paragraph already reads as
|
||||||
as the summary.
|
the summary.
|
||||||
4. **The changesets**, one `### <bump title>` heading per bump, in chronological order - the
|
4. **One `###` section per topic, not per bump** - for the larger changes only. A topic is
|
||||||
detail a reader follows into from the graded list above. A changeset is a few sentences,
|
what a reader would call one change; it often gathers several bumps, and its heading names
|
||||||
not the full rationale; what needs more than that belongs in the issue tracker, not here.
|
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
|
**The budget, checked by `docs verify` and again by `version release` before it writes:** the
|
||||||
changeset's `###` heading are the same string.
|
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`
|
- **`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
|
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.
|
`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
|
- 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
|
[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
|
`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.
|
- 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
|
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 `--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
|
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
|
content does have to move after all. Write the migration document first, then run
|
||||||
with `version bump --migration-required` - it removes the `**Migration:** none required` line
|
`version bump --migration-required` - it replaces the `**Migration:** none required` line the
|
||||||
the earlier bump wrote, and refuses unless a document already targets the new base. Nothing
|
earlier bump wrote with the `required` line naming the document, and refuses unless a required
|
||||||
else takes that statement back: the line is machine-written (invariant 1), `docs verify` is
|
document already targets the new base. Any later bump would make the same replacement once
|
||||||
satisfied by its bare presence, and the escalation checks in this step run only on the bump
|
the document exists, and `docs verify` reports a crossing entry whose migration line does not
|
||||||
that *first* crosses the boundary - so a candidate that keeps a stale `none required` line is
|
name it; the flag is the explicit form, which also refuses when there is nothing to replace.
|
||||||
never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for
|
`--no-migration` itself is refused while a required document targets the base. The 5.0.0
|
||||||
a TOC-verification change, then absorbed a schema removal that migrates 152 pages.
|
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
|
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
|
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
|
candidate collected several bump titles along the way; without one, the heading simply keeps
|
||||||
whichever bump last set it.
|
whichever bump last set it.
|
||||||
|
|
||||||
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
|
8. **Write the entry's prose - a section for a larger change, nothing for a smaller one.** `bump`
|
||||||
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
|
writes no prose on purpose. Decide per bump: a smaller change is done once its title is in
|
||||||
release time, right below the graded bump list: what this release is about, and why, for a
|
the bump list. A larger one gets a `###` section for its topic - one paragraph, in the budget
|
||||||
reader who will not read the changesets underneath. `version release` refuses to close an
|
of § The candidate model, on what changed and why, which is the one thing a future reader
|
||||||
entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since
|
cannot reconstruct from the diff. If its topic already has a section, rewrite that paragraph
|
||||||
there the bump's changeset already reads as one. Each **changeset**, under its own
|
to cover both bumps rather than adding another. The full rationale of a decision belongs in
|
||||||
`### <bump title>` heading, is a few sentences on what changed and why - it is the one thing a
|
the issue tracker or the commit history; a paragraph pressing against its 1 000 bytes is a
|
||||||
future reader cannot reconstruct from the diff, but it is not the place for the full rationale
|
sign that part of it belongs there instead. The **summary** is a short paragraph (a few
|
||||||
of a decision; that belongs in the issue tracker or the commit history, and a changeset that
|
sentences) written once, at release time, right below the graded bump list: what this
|
||||||
is growing past a paragraph or two is a sign it belongs there instead.
|
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
|
## Decision points
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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:`.
|
`instructions/migrations/<version>-<slug>.md`, carrying `migrates_to:` and `migration_kind:`.
|
||||||
`tools/wikitool migrate status` builds the outstanding chain from those files, and `version
|
`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
|
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
|
changed, which pages are affected, how to tell a migrated page from an unmigrated one, and what
|
||||||
|
|||||||
+20
-8
@@ -2504,6 +2504,8 @@ Check the docs that mirror the code.
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 A command, contract, or type-form mismatch
|
- 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 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale
|
||||||
- 1 A type-spec's own frontmatter fails its schema
|
- 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
|
- 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**
|
**ON FAILURE**
|
||||||
|
|
||||||
- A command, contract, or type-form mismatch -> Fix the documentation it names, then re-run
|
- 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
|
- 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 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
|
- 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 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.
|
- 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.
|
- `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.
|
- Read-only.
|
||||||
|
|
||||||
**SEE ALSO**
|
**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 `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 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 `--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**
|
**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
|
- `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
|
- 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
|
- `--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**
|
||||||
|
|
||||||
- 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 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**
|
**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.
|
- `--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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Enforces that a crossing documents itself, never that the part was chosen correctly.
|
||||||
- Not idempotent: every successful run escalates or continues the candidate again.
|
- Not idempotent: every successful run escalates or continues the candidate again.
|
||||||
- `--dry-run` reports the step without writing.
|
- `--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
|
- 0 success
|
||||||
- 1 `VERSION` is already a release - there is no running candidate to fix
|
- 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 `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**
|
**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` 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
|
- `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**
|
**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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Not idempotent: a second run fails once the suffix is gone.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ start committing derived output.
|
|||||||
A fifth has the same shape as the fourth: `VERSION` is not documentation
|
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
|
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
|
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`
|
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
|
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]:
|
def check_breaking_change_for_boundary() -> list[str]:
|
||||||
"""A version that crosses the compatibility boundary must say what breaks.
|
"""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 "
|
"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 "
|
"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.",
|
"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.",
|
"Read-only.",
|
||||||
),
|
),
|
||||||
failures=(
|
failures=(
|
||||||
@@ -1153,6 +1218,17 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
cause="A command, contract, or type-form mismatch",
|
cause="A command, contract, or type-form mismatch",
|
||||||
reaction="Fix the documentation it names, then re-run",
|
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(
|
cli_contract.Failure(
|
||||||
cause="The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale",
|
cause="The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale",
|
||||||
reaction="Run `docs contract --apply`, then re-run",
|
reaction="Run `docs contract --apply`, then re-run",
|
||||||
@@ -1228,7 +1304,9 @@ def verify():
|
|||||||
+ check_ignored_content()
|
+ check_ignored_content()
|
||||||
+ check_version_changelog()
|
+ check_version_changelog()
|
||||||
+ check_migration_for_boundary()
|
+ check_migration_for_boundary()
|
||||||
|
+ check_migration_line_names_documents()
|
||||||
+ check_breaking_change_for_boundary()
|
+ check_breaking_change_for_boundary()
|
||||||
|
+ check_changelog_budget()
|
||||||
+ check_no_issue_references()
|
+ check_no_issue_references()
|
||||||
+ check_toc_regions()
|
+ check_toc_regions()
|
||||||
+ check_reference_targets()
|
+ check_reference_targets()
|
||||||
|
|||||||
@@ -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. "
|
"groups omitted - except while every bump is `medium`, where it stays one flat list. "
|
||||||
"`version regrade` corrects a grade after the fact.",
|
"`version regrade` corrects a grade after the fact.",
|
||||||
"Writes the heading, the bump list and the breaking/migration lines; the entry's prose "
|
"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, "
|
"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 "
|
"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 "
|
"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 "
|
"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 "
|
"bare marker from the second on; repeating a reason verbatim is a no-op), while a "
|
||||||
"further `--no-migration` **replaces** the single migration line.",
|
"further `--no-migration` **replaces** the single migration line.",
|
||||||
"`--migration-required` retracts the running candidate's `--no-migration` line; it "
|
"`--migration-required` replaces the running candidate's `--no-migration` line with "
|
||||||
"needs a migration document already targeting the new base. Nothing retracts a "
|
"the `required` line; it needs a required migration document already targeting the new "
|
||||||
"recorded `--breaking` reason.",
|
"base. Nothing retracts a recorded `--breaking` reason.",
|
||||||
"Enforces that a crossing documents itself, never that the part was chosen correctly.",
|
"Enforces that a crossing documents itself, never that the part was chosen correctly.",
|
||||||
"Not idempotent: every successful run escalates or continues the candidate again.",
|
"Not idempotent: every successful run escalates or continues the candidate again.",
|
||||||
"`--dry-run` reports the step without writing.",
|
"`--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",
|
cause="`--breaking` or `--no-migration` on a bump that crosses nothing",
|
||||||
reaction="Nothing was written - drop the flag and retry",
|
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(
|
cli_contract.Failure(
|
||||||
cause="`--migration-required` combined with `--no-migration`, on a bump with no "
|
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",
|
"migration document already targeting the new base",
|
||||||
reaction="Nothing was written - write the migration document or fix the "
|
reaction="Nothing was written - write the migration document or fix the "
|
||||||
"combination, then retry",
|
"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.",
|
"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 "
|
"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 "
|
"list, breaking and migration lines); `--migration-required` is the only way to take "
|
||||||
"the migration line back.",
|
"a `none required` line back.",
|
||||||
),
|
),
|
||||||
see_also=(
|
see_also=(
|
||||||
"`wikitool version regrade` - corrects an `--impact` grade",
|
"`wikitool version regrade` - corrects an `--impact` grade",
|
||||||
@@ -602,11 +611,13 @@ def bump_command(
|
|||||||
`--no-migration` **replaces**: whether content has to change is one
|
`--no-migration` **replaces**: whether content has to change is one
|
||||||
question about the candidate as a whole, not one per crossing.
|
question about the candidate as a whole, not one per crossing.
|
||||||
|
|
||||||
A later bump of the same candidate that finds out `--no-migration` was
|
Wherever a required migration document targets the candidate's base,
|
||||||
wrong after all retracts it with `--migration-required` - write the
|
every bump writes `**Migration:** required - <paths>` - the pointer the
|
||||||
migration document first, then re-run with this flag instead of
|
release notes give an operator - and a `none required` line cannot stand
|
||||||
`--no-migration`. There is no other way to take the line back: it is
|
beside it. A later bump that finds out `--no-migration` was wrong after
|
||||||
machine-written, and invariant 1 forbids hand-editing it."""
|
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]
|
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
||||||
if len(selected) != 1:
|
if len(selected) != 1:
|
||||||
fail("Pass exactly one of --major / --minor / --patch")
|
fail("Pass exactly one of --major / --minor / --patch")
|
||||||
@@ -669,10 +680,15 @@ def bump_command(
|
|||||||
)
|
)
|
||||||
return
|
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(
|
fail(
|
||||||
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
||||||
f"instance must migrate - but no migration document targets {new_version.base}.\n"
|
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."
|
f"{current} -> {new_version} does not."
|
||||||
)
|
)
|
||||||
return
|
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:
|
if migration_required and no_migration:
|
||||||
fail("--migration-required and --no-migration contradict each other on the same bump.")
|
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."
|
f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
from chemenu import kb_state
|
if not required_documents:
|
||||||
|
|
||||||
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
|
|
||||||
fail(
|
fail(
|
||||||
f"--migration-required retracts the no-migration line, so a migration document must "
|
f"--migration-required replaces the no-migration line with one naming the migration "
|
||||||
f"target {new_version.base} first - write one under "
|
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"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
|
||||||
f"(see instructions/migrate-corpus.md), then re-run with --migration-required."
|
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,
|
text, new_version, today_iso(), title.strip(), author,
|
||||||
no_migration_reason=no_migration.strip() if no_migration else None,
|
no_migration_reason=no_migration.strip() if no_migration else None,
|
||||||
breaking_reason=breaking.strip() if breaking else None,
|
breaking_reason=breaking.strip() if breaking else None,
|
||||||
migration_required=migration_required,
|
migration_documents=required_documents,
|
||||||
impact=chosen_impact,
|
impact=chosen_impact,
|
||||||
),
|
),
|
||||||
encoding="utf-8", newline="\n",
|
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 "
|
"Leaves the entry's machine-managed bump list untouched, as the record of what "
|
||||||
"happened.",
|
"happened.",
|
||||||
"From two bumps on, requires a summary paragraph (at least 200 non-whitespace "
|
"From two bumps on, requires a summary paragraph (at least 200 non-whitespace "
|
||||||
"characters) between the bump list and the first `### <bump title>` changeset "
|
"characters) between the bump list and the first `###` section heading. A candidate "
|
||||||
"heading. A candidate with exactly one bump is exempt, since there its own changeset "
|
"with exactly one bump is exempt, since there its own paragraph already is the "
|
||||||
"already is the summary. `--dry-run` runs this check too.",
|
"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 "
|
"Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto "
|
||||||
"`main`, which `release.yml` reacts to.",
|
"`main`, which `release.yml` reacts to.",
|
||||||
"Not idempotent: a second run fails once the suffix is gone.",
|
"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",
|
reaction="Fix whichever is wrong, then retry",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
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",
|
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=(
|
examples=(
|
||||||
"tools/wikitool version release --dry-run",
|
"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
|
Refuses when `VERSION` is already a release: there is no running candidate
|
||||||
to fix. Also refuses - Gitea #95 - when the candidate collected two or
|
to fix. Also refuses - Gitea #95 - when the candidate collected two or
|
||||||
more bumps and its entry still has no summary paragraph above the
|
more bumps and its entry still has no summary paragraph above the
|
||||||
individual changesets: a release note that is only a chronological bump
|
`###` sections: a release note that is only a chronological bump list is
|
||||||
list is exactly the thing this refusal exists to stop shipping. A
|
exactly the thing this refusal exists to stop shipping. A candidate with
|
||||||
candidate with exactly one bump is exempt - there, the bump's own
|
exactly one bump is exempt - there, its own paragraph already is the
|
||||||
changeset already is the summary."""
|
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:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
@@ -865,16 +898,21 @@ def release_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_version = current.base
|
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:
|
if dry_run:
|
||||||
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
||||||
return
|
return
|
||||||
|
|
||||||
version_mod.write_version(new_version)
|
version_mod.write_version(new_version)
|
||||||
changes.write_text(
|
changes.write_text(released, encoding="utf-8", newline="\n")
|
||||||
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
|
|
||||||
encoding="utf-8", newline="\n",
|
|
||||||
)
|
|
||||||
success(
|
success(
|
||||||
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
|
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 "
|
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||||
|
|||||||
@@ -623,6 +623,130 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
|||||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
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):
|
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
|
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
|
||||||
|
|||||||
@@ -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"
|
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 ----------------------------------------------------------
|
# --- 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")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
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 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"
|
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"
|
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
first = version_mod.insert_changes_entry(
|
first = version_mod.insert_changes_entry(
|
||||||
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
|
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(
|
second = version_mod.insert_changes_entry(
|
||||||
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
|
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 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 version_mod.BREAKING_CHANGE_MARKER in second
|
||||||
assert "the feed moved" 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 --------------------------------
|
# --- version bump: the breaking-change note --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+165
-38
@@ -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
|
boundary-crossing bumps that migrate nothing are a real case, not an escape
|
||||||
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
|
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
|
||||||
records the break, `MIGRATION_NONE_MARKER` records the absence of the
|
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.
|
bump; this module only enforces that a crossing says what it costs.
|
||||||
|
|
||||||
Paths are resolved through `config.ROOT` at call time rather than at import,
|
Paths are resolved through `config.ROOT` at call time rather than at import,
|
||||||
@@ -88,10 +89,19 @@ _BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
|||||||
_BUMPS_CLOSE = blocks.close_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)
|
_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
|
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||||
# of a migration document, so the exact string is a contract between the two.
|
# of a migration document, so the exact string is a contract between the two.
|
||||||
MIGRATION_NONE_MARKER = "**Migration:** none required"
|
MIGRATION_NONE_MARKER = 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
|
# 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
|
# 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
|
# 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
|
SUMMARY_MIN_CHARS = 200
|
||||||
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
|
_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:
|
def summary_prose(section: str) -> str:
|
||||||
"""The text between the bumps region (or, for an entry with none, the
|
"""The text between the bumps region (or, for an entry with none, the
|
||||||
heading) and the first `### <bump title>` changeset heading - the
|
heading) and the first `### ` section heading - the candidate's own
|
||||||
candidate's own summary of what it did, as opposed to the per-bump detail
|
summary of what it did, as opposed to the per-topic paragraphs below it.
|
||||||
below it.
|
|
||||||
"""
|
"""
|
||||||
close = section.find(_BUMPS_CLOSE)
|
close = section.find(_BUMPS_CLOSE)
|
||||||
if close != -1:
|
if close != -1:
|
||||||
@@ -721,6 +741,75 @@ def summary_prose(section: str) -> str:
|
|||||||
return section[start:end]
|
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:
|
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
|
"""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
|
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:
|
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||||
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
"""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
|
the breaking-change paragraph below - is **not** accumulated: it answers
|
||||||
one yes/no question about the candidate as a whole ("does content have to
|
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,
|
change?"), so a second answer replaces the first rather than joining it.
|
||||||
and `_clear_marker_line` is its retraction path.
|
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):
|
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
|
with a graded, potentially 30-line list, the line an operator most needs
|
||||||
to act on stayed the deepest thing in the entry otherwise.
|
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:
|
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
|
The counterpart to `_set_marker_line`: it drops a `required - <paths>`
|
||||||
recorded `--no-migration` and later turns out to need one after all has no
|
line whose documents no longer target the candidate's base - the line is
|
||||||
other way to take that statement back - the line is machine-managed, and
|
machine-managed, and invariant 1 forbids hand-editing it.
|
||||||
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)
|
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]:
|
def _entry_span(text: str) -> tuple[int, int]:
|
||||||
"""Start/end offsets of the topmost entry, heading included."""
|
"""Start/end offsets of the topmost entry, heading included."""
|
||||||
match = re.search(r"^## ", text, re.MULTILINE)
|
match = re.search(r"^## ", text, re.MULTILINE)
|
||||||
@@ -874,22 +1004,18 @@ def _update_open_candidate(
|
|||||||
title: str,
|
title: str,
|
||||||
breaking_reason: Optional[str],
|
breaking_reason: Optional[str],
|
||||||
no_migration_reason: Optional[str],
|
no_migration_reason: Optional[str],
|
||||||
migration_required: bool = False,
|
migration_documents: list[str],
|
||||||
impact: str = DEFAULT_IMPACT,
|
impact: str = DEFAULT_IMPACT,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||||
`(impact, title)` to its machine-managed bump list, and record the
|
`(impact, title)` to its machine-managed bump list, record a breaking
|
||||||
breaking/no-migration lines only where this call supplies them - see
|
reason only where this call supplies one, and bring the migration line in
|
||||||
`insert_changes_entry`.
|
line with this bump's answer - see `insert_changes_entry`.
|
||||||
|
|
||||||
The two are recorded differently on purpose: a `breaking_reason` **joins**
|
The two are recorded differently on purpose: a `breaking_reason` **joins**
|
||||||
whatever crossings the candidate already recorded (`_add_breaking_reason`),
|
whatever crossings the candidate already recorded (`_add_breaking_reason`),
|
||||||
a `no_migration_reason` **replaces** the single line that answers whether
|
while the migration line is one answer about the whole candidate that a
|
||||||
content has to change (`_set_marker_line`).
|
later bump **replaces** (`_record_migration`)."""
|
||||||
|
|
||||||
`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."""
|
|
||||||
start, end = _entry_span(text)
|
start, end = _entry_span(text)
|
||||||
section = text[start:end]
|
section = text[start:end]
|
||||||
|
|
||||||
@@ -904,10 +1030,7 @@ def _update_open_candidate(
|
|||||||
|
|
||||||
if breaking_reason:
|
if breaking_reason:
|
||||||
section = _add_breaking_reason(section, breaking_reason)
|
section = _add_breaking_reason(section, breaking_reason)
|
||||||
if no_migration_reason:
|
section = _record_migration(section, no_migration_reason, migration_documents)
|
||||||
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)
|
|
||||||
|
|
||||||
return text[:start] + section + text[end:]
|
return text[:start] + section + text[end:]
|
||||||
|
|
||||||
@@ -920,7 +1043,7 @@ def insert_changes_entry(
|
|||||||
author: str,
|
author: str,
|
||||||
no_migration_reason: Optional[str] = None,
|
no_migration_reason: Optional[str] = None,
|
||||||
breaking_reason: Optional[str] = None,
|
breaking_reason: Optional[str] = None,
|
||||||
migration_required: bool = False,
|
migration_documents: Optional[list[str]] = None,
|
||||||
impact: str = DEFAULT_IMPACT,
|
impact: str = DEFAULT_IMPACT,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Open a new entry above the newest existing one, or - when the topmost
|
"""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
|
compatibility boundary is crossed - the line saying what breaks (one
|
||||||
crossing, so the flat one-line form; a candidate that crosses again
|
crossing, so the flat one-line form; a candidate that crosses again
|
||||||
accumulates bullets there, see `_add_breaking_reason`), plus the
|
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,
|
machine-managed bump list (started with this one `(impact, title)` pair,
|
||||||
for a candidate). The break comes first, above the bump list rather than
|
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
|
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
|
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
|
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
|
actual prose - the release summary and the one-paragraph `###` sections,
|
||||||
written afterwards by whoever made the change, which is also why `bump`
|
one per topic rather than one per bump - is written afterwards by whoever
|
||||||
refuses to invent a title.
|
made the change, which is also why `bump` refuses to invent a title.
|
||||||
|
|
||||||
`migration_required` only has anything to retract on an already-open
|
`migration_documents` are the paths of the required migration documents
|
||||||
candidate, so a fresh entry ignores it - there is no earlier
|
targeting `version.base`; where there are any, they are the migration
|
||||||
`--no-migration` line in a skeleton that was just opened.
|
line (`**Migration:** required - <paths>`), replacing a `none required`
|
||||||
|
line - see `_record_migration`.
|
||||||
"""
|
"""
|
||||||
|
documents = list(migration_documents or [])
|
||||||
top = top_changes_version(text)
|
top = top_changes_version(text)
|
||||||
if top is not None and top.is_prerelease:
|
if top is not None and top.is_prerelease:
|
||||||
return _update_open_candidate(
|
return _update_open_candidate(
|
||||||
text, version, date, title,
|
text, version, date, title,
|
||||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
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}", ""]
|
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||||
if breaking_reason:
|
if breaking_reason:
|
||||||
lines += [f"{BREAKING_CHANGE_MARKER} {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}", ""]
|
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
||||||
if version.is_prerelease:
|
if version.is_prerelease:
|
||||||
lines += [_bumps_block([(impact, title)]), ""]
|
lines += [_bumps_block([(impact, title)]), ""]
|
||||||
|
|||||||
Reference in new issue
Block a user