Compare commits

..

1 Commits

Author SHA1 Message Date
torben 1b0158fc8d stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-12 18:23:58 +02:00
12 changed files with 690 additions and 106 deletions
+54 -7
View File
@@ -25,13 +25,60 @@ 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:**` line - written and read by `wikitool version bump`,
never by hand. `wikitool version release` is what closes a candidate: it
strips the suffix and turns the entry into an ordinary, suffix-free one,
leaving the bump-title list as the record of what happened. A distributed
instance never sees a `-beta.` version at all (`release.yml` only ever
releases a fixed one), so this suffix and the list beneath it are a
dev-checkout concern - readable here, never shipped as something to parse.
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
written and read by `wikitool version bump`, never by hand.
That list is graded, not a flat chronological dump: each bump carries an
impact (`--impact high|medium|low`, default `medium`), and the list renders
grouped under `**High/Medium/Low impact**` headings - except when every bump
so far is `medium`, where it stays flat with no headings at all, exactly as
it always did before grading existed. `wikitool version regrade` corrects a
grade after the fact, against a single read of the whole list. Below the
list comes a short summary paragraph, written once at release time, and below
that one `### <bump title>` changeset per bump, in chronological order -
`wikitool version release` refuses to close a candidate that collected two or
more bumps and has no summary there (a one-bump candidate is exempt, since its
single changeset already reads as one). This layering exists because a
long-running candidate's bump list, left flat and ungraded, grows unreadable
as a release announcement - the concrete case that forced it was `5.0.0`, one
entry across roughly 1440 lines.
`wikitool version release` is what closes a candidate: it strips the suffix
and turns the entry into an ordinary, suffix-free one, leaving the bump list,
summary and changesets as the record of what happened. A distributed instance
never sees a `-beta.` version at all (`release.yml` only ever releases a fixed
one), so the suffix and everything below the heading are a dev-checkout
concern - readable here, never shipped as something to parse.
---
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
**High impact**
- changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
<!-- /wikitool:bumps -->
Seit `4.4.0` sammelt ein laufender Kandidat alle Bumps in einem Eintrag; bei `5.0.0` wurde das
mit 20 Bumps und ~1440 Zeilen unlesbar, weil die Liste chronologisch und ungewichtet war und die
Release-Seite genau diesen Eintrag 1:1 uebernimmt (`version notes`, `release.yml`). Der Eintrag
ist jetzt geschichtet statt einer einzigen Wand Text: `version bump --impact high|medium|low`
(Default `medium`) graduiert jeden Bump, die Liste rendert nach High/Medium/Low gruppiert - ausser
wenn alles `medium` ist, dann bleibt sie flach wie bisher, damit jeder alte Eintrag und jeder
einfache Patch unveraendert bleibt. `version regrade` korrigiert eine Note im Nachhinein, gegen
einen einzelnen Lesevorgang der ganzen Liste, bevor der Kandidat geschlossen wird. Direkt unter
der Liste steht jetzt eine kurze Zusammenfassung, darunter je Bump ein eigener
`### <Bump-Titel>`-Changeset-Absatz; `version release` verweigert das Schliessen eines Kandidaten
mit zwei oder mehr Bumps, solange diese Zusammenfassung fehlt (ein Kandidat mit genau einem Bump
ist ausgenommen - sein Changeset ist bereits die Zusammenfassung, wie hier). Geschlossene
Eintraege wie der zu `5.0.0` bleiben in der alten Form stehen: die Release-Seiten sind laut
eigenem Footer unveraenderliche Snapshots, und `instructions/dev/version-parts.md` sowie
`docs/version-model.md` zitieren den `2.0.0`-Eintrag mit Abschnittsnamen.
`--breaking`/`**Migration:**` sitzen jetzt oberhalb der Bump-Liste statt darunter, damit die fuer
einen Operator wichtigste Zeile nicht unter einer moeglicherweise langen Liste verschwindet.
---
+15 -8
View File
@@ -22,19 +22,24 @@ ein Release verbraucht wird - steht in
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
eine Sitzung ihn tatsächlich durchläuft:
1. **Bump eröffnet oder eskaliert den Kandidaten.**
1. **Bump eröffnet oder eskaliert den Kandidaten, gewichtet mit `--impact`.**
```bash
tools/wikitool version bump --minor --title "Was sich geändert hat"
tools/wikitool version bump --minor --title "Was sich geändert hat" --impact medium
```
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
denselben Eintrag, statt einen neuen zu eröffnen.
denselben Eintrag, statt einen neuen zu eröffnen. `--impact high|medium|low` (Default
`medium`) gruppiert den Eintrag; `tools/wikitool version regrade` korrigiert eine Note später,
wenn der Gesamteindruck des Kandidaten den Blick auf einen früheren Bump ändert.
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading,
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
Breaking-/Migration-Zeile). Darunter kommen zwei Autorenanteile: eine kurze Zusammenfassung
(ein paar Sätze, worum es in diesem Release geht) direkt unter der Liste, und darunter je Bump
ein eigener `### <Bump-Titel>`-Changeset-Absatz. Details dazu in
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) § The candidate model.
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
@@ -52,8 +57,10 @@ eine Sitzung ihn tatsächlich durchläuft:
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der
Kandidat zwei oder mehr Bumps gesammelt hat und die Zusammenfassung aus Schritt 2 noch fehlt -
ein Kandidat mit genau einem Bump ist davon ausgenommen. Committet und pusht nichts
(Invariante 5 in [AGENTS.md](AGENTS.md)).
5. **Publish bewegt `VERSION` auf `main`.**
+1 -1
View File
@@ -1 +1 @@
5.0.1
5.1.0
+6 -1
View File
@@ -107,9 +107,14 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
version and a changelog entry:
```bash
tools/wikitool version bump --patch --title "<what changed>"
tools/wikitool version bump --patch --title "<what changed>" --impact medium
```
`--impact high|medium|low` (default `medium`) grades this bump in the changelog entry's own
list - `tools/wikitool version regrade` corrects it later if the candidate's overall shape
changes the read on an earlier one; see
[instructions/dev/version-parts.md](../version-parts.md) § The candidate model.
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
replacement** for the old one - not by whether content has to be migrated:
+52 -16
View File
@@ -55,10 +55,32 @@ a new one, and only `version release` turns it into something the release workfl
its parser never has to know the suffix exists.
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
heading, date and the bumps list all move, but the entry's own prose (written below the
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
release, whatever a candidate's history of bumps looked like.
bump's `--title`, graded by `--impact`); every later bump of the *same* candidate updates that
entry in place - heading, date and the bumps list all move, but the entry's own prose is left
alone. `version notes` therefore still prints exactly one entry per release, whatever a
candidate's history of bumps looked like.
- **The entry is layered, not one undifferentiated block.** A long-running candidate can collect
dozens of bumps, chronological and equally weighted, which is unreadable as a release
announcement - `5.0.0` did this at ~1440 lines for one entry. So the entry reads, top to bottom,
as four layers with different authors and different lifetimes:
1. **Heading, author, breaking/migration lines** - written by `version bump`, anchored right
above the bump list so the line an operator most needs to act on never sits beneath a list
that can run long.
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
`version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`.
Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the
common case, and the shape every pre-existing region still is.
3. **The release summary** - a short paragraph, written once, by hand, when the candidate is
ready to ship. `version release` refuses to close an entry with two or more bumps and no
summary here; a one-bump entry is exempt, since there the bump's own changeset already reads
as the summary.
4. **The changesets**, one `### <bump title>` heading per bump, in chronological order - the
detail a reader follows into from the graded list above. A changeset is a few sentences,
not the full rationale; what needs more than that belongs in the issue tracker, not here.
The list is the index into the changesets, which is why the bump list's title text and a
changeset's `###` heading are the same string.
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
strips the suffix and turns the entry into a real, closed release - see its own row in
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
@@ -126,9 +148,12 @@ the three-line test below is usually enough.
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
entry:
5. **Grade the bump while you are making it, with `--impact high|medium|low`** (default
`medium`) - the judgment is easiest right after you did the work, not weeks later staring at a
chronological list. It is not final: `version regrade` corrects it before release if the
candidate's overall shape changes the read on an earlier bump. Then record the break in the
escalation bump itself. The bump that first crosses the boundary requires
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
```bash
tools/wikitool version bump --major \
@@ -168,16 +193,27 @@ the three-line test below is usually enough.
never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for
a TOC-verification change, then absorbed a schema removal that migrates 152 pages.
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
point to pass a summarising `--title` if the candidate collected several bump titles along the
way; without one, the heading simply keeps whichever bump last set it.
7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run
`tools/wikitool version regrade` with no arguments - it lists every bump at its current grade,
numbered in rendered order. A candidate that grew over several sessions often has a bump graded
in isolation that reads differently once the whole shape is visible; `version regrade 3 7
--impact high` corrects one or several positions against a single read of that list, put the
result in front of the user, and re-list to confirm. Only then run
`tools/wikitool version release`, which strips the `-beta.N` suffix and closes the entry - see
its row in `tools/CONTRACT.md`. That is also the point to pass a summarising `--title` if the
candidate collected several bump titles along the way; without one, the heading simply keeps
whichever bump last set it.
8. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
cannot reconstruct from the diff, and it is what the next session in this position will read
instead of guessing.
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
release time, right below the graded bump list: what this release is about, and why, for a
reader who will not read the changesets underneath. `version release` refuses to close an
entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since
there the bump's changeset already reads as one. Each **changeset**, under its own
`### <bump title>` heading, is a few sentences on what changed and why - it is the one thing a
future reader cannot reconstruct from the diff, but it is not the place for the full rationale
of a decision; that belongs in the issue tracker or the commit history, and a changeset that
is growing past a paragraph or two is a sign it belongs there instead.
## Decision points
+6 -4
View File
@@ -189,8 +189,9 @@ tools/wikitool <command> --help
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--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. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a `VERSION`/newest-changelog-entry mismatch. 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 lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--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. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. 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 lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no 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 and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
### Content migrations
@@ -399,8 +400,9 @@ is atomic, and whether a retry is safe.
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--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 | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), or `VERSION` and the changelog's newest entry naming different versions | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--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 | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
### Content migrations
+10 -5
View File
@@ -91,11 +91,16 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
5. Raise the version: `wikitool version bump --minor --title "..."` for a new
command (`--patch` for a fix, `--major` when the new version is **not a
drop-in replacement** - a renamed flag or artefact, a stricter check that
newly fails on content an instance already had, anything needing hand-work
after the copy). Content migration is one way to land there, not the
definition of it: a `--major` may well ship `--no-migration`, and one that
does migrate also needs a document under `instructions/migrations/`. The
full test is `instructions/dev/version-parts.md` - read it before choosing
newly fails on **shipped content an instance already had** (a `kb/` page,
an `instructions/*.md` file), anything needing hand-work after the copy).
A new command that is merely pickier about its *own* fresh input - a flag
it did not previously accept, a write it now refuses without more from the
caller - is the ordinary MINOR case: nothing an instance already has stops
validating, there is simply more to say when the command is next invoked.
Content migration is one way to land in the MAJOR row, not the definition
of it: a `--major` may well ship `--no-migration`, and one that does
migrate also needs a document under `instructions/migrations/`. The full
test is `instructions/dev/version-parts.md` - read it before choosing
`--major`.
A new command reaches every future instance, and CI's version gate refuses a
stack change that moved no version.
+9 -1
View File
@@ -125,7 +125,15 @@ def is_exempt(command: str, args: list[str]) -> bool:
if command in SKIP_COMMANDS:
return True
subcommand = args[0] if args and not args[0].startswith("-") else ""
return (command, subcommand) in SKIP_COMMAND_PATHS
if (command, subcommand) in SKIP_COMMAND_PATHS:
return True
# `version regrade` only reads when called with no further arguments at
# all - the bare listing. Any index (with `--impact`) writes CHANGES.md
# and stays counted like `version bump`, so this cannot join
# SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot.
if command == "version" and subcommand == "regrade":
return len(args) == 1
return False
def _session_id() -> str:
+116 -4
View File
@@ -11,7 +11,14 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
holds the two together.
- `version release` fixes that candidate: strips its `-beta.N` suffix and
closes its changelog entry. It is the only thing that turns a candidate into
a number a release actually consumes.
a number a release actually consumes. Refuses if the candidate collected
more than one bump and its entry still carries no summary above the
changesets - see `version_mod.summary_prose`.
- `version regrade` lists or changes the impact grade (high/medium/low) of
the running candidate's bump titles, addressed by their position in the
rendered list - the correction path for the judgment `version bump
--impact` made at the time, per Gitea #95's fix for an unreadably long,
ungraded bump list.
- `version check` is the one command in `wikitool` that makes a network call.
It is deliberately its own command: nothing else reaches for it implicitly,
it needs no key, it times out, and a feed that cannot be reached is reported
@@ -20,6 +27,7 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
from __future__ import annotations
import json as _json
import re
from typing import Optional
import typer
@@ -210,6 +218,13 @@ def bump_command(
"Requires a migration document already targeting the new base, and refuses when the entry "
"carries no --no-migration line to retract.",
),
impact: Optional[str] = typer.Option(
None,
"--impact",
help="high|medium|low - how much this bump matters to a reader of the release notes "
"(default: medium). Grouped into the entry's bump list; `version regrade` corrects it "
"later if the running candidate's own judgment changes.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Raise or continue the running candidate, and open or update its
@@ -242,6 +257,10 @@ def bump_command(
if not title.strip():
fail("--title must not be empty - it becomes the changelog entry's heading")
return
if impact is not None and impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
chosen_impact = impact or version_mod.DEFAULT_IMPACT
try:
current = version_mod.read_version()
@@ -351,13 +370,15 @@ def bump_command(
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
impact=chosen_impact,
),
encoding="utf-8",
)
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
f"`version release` once the candidate is ready to ship."
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
f"publishing, and `version release` once the candidate is ready to ship."
)
@@ -382,7 +403,12 @@ def release_command(
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
Refuses when `VERSION` is already a release: there is no running candidate
to fix."""
to fix. Also refuses - Gitea #95 - when the candidate collected two or
more bumps and its entry still has no summary paragraph above the
individual changesets: a release note that is only a chronological bump
list is exactly the thing this refusal exists to stop shipping. A
candidate with exactly one bump is exempt - there, the bump's own
changeset already is the summary."""
try:
current = version_mod.read_version()
except VersionError as exc:
@@ -412,6 +438,18 @@ def release_command(
)
return
section = version_mod.changes_section(text, current) or ""
bump_count = len(version_mod.bump_entries(section))
summary_chars = len(re.sub(r"\s+", "", version_mod.summary_prose(section)))
if bump_count >= 2 and summary_chars < version_mod.SUMMARY_MIN_CHARS:
fail(
f"This candidate collected {bump_count} bumps, but its {version_mod.CHANGES_FILENAME} "
"entry carries no summary above the individual changesets - write a short paragraph "
"(a few sentences on what this release is about) right below the bump list before "
"releasing. `version regrade` (no arguments) shows the bump list first, if that helps."
)
return
new_version = current.base
if dry_run:
@@ -428,3 +466,77 @@ def release_command(
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
"is what release.yml reacts to."
)
@app.command("regrade")
def regrade_command(
indices: Optional[list[int]] = typer.Argument(
None,
help="1-based positions in the rendered bump list to regrade (see the bare listing). "
"Omit to just list.",
),
impact: Optional[str] = typer.Option(
None, "--impact", help="high|medium|low - required together with indices"
),
):
"""List the running candidate's bump titles with their impact grade, or
change one or more of them in a single call.
Positions are `version_mod.bump_entries`'s own rendered order - grouped
High before Medium before Low, chronological within a grade - as it
stands *before* this call: `wikitool version regrade 3 7 --impact high`
regrades both against today's list in one read, not #3 first and then #7
against whatever regrading #3 produced. Run the bare command again
afterwards to see the result and its new numbering.
The bare listing is read-only and, like `version notes`, exempt from the
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
like `version bump`, because that is what it does."""
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there is nothing to regrade")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a regrade. "
"Fix whichever is wrong."
)
return
section = version_mod.changes_section(text, current) or ""
entries = version_mod.bump_entries(section)
if not entries:
fail(f"{current}'s {version_mod.CHANGES_FILENAME} entry has no bump list to regrade.")
return
if not indices:
for position, (level, bump_title) in enumerate(entries, start=1):
typer.echo(f"{position}. [{level}] {bump_title}")
return
if impact is None:
fail("--impact is required when regrading - pass one of high/medium/low.")
return
if impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
updates = {index: impact for index in indices}
try:
new_text = version_mod.regrade(text, current, updates)
except VersionError as exc:
fail(str(exc))
return
changes.write_text(new_text, encoding="utf-8")
success(f"Regraded {len(indices)} bump title(s) to {impact} impact.")
+13
View File
@@ -189,6 +189,19 @@ def test_search_exemption_survives_a_query_that_looks_like_a_subcommand():
assert not run_budget.is_exempt("publish", ["--message", "x"])
def test_version_regrade_bare_listing_is_exempt():
"""Gitea #95: `version regrade` only reads when called with no further
arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS
(that dict only ever looks at the subcommand slot), so it is its own
branch in `is_exempt`."""
assert run_budget.is_exempt("version", ["regrade"])
def test_version_regrade_with_indices_is_counted():
assert not run_budget.is_exempt("version", ["regrade", "1", "--impact", "high"])
assert not run_budget.is_exempt("version", ["regrade", "3"])
def test_reset_command_requires_yes():
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
with pytest.raises(typer.Exit):
+267 -31
View File
@@ -34,6 +34,17 @@ def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
return tmp_path
def _write_summary(tree: Path, words: str = "Summary paragraph. ") -> None:
"""Append enough non-whitespace prose above the changesets for `version
release`'s summary requirement to pass - the way an author's own edit
would, between the bumps region and any `### <bump title>` heading."""
path = tree / "CHANGES.md"
text = path.read_text(encoding="utf-8")
marker = version_mod._BUMPS_CLOSE + "\n"
assert marker in text
path.write_text(text.replace(marker, marker + "\n" + words * 15 + "\n", 1), encoding="utf-8")
# --- the version itself ----------------------------------------------------
@@ -318,13 +329,237 @@ def test_release_entry_can_replace_the_title():
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
# --- the changelog: impact grading (Gitea #95) ------------------------------
def test_bumps_block_with_all_medium_renders_flat_like_before():
section = version_mod._bumps_block([("medium", "First"), ("medium", "Second")])
assert version_mod._IMPACT_GROUP_RE.search(section) is None
assert "- First" in section and "- Second" in section
def test_bumps_block_groups_by_impact_in_high_medium_low_order():
section = version_mod._bumps_block(
[("low", "Low one"), ("high", "High one"), ("medium", "Medium one")]
)
assert section.index("**High impact**") < section.index("**Medium impact**")
assert section.index("**Medium impact**") < section.index("**Low impact**")
assert "- High one" in section and "- Medium one" in section and "- Low one" in section
def test_bumps_block_omits_empty_groups():
section = version_mod._bumps_block([("high", "Only one"), ("low", "Another")])
assert "**Medium impact**" not in section
def test_bump_entries_round_trips_through_bumps_block():
"""Round-trips the *grouping*, not the original chronological order: the
region is grouped by grade, so a reparse reads back grouped too."""
entries = [("high", "First"), ("low", "Second"), ("high", "Third")]
section = version_mod._bumps_block(entries)
assert version_mod.bump_entries(section) == [
("high", "First"), ("high", "Third"), ("low", "Second"),
]
def test_bump_entries_reads_a_pre_impact_flat_region_as_all_medium():
section = f"{version_mod._BUMPS_OPEN}\n- Old style\n- Another\n{version_mod._BUMPS_CLOSE}"
assert version_mod.bump_entries(section) == [("medium", "Old style"), ("medium", "Another")]
def test_summary_prose_is_the_text_between_the_bumps_region_and_the_first_changeset():
section = (
f"## 1.1.0 - 2026-09-12 - Title\n\n**Author:** Someone\n\n"
f"{version_mod._bumps_block([('medium', 'A')])}\n\n"
"This is the summary.\n\n### A\n\nDetail.\n"
)
assert version_mod.summary_prose(section).strip() == "This is the summary."
def test_regrade_changes_several_indices_against_one_read():
text = CHANGES_HEADER + version_mod.insert_changes_entry(
"", Version(0, 2, 0, beta=1), "2026-09-01", "First", "Someone",
)
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=2), "2026-09-02", "Second", "Someone")
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=3), "2026-09-03", "Third", "Someone")
regraded = version_mod.regrade(text, Version(0, 2, 0, beta=3), {1: "high", 3: "low"})
section = version_mod.changes_section(regraded, Version(0, 2, 0, beta=3))
assert version_mod.bump_entries(section) == [
("high", "First"), ("medium", "Second"), ("low", "Third"),
]
def test_regrade_refuses_an_out_of_range_index():
text = CHANGES_HEADER + version_mod.insert_changes_entry(
"", Version(0, 2, 0, beta=1), "2026-09-01", "Only", "Someone",
)
with pytest.raises(VersionError):
version_mod.regrade(text, Version(0, 2, 0, beta=1), {5: "high"})
# --- version bump: --impact -------------------------------------------------
def test_bump_without_impact_defaults_to_medium(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, version_mod.read_version())
assert version_mod.bump_entries(section) == [("medium", "First bump")]
def test_bump_with_impact_grades_the_bump_and_a_follow_up_regroups_the_list(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact="low", dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, version_mod.read_version())
assert version_mod.bump_entries(section) == [("high", "First bump"), ("low", "Second bump")]
assert "**High impact**" in section and "**Low impact**" in section
def test_bump_refuses_an_unknown_impact(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact="urgent", dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_breaking_line_sits_above_the_bumps_region(tree):
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,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
# --- version regrade --------------------------------------------------------
def test_regrade_lists_the_running_candidates_bumps(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
)
version_cmd.regrade_command(indices=None, impact=None)
out = capsys.readouterr().out
assert "1. [high] First bump" in out
def test_regrade_writes_the_new_impact_and_leaves_version_untouched(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_before = (tree / "VERSION").read_text(encoding="utf-8")
version_cmd.regrade_command(indices=[1, 2], impact="high")
assert (tree / "VERSION").read_text(encoding="utf-8") == version_before
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, version_mod.read_version())
assert version_mod.bump_entries(section) == [("high", "First bump"), ("high", "Second bump")]
def test_regrade_refuses_without_impact(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.regrade_command(indices=[1], impact=None)
def test_regrade_refuses_an_out_of_range_index_via_the_cli(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.regrade_command(indices=[9], impact="high")
def test_regrade_fails_without_a_running_candidate(tree):
with pytest.raises(typer.Exit):
version_cmd.regrade_command(indices=None, impact=None)
# --- version release: the summary requirement (Gitea #95) -------------------
def test_release_refuses_two_bumps_with_no_summary(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
def test_release_dry_run_also_refuses_without_a_summary(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=True)
def test_release_passes_with_two_bumps_once_a_summary_is_written(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_write_summary(tree)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
def test_release_passes_with_a_single_bump_and_no_summary(tree):
"""The one-bump exemption: there, the bump's own changeset already is the
summary. `test_release_fixes_version_and_the_changelog_heading` already
covers this shape; this test names the exemption explicitly."""
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,
)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
# --- version bump ----------------------------------------------------------
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Something happened",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -336,11 +571,11 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -350,7 +585,7 @@ def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(t
def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, migration_required=False, dry_run=True
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=True
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -363,7 +598,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
major, minor, patch = flags
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -371,7 +606,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
def test_bump_refuses_an_empty_title(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, dry_run=False
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
)
@@ -384,7 +619,7 @@ def test_bump_refuses_when_version_and_changelog_disagree(tree):
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -398,7 +633,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -413,7 +648,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
@@ -430,11 +665,11 @@ def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migratio
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -446,7 +681,7 @@ def test_no_migration_records_the_reason_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the release feed moved",
no_migration="no distributed instance exists yet", migration_required=False, dry_run=False,
no_migration="no distributed instance exists yet", migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes
@@ -458,7 +693,7 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix",
breaking=None, no_migration="not needed", migration_required=False, dry_run=False,
breaking=None, no_migration="not needed", migration_required=False, impact=None, dry_run=False,
)
@@ -479,7 +714,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
one after all has no other way to take that statement back."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes
@@ -487,7 +722,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER not in changes
@@ -498,12 +733,12 @@ def test_migration_required_retracts_the_no_migration_line(tree):
def test_migration_required_is_refused_without_a_migration_document(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
@@ -513,12 +748,12 @@ def test_migration_required_is_refused_with_no_no_migration_line_to_retract(tree
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Nothing to retract",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
@@ -527,7 +762,7 @@ def test_migration_required_is_refused_together_with_no_migration(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Contradiction",
breaking="the feed moved", no_migration="kb/ untouched",
migration_required=True, dry_run=False,
migration_required=True, impact=None, dry_run=False,
)
@@ -536,7 +771,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
@@ -564,7 +799,7 @@ def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -573,7 +808,7 @@ def test_breaking_records_what_stops_working_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking="update_url points at a repo path that no longer exists",
no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
@@ -590,7 +825,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="New command",
breaking="nothing, really", no_migration=None, migration_required=False, dry_run=False,
breaking="nothing, really", no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -601,7 +836,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
def test_release_fixes_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
@@ -614,12 +849,13 @@ def test_release_fixes_version_and_the_changelog_heading(tree):
def test_release_can_replace_the_title(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_write_summary(tree)
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
@@ -632,7 +868,7 @@ def test_release_can_replace_the_title(tree):
def test_release_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=True)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
@@ -647,7 +883,7 @@ def test_release_refuses_when_version_is_already_a_release(tree):
def test_release_refuses_when_version_and_changelog_disagree(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
with pytest.raises(typer.Exit):
@@ -665,11 +901,11 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.notes_command(version=None)
out = capsys.readouterr().out
+141 -28
View File
@@ -459,20 +459,125 @@ def changes_section(text: str, version: Version) -> Optional[str]:
return None
def _bumps_block(titles: list[str]) -> str:
lines = "\n".join(f"- {title}" for title in titles)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
# Grading it at bump time, and letting a session regrade it before release,
# is the fix; see instructions/dev/version-parts.md § The candidate model.
IMPACT_LEVELS = ("high", "medium", "low")
DEFAULT_IMPACT = "medium"
_IMPACT_GROUP_RE = re.compile(r"^\*\*(High|Medium|Low) impact\*\*$", re.MULTILINE)
def _bump_titles(section: str) -> list[str]:
def _bumps_block(entries: list[tuple[str, str]]) -> str:
"""Render the bumps region from `(impact, title)` pairs.
Grouped under a `**High/Medium/Low impact**` heading, in that order, each
present only if it holds at least one title. **Except** when every entry
is `medium` (the default, and the only grade that existed before this):
rendered flat, with no heading at all, exactly as `version bump` always
wrote it. That keeps a single-bump patch entry, and every entry a build
that predates `--impact` ever wrote, byte-identical to what it was.
"""
if all(impact == DEFAULT_IMPACT for impact, _ in entries):
lines = "\n".join(f"- {title}" for _, title in entries)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
groups: dict[str, list[str]] = {level: [] for level in IMPACT_LEVELS}
for impact, title in entries:
groups[impact].append(title)
rendered = [
f"**{level.capitalize()} impact**\n" + "\n".join(f"- {title}" for title in groups[level])
for level in IMPACT_LEVELS
if groups[level]
]
return f"{_BUMPS_OPEN}\n" + "\n\n".join(rendered) + f"\n{_BUMPS_CLOSE}"
def bump_entries(section: str) -> list[tuple[str, str]]:
"""The bumps region parsed back into `(impact, title)` pairs, in rendered
order - the addressing `version regrade` and `version_cmd.release_command`
use.
A `**<Grade> impact**` heading sets the running grade for the `- ` lines
beneath it; a `- ` line with none above it - the shape every region had
before `--impact` existed, and the flat shape `_bumps_block` still writes
when every grade is `medium` - reads as `medium`. That is what makes an
old region parse the same as a new one that happens to grade everything
the same way.
"""
match = _BUMPS_RE.search(section)
if not match:
return []
return [
line[2:].strip()
for line in match.group(1).strip("\n").splitlines()
if line.strip().startswith("- ")
entries: list[tuple[str, str]] = []
current = DEFAULT_IMPACT
for line in match.group(1).strip("\n").splitlines():
stripped = line.strip()
heading_match = _IMPACT_GROUP_RE.match(stripped)
if heading_match:
current = heading_match.group(1).lower()
continue
if stripped.startswith("- "):
entries.append((current, stripped[2:].strip()))
return entries
# The free-form paragraph `version release` requires above the changesets once
# a candidate collected more than one bump - see `summary_prose` and
# `version_cmd.release_command`. A number, not a quality judgement: it catches
# the empty and the one-line "TODO" case, nothing subtler.
SUMMARY_MIN_CHARS = 200
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
def summary_prose(section: str) -> str:
"""The text between the bumps region (or, for an entry with none, the
heading) and the first `### <bump title>` changeset heading - the
candidate's own summary of what it did, as opposed to the per-bump detail
below it.
"""
close = section.find(_BUMPS_CLOSE)
if close != -1:
start = close + len(_BUMPS_CLOSE)
else:
heading_match = _CHANGES_ENTRY_RE.match(section)
start = heading_match.end() if heading_match else 0
heading = _CHANGESET_HEADING_RE.search(section, start)
end = heading.start() if heading else len(section)
return section[start:end]
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
"""Change the impact grade of one or more of the topmost entry's bump
titles, addressed by their 1-based position in `bump_entries`'s rendered
order.
All of `updates` are read against a **single** parse of the region, so
`{3: "high", 7: "high"}` in one call means "regrade these two against
today's list" - not "regrade #3, re-render, then regrade #7 against
whatever that produced". `version_cmd.regrade_command` is the only
caller; `version` must already equal the entry it addresses (the same
VERSION/newest-entry agreement every other write here requires).
"""
start, end = _entry_span(text)
section = text[start:end]
heading_match = _CHANGES_ENTRY_RE.match(section)
if not heading_match or Version.parse(heading_match.group(1)) != version:
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry does not name {version}")
entries = bump_entries(section)
if not entries:
raise VersionError(f"{version}'s {CHANGES_FILENAME} entry has no bump list to regrade")
out_of_range = sorted(i for i in updates if i < 1 or i > len(entries))
if out_of_range:
raise VersionError(
f"index/indices out of range (1-{len(entries)}): {', '.join(map(str, out_of_range))}"
)
new_entries = [
(updates.get(position, impact), title)
for position, (impact, title) in enumerate(entries, start=1)
]
new_section = _BUMPS_RE.sub(lambda _m: _bumps_block(new_entries), section, count=1)
return text[:start] + new_section + text[end:]
def _set_marker_line(section: str, marker: str, line: str) -> str:
@@ -481,17 +586,17 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
if pattern.search(section):
return pattern.sub(line, section, count=1)
anchor = section.find(_BUMPS_CLOSE)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
insert_at = section.find("\n", anchor)
insert_at = insert_at + 1 if insert_at != -1 else len(section)
else:
insert_at = len(section)
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
return section[:anchor] + f"{line}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{line}\n"
def _clear_marker_line(section: str, marker: str) -> str:
@@ -525,10 +630,12 @@ def _update_open_candidate(
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`title` to its machine-managed bump list, and set the breaking/no-migration
lines only where this call supplies them - see `insert_changes_entry`.
`(impact, title)` to its machine-managed bump list, and set the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
@@ -541,7 +648,9 @@ def _update_open_candidate(
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
section = _BUMPS_RE.sub(
lambda _m: _bumps_block(bump_entries(section) + [(impact, title)]), section, count=1
)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
@@ -562,6 +671,7 @@ def insert_changes_entry(
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Open a new entry above the newest existing one, or - when the topmost
entry is still an open candidate (a pre-release heading) - update that
@@ -573,14 +683,17 @@ def insert_changes_entry(
the topmost heading still a pre-release" the right test for "is a
candidate still open" here.
A fresh entry gets the skeleton only: heading, date, author, the
machine-managed bump-title list (started with this one title, for a
candidate), and - when a compatibility boundary is crossed - the line
saying what breaks, plus the line saying no content has to change where
that applies. The break comes first: it is what an operator reading the
release notes has to act on, and the migration line only qualifies it. The
entry's actual prose is written afterwards by whoever made the change,
which is also why `bump` refuses to invent a title.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks, plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than
below it (Gitea #95): it is what an operator reading the release notes has
to act on, the migration line only qualifies it, and neither should sit
beneath a list that can run to dozens of graded entries. The entry's
actual prose - the release summary, and each bump's own changeset - is
written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
`migration_required` only has anything to retract on an already-open
candidate, so a fresh entry ignores it - there is no earlier
@@ -591,16 +704,16 @@ def insert_changes_entry(
return _update_open_candidate(
text, version, date, title,
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
migration_required=migration_required,
migration_required=migration_required, impact=impact,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if version.is_prerelease:
lines += [_bumps_block([title]), ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason:
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
if version.is_prerelease:
lines += [_bumps_block([(impact, title)]), ""]
entry = "\n".join(lines) + "\n---\n\n"
anchor = re.search(r"^## ", text, re.MULTILINE)
if anchor: