Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b0158fc8d |
+54
-7
@@ -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
|
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
|
opening another: the heading's version/date/title move, and the bump's
|
||||||
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||||
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
|
||||||
never by hand. `wikitool version release` is what closes a candidate: it
|
written and read by `wikitool version bump`, never by hand.
|
||||||
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
|
That list is graded, not a flat chronological dump: each bump carries an
|
||||||
instance never sees a `-beta.` version at all (`release.yml` only ever
|
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
||||||
releases a fixed one), so this suffix and the list beneath it are a
|
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
||||||
dev-checkout concern - readable here, never shipped as something to parse.
|
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
@@ -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
|
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||||
eine Sitzung ihn tatsächlich durchläuft:
|
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
|
```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
|
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
|
`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,
|
2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading,
|
||||||
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
|
||||||
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
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:**
|
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
|
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,
|
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
|
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der
|
||||||
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
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`.**
|
5. **Publish bewegt `VERSION` auf `main`.**
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,14 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
version and a changelog entry:
|
version and a changelog entry:
|
||||||
|
|
||||||
```bash
|
```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`
|
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
|
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:
|
replacement** for the old one - not by whether content has to be migrated:
|
||||||
|
|||||||
@@ -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.
|
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
|
- **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
|
(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 -
|
bump's `--title`, graded by `--impact`); every later bump of the *same* candidate updates that
|
||||||
heading, date and the bumps list all move, but the entry's own prose (written below the
|
entry in place - heading, date and the bumps list all move, but the entry's own prose is left
|
||||||
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
alone. `version notes` therefore still prints exactly one entry per release, whatever a
|
||||||
release, whatever a candidate's history of bumps looked like.
|
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`
|
- **`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.
|
||||||
@@ -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.
|
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
|
5. **Grade the bump while you are making it, with `--impact high|medium|low`** (default
|
||||||
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
`medium`) - the judgment is easiest right after you did the work, not weeks later staring at a
|
||||||
entry:
|
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
|
```bash
|
||||||
tools/wikitool version bump --major \
|
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
|
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.
|
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
|
7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run
|
||||||
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
`tools/wikitool version regrade` with no arguments - it lists every bump at its current grade,
|
||||||
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
numbered in rendered order. A candidate that grew over several sessions often has a bump graded
|
||||||
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
in isolation that reads differently once the whole shape is visible; `version regrade 3 7
|
||||||
way; without one, the heading simply keeps whichever bump last set it.
|
--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
|
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
|
||||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
|
||||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
release time, right below the graded bump list: what this release is about, and why, for a
|
||||||
instead of guessing.
|
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
|
## Decision points
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -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 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 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 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 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 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 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
|
### 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 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 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 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 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 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 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
|
### Content migrations
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -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
|
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
|
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
|
drop-in replacement** - a renamed flag or artefact, a stricter check that
|
||||||
newly fails on content an instance already had, anything needing hand-work
|
newly fails on **shipped content an instance already had** (a `kb/` page,
|
||||||
after the copy). Content migration is one way to land there, not the
|
an `instructions/*.md` file), anything needing hand-work after the copy).
|
||||||
definition of it: a `--major` may well ship `--no-migration`, and one that
|
A new command that is merely pickier about its *own* fresh input - a flag
|
||||||
does migrate also needs a document under `instructions/migrations/`. The
|
it did not previously accept, a write it now refuses without more from the
|
||||||
full test is `instructions/dev/version-parts.md` - read it before choosing
|
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`.
|
`--major`.
|
||||||
A new command reaches every future instance, and CI's version gate refuses a
|
A new command reaches every future instance, and CI's version gate refuses a
|
||||||
stack change that moved no version.
|
stack change that moved no version.
|
||||||
|
|||||||
@@ -125,7 +125,15 @@ def is_exempt(command: str, args: list[str]) -> bool:
|
|||||||
if command in SKIP_COMMANDS:
|
if command in SKIP_COMMANDS:
|
||||||
return True
|
return True
|
||||||
subcommand = args[0] if args and not args[0].startswith("-") else ""
|
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:
|
def _session_id() -> str:
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
|||||||
holds the two together.
|
holds the two together.
|
||||||
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
- `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
|
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.
|
- `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 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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -210,6 +218,13 @@ def bump_command(
|
|||||||
"Requires a migration document already targeting the new base, and refuses when the entry "
|
"Requires a migration document already targeting the new base, and refuses when the entry "
|
||||||
"carries no --no-migration line to retract.",
|
"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"),
|
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
|
"""Raise or continue the running candidate, and open or update its
|
||||||
@@ -242,6 +257,10 @@ def bump_command(
|
|||||||
if not title.strip():
|
if not title.strip():
|
||||||
fail("--title must not be empty - it becomes the changelog entry's heading")
|
fail("--title must not be empty - it becomes the changelog entry's heading")
|
||||||
return
|
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:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
@@ -351,13 +370,15 @@ def bump_command(
|
|||||||
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_required=migration_required,
|
||||||
|
impact=chosen_impact,
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
|
||||||
success(
|
success(
|
||||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
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"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
|
||||||
f"`version release` once the candidate is ready to ship."
|
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
|
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||||
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||||
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."""
|
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:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
@@ -412,6 +438,18 @@ def release_command(
|
|||||||
)
|
)
|
||||||
return
|
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
|
new_version = current.base
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -428,3 +466,77 @@ def release_command(
|
|||||||
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 "
|
||||||
"is what release.yml reacts to."
|
"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.")
|
||||||
|
|||||||
@@ -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"])
|
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():
|
def test_reset_command_requires_yes():
|
||||||
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
|
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
return tmp_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 ----------------------------------------------------
|
# --- 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
|
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 ----------------------------------------------------------
|
# --- version bump ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="Something happened",
|
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"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
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):
|
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First",
|
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(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second",
|
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"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
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):
|
def test_bump_dry_run_writes_nothing(tree):
|
||||||
version_cmd.bump_command(
|
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 (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")
|
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
|
major, minor, patch = flags
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
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"
|
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):
|
def test_bump_refuses_an_empty_title(tree):
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
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"
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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"
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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"
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Follow-up",
|
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"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the release feed moved",
|
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")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Fix",
|
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."""
|
one after all has no other way to take that statement back."""
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
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")
|
_migration_document(tree, "2.0.0")
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
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")
|
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
|
||||||
@@ -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):
|
def test_migration_required_is_refused_without_a_migration_document(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
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")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
|
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")
|
_migration_document(tree, "2.0.0")
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Nothing to retract",
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Contradiction",
|
major=True, minor=False, patch=False, title="Contradiction",
|
||||||
breaking="the feed moved", no_migration="kb/ untouched",
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Fix",
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
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"
|
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(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||||
breaking="update_url points at a repo path that no longer exists",
|
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")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
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):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="New 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"
|
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):
|
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
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)
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
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):
|
def test_release_can_replace_the_title(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
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(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second bump",
|
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)
|
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert "## 1.1.0 - " in changes
|
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):
|
def test_release_dry_run_writes_nothing(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
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)
|
version_cmd.release_command(title=None, dry_run=True)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
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):
|
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
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")
|
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||||
with pytest.raises(typer.Exit):
|
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):
|
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
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(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second bump",
|
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)
|
version_cmd.notes_command(version=None)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
+140
-27
@@ -459,20 +459,125 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _bumps_block(titles: list[str]) -> str:
|
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
|
||||||
lines = "\n".join(f"- {title}" for title in titles)
|
# 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 _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}"
|
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_titles(section: str) -> list[str]:
|
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)
|
match = _BUMPS_RE.search(section)
|
||||||
if not match:
|
if not match:
|
||||||
return []
|
return []
|
||||||
return [
|
entries: list[tuple[str, str]] = []
|
||||||
line[2:].strip()
|
current = DEFAULT_IMPACT
|
||||||
for line in match.group(1).strip("\n").splitlines()
|
for line in match.group(1).strip("\n").splitlines():
|
||||||
if line.strip().startswith("- ")
|
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:
|
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
|
Used for the breaking-change and no-migration lines, which - unlike the
|
||||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||||
restates it rather than growing a list nobody would read as history.
|
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)
|
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||||
if pattern.search(section):
|
if pattern.search(section):
|
||||||
return pattern.sub(line, section, count=1)
|
return pattern.sub(line, section, count=1)
|
||||||
anchor = section.find(_BUMPS_CLOSE)
|
anchor = section.find(_BUMPS_OPEN)
|
||||||
if anchor != -1:
|
if anchor != -1:
|
||||||
insert_at = section.find("\n", anchor)
|
return section[:anchor] + f"{line}\n\n" + section[anchor:]
|
||||||
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
return section.rstrip() + f"\n\n{line}\n"
|
||||||
else:
|
|
||||||
insert_at = len(section)
|
|
||||||
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_marker_line(section: str, marker: str) -> str:
|
def _clear_marker_line(section: str, marker: str) -> str:
|
||||||
@@ -525,10 +630,12 @@ def _update_open_candidate(
|
|||||||
breaking_reason: Optional[str],
|
breaking_reason: Optional[str],
|
||||||
no_migration_reason: Optional[str],
|
no_migration_reason: Optional[str],
|
||||||
migration_required: bool = False,
|
migration_required: bool = False,
|
||||||
|
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
|
||||||
`title` to its machine-managed bump list, and set the breaking/no-migration
|
`(impact, title)` to its machine-managed bump list, and set the
|
||||||
lines only where this call supplies them - see `insert_changes_entry`.
|
breaking/no-migration lines only where this call supplies them - see
|
||||||
|
`insert_changes_entry`.
|
||||||
|
|
||||||
`migration_required` retracts an earlier `--no-migration` line instead of
|
`migration_required` retracts an earlier `--no-migration` line instead of
|
||||||
setting one - the two are mutually exclusive on a single bump, enforced by
|
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")
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
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:
|
if breaking_reason:
|
||||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {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,
|
no_migration_reason: Optional[str] = None,
|
||||||
breaking_reason: Optional[str] = None,
|
breaking_reason: Optional[str] = None,
|
||||||
migration_required: bool = False,
|
migration_required: bool = False,
|
||||||
|
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
|
||||||
entry is still an open candidate (a pre-release heading) - update that
|
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
|
the topmost heading still a pre-release" the right test for "is a
|
||||||
candidate still open" here.
|
candidate still open" here.
|
||||||
|
|
||||||
A fresh entry gets the skeleton only: heading, date, author, the
|
A fresh entry gets the skeleton only: heading, date, author, - when a
|
||||||
machine-managed bump-title list (started with this one title, for a
|
compatibility boundary is crossed - the line saying what breaks, plus the
|
||||||
candidate), and - when a compatibility boundary is crossed - the line
|
line saying no content has to change where that applies, and then the
|
||||||
saying what breaks, plus the line saying no content has to change where
|
machine-managed bump list (started with this one `(impact, title)` pair,
|
||||||
that applies. The break comes first: it is what an operator reading the
|
for a candidate). The break comes first, above the bump list rather than
|
||||||
release notes has to act on, and the migration line only qualifies it. The
|
below it (Gitea #95): it is what an operator reading the release notes has
|
||||||
entry's actual prose is written afterwards by whoever made the change,
|
to act on, the migration line only qualifies it, and neither should sit
|
||||||
which is also why `bump` refuses to invent a title.
|
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
|
`migration_required` only has anything to retract on an already-open
|
||||||
candidate, so a fresh entry ignores it - there is no earlier
|
candidate, so a fresh entry ignores it - there is no earlier
|
||||||
@@ -591,16 +704,16 @@ def insert_changes_entry(
|
|||||||
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,
|
migration_required=migration_required, impact=impact,
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||||
if version.is_prerelease:
|
|
||||||
lines += [_bumps_block([title]), ""]
|
|
||||||
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 no_migration_reason:
|
||||||
lines += [f"{MIGRATION_NONE_MARKER} - {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"
|
entry = "\n".join(lines) + "\n---\n\n"
|
||||||
anchor = re.search(r"^## ", text, re.MULTILINE)
|
anchor = re.search(r"^## ", text, re.MULTILINE)
|
||||||
if anchor:
|
if anchor:
|
||||||
|
|||||||
Reference in New Issue
Block a user