Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29d400dd3 | |||
| b1883befc7 |
@@ -66,6 +66,26 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
version="$(cat VERSION | tr -d '[:space:]')"
|
version="$(cat VERSION | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
# A running candidate (`X.Y.Z-beta.N`) is never released - betas are
|
||||||
|
# a dev-checkout state, not a distributed one (see
|
||||||
|
# instructions/dev/version-parts.md). This guard sits *before* the
|
||||||
|
# API query below: without it, every `version bump` on a candidate
|
||||||
|
# would push VERSION and trigger a wasted round-trip against the
|
||||||
|
# releases API for a tag that was never going to be created. Ending
|
||||||
|
# the job cleanly here (not `exit 1`) is what keeps a beta bump a
|
||||||
|
# normal, unremarkable push rather than a failing CI run - skipping
|
||||||
|
# every later step is what "cleanly" means in Actions: mark this one
|
||||||
|
# skip and gate the rest on it.
|
||||||
|
case "$version" in
|
||||||
|
*-beta.*)
|
||||||
|
echo "VERSION is a running candidate (${version}) - nothing to release. Skipping."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
tag="v${version}"
|
tag="v${version}"
|
||||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -82,6 +102,7 @@ jobs:
|
|||||||
- name: Release notes from CHANGES.md
|
- name: Release notes from CHANGES.md
|
||||||
# `version notes` fails when the changelog has no entry for this
|
# `version notes` fails when the changelog has no entry for this
|
||||||
# version, which is the last place that mistake can still be caught.
|
# version, which is the last place that mistake can still be caught.
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
tools/wikitool docs verify
|
tools/wikitool docs verify
|
||||||
@@ -90,6 +111,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the distribution tarball
|
- name: Build the distribution tarball
|
||||||
id: build
|
id: build
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
@@ -109,6 +131,7 @@ jobs:
|
|||||||
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Publish the release
|
- name: Publish the release
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
TOKEN: ${{ gitea.token }}
|
TOKEN: ${{ gitea.token }}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
|||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
||||||
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
||||||
|
| `DEVELOPMENT.md` | Humans - the release workflow (`version bump`/`version release`/`publish`/CI), for whoever develops this stack rather than an instance built on it | Never by an agent as instruction. Not shipped: `dist_cmd.ROOT_FILES` excludes it deliberately, the same way `instructions/dev/` (which it may link to, unlike the documents `instructions verify` holds to that rule) is excluded - a distributed instance has no release workflow to document |
|
||||||
| `AGENTS.md` | Agents | Always, every session |
|
| `AGENTS.md` | Agents | Always, every session |
|
||||||
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
||||||
| `USER.md` | Agents | Always, every session |
|
| `USER.md` | Agents | Always, every session |
|
||||||
|
|||||||
+113
@@ -18,6 +18,119 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
|
|||||||
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
||||||
their date-only headings.
|
their date-only headings.
|
||||||
|
|
||||||
|
Since `4.4.0` the stack carries **one running candidate** between two
|
||||||
|
releases rather than a fresh version per bump - see
|
||||||
|
`instructions/dev/version-parts.md`. While a candidate is open its heading
|
||||||
|
names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
|
||||||
|
every bump of that same candidate updates this one entry in place rather than
|
||||||
|
opening another: the heading's version/date/title move, and the bump's
|
||||||
|
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||||
|
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
||||||
|
never by hand. `wikitool version release` is what closes a candidate: it
|
||||||
|
strips the suffix and turns the entry into an ordinary, suffix-free one,
|
||||||
|
leaving the bump-title list as the record of what happened. A distributed
|
||||||
|
instance never sees a `-beta.` version at all (`release.yml` only ever
|
||||||
|
releases a fixed one), so this suffix and the list beneath it are a
|
||||||
|
dev-checkout concern - readable here, never shipped as something to parse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4.0 - 2026-09-03 - Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Bisher bekam jeder `version bump` sofort eine fixierte, dauerhafte Nummer, unabhängig davon, ob
|
||||||
|
je ein Release dazu erschien - fünf Minor-Bumps ohne Release ergaben fünf Nummern, von denen vier
|
||||||
|
nie ausgeliefert wurden. `VERSION` trägt jetzt zwischen zwei Releases **einen** laufenden
|
||||||
|
Kandidaten (`X.Y.Z-beta.N`) statt einer neuen Nummer pro Bump: `--major/--minor/--patch`
|
||||||
|
eskaliert diesen Kandidaten max-wins gegen den letzten Release, statt daneben eine neue Nummer zu
|
||||||
|
schreiben, und schrittet dabei nie zurück.
|
||||||
|
|
||||||
|
`Version` versteht den Suffix, mit einer expliziten Ordnung
|
||||||
|
(`4.4.0-beta.1 < 4.4.0-beta.2 < 4.4.0`, numerisch nach `N`, nicht lexikografisch). `CHANGES.md`
|
||||||
|
trägt genau einen offenen Eintrag pro Kandidat: der erste Bump eröffnet ihn, jeder weitere
|
||||||
|
aktualisiert Heading und die maschinenverwaltete Bump-Titel-Liste in
|
||||||
|
`<!-- wikitool:bumps -->` (Marker-Konvention aus `blocks.py`, aber bewusst nicht in
|
||||||
|
`blocks.BLOCKS` - diese Region gehört zu `CHANGES.md`, nicht zu einer Seite). `version release`
|
||||||
|
ist neu und fixiert einen Kandidaten: Suffix weg, Eintrag geschlossen, committet und pusht nichts.
|
||||||
|
|
||||||
|
Vier Stellen am Bestand angepasst, die das Kandidatenmodell sonst still beschädigt hätten:
|
||||||
|
`release.yml` überspringt einen suffixbehafteten `VERSION`-Push sauber, bevor die Releases-API
|
||||||
|
gefragt wird, statt jeden Beta-Bump zu veröffentlichen; die Grenzübertritts-Checks in
|
||||||
|
`docs verify` (`check_migration_for_boundary`, `check_breaking_change_for_boundary`) messen jetzt
|
||||||
|
gegen den **letzten Release** (`version_mod.last_release`) statt gegen den zweitobersten Eintrag,
|
||||||
|
der zwischen zwei Betas keine Grenze mehr hergibt; `kb_state.chain()`/`next_link()` vergleichen
|
||||||
|
gegen die **Kandidatenbasis**, weil eine Migration mit Ziel `4.4.0` sonst bei installiertem
|
||||||
|
`4.4.0-beta.1` aus dem Intervall fällt (`4.4.0-beta.1 < 4.4.0`); `read_kb_version()` verweigert
|
||||||
|
einen Prerelease, weil eine Inhaltsform kein Beta kennt. `dist export` schreibt `VERSION` und den
|
||||||
|
Stamp weiterhin ehrlich mit Suffix, aber `.wikitool-kb.json` bekommt die Basis.
|
||||||
|
|
||||||
|
Menschendoku für die Erzeuger-Seite: `DEVELOPMENT.md` im Repo-Root, bewusst nicht in
|
||||||
|
`dist_cmd.ROOT_FILES` (Begründung als Kommentar dort), mit Zeile in `AGENTS.md` § File naming und
|
||||||
|
Zeiger aus `README.md`. `docs/version-model.md` hat einen neuen Abschnitt, warum eine Nummer erst
|
||||||
|
durch ein Release verbraucht wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3.3 - 2026-09-03 - Modellwahl nach Pruefbarkeit statt nach Aufgabenname; stack-dev bricht an den Phasenwechseln fuer den Model-Switch
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
`instructions/claude-code-model-selection.md` routete bisher nach Skill: eine Zeile "Stack
|
||||||
|
development -> Opus/high" fuer alles, was `tools/`, `types/` oder `instructions/` anfasst. Das ist
|
||||||
|
zu grob in beide Richtungen - es verteuert die lange, mechanische Mitte einer Stack-Sitzung, und es
|
||||||
|
sagt nichts darueber, dass Anfang und Ende derselben Sitzung anders zu behandeln sind.
|
||||||
|
|
||||||
|
**Die neue Achse ist "was faengt hier einen Fehler ab".** Wo ein Check in Code steht - `pytest`,
|
||||||
|
`docs verify`, `instructions verify`, CI, die Gates - kostet der Fehler eines schwaecheren Modells
|
||||||
|
eine Runde und faellt auf. Wo die einzige Durchsetzung eine Sitzung ist, die Prosa liest, faellt
|
||||||
|
derselbe Fehler gar nicht auf: er wird ausgeliefert und bleibt stehen. Das ist dasselbe Argument,
|
||||||
|
das `docs/why-gates-are-code.md` fuer Gates fuehrt, angewandt auf die Modellwahl.
|
||||||
|
|
||||||
|
Stack-Entwicklung ist damit **nicht mehr eine Zeile, sondern drei**:
|
||||||
|
|
||||||
|
| Phase | Was einen Fehler faengt | Modell |
|
||||||
|
|---|---|---|
|
||||||
|
| Design, Versionsstelle, Grenzuebertritts-Urteil | nichts | Opus/high |
|
||||||
|
| Code, Tests, mechanische Doku-Synchronisation | pytest, CI, `docs verify` | Sonnet/high |
|
||||||
|
| Issue-Abschluss, `docs/`-Veralterung, Changelog-Prosa | nichts, per Konstruktion | Opus/high |
|
||||||
|
|
||||||
|
Die Mitte ist die lange Phase und die mit den Checks - dort liegt die Ersparnis. Die beiden
|
||||||
|
Raender sind kurz (Minuten, nicht Stunden), haben aber keinen maschinellen Waechter: `wikitool`
|
||||||
|
kennt den Issue-Tracker bewusst nicht, und eine `docs/`-Seite traegt keinen normativen Satz, also
|
||||||
|
gibt es dort nichts zu verifizieren. Sie oben zu lassen ist billig und schuetzt genau die Arbeit,
|
||||||
|
die still scheitert.
|
||||||
|
|
||||||
|
Zwei Praezisierungen dazu: **Effort ist der billigere Hebel als das Modell** - `medium` steht fuer
|
||||||
|
Stack-Arbeit bewusst in keiner Zeile, weil Mehrdatei-Konsistenz das ist, was ein reduzierter
|
||||||
|
Effort zuerst aufgibt; `high` ist die Untergrenze, sobald mehr als eine Datei oder ein Contract
|
||||||
|
betroffen ist. Und die Asymmetrie ist benannt: eine unnoetige Opus-Phase kostet einmal Geld, eine
|
||||||
|
ungepruefte Sonnet-Phase kann etwas ausliefern, das nie wieder jemand ansieht.
|
||||||
|
|
||||||
|
**Damit die Tabelle ueberhaupt wirksam wird, braucht sie Haltepunkte.** Eine Sitzung kann ihr
|
||||||
|
eigenes Modell nicht wechseln - das ist `/model` und gehoert dem Nutzer. Eine Empfehlung, die
|
||||||
|
niemand zum richtigen Zeitpunkt ausspricht, aendert nichts. `instructions/dev/stack-dev/SKILL.md`
|
||||||
|
bekommt deshalb zwei ausdrueckliche Breaks:
|
||||||
|
|
||||||
|
- **Neuer Schritt 3** - "Settle the design before building", mit dem Angebot zum Wechsel nach
|
||||||
|
unten, sobald der Plan steht und die Arbeit mechanisch wird. Einmal aussprechen, dann so oder
|
||||||
|
so weiterarbeiten.
|
||||||
|
- **Schritt 6 (Abschluss) bricht in die Gegenrichtung** - ab dort greift wieder kein Check. Mit
|
||||||
|
der ausdruecklichen Auflage, die Arbeit **unabhaengig von der Antwort** zu tun: nach dem Publish
|
||||||
|
auf einen Modellwechsel zu blockieren wuerde genau den Zustand hinterlassen, den Schritt 6
|
||||||
|
verhindern soll. Lief die Phase auf dem billigeren Modell, gehoert das in die Uebergabe statt
|
||||||
|
ins Schweigen.
|
||||||
|
|
||||||
|
Ein auftauchender Grenzuebertritt ist unter den Decision points ebenfalls als Anlass zum Wechsel
|
||||||
|
nach oben benannt: `docs verify` prueft, dass ein Uebertritt sich dokumentiert, nie dass die
|
||||||
|
Stelle richtig gewaehlt war.
|
||||||
|
|
||||||
|
Die uebrigen Schritte sind unveraendert und nur umnummeriert (alt 3-5 -> neu 4-6).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
|
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Entwicklung dieses Stacks
|
||||||
|
|
||||||
|
Dieses Dokument richtet sich an Menschen, die an `tools/wikitool`, dem Type-Schema oder der
|
||||||
|
Instruction-/Skill-Schicht selbst arbeiten - nicht an den Konsumenten einer Instanz. Für die
|
||||||
|
Gegenseite (eine Instanz installieren, aktualisieren, betreiben) siehe [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
**Diese Datei wird nicht ausgeliefert.** Sie ist das menschliche Gegenstück zu
|
||||||
|
`instructions/dev/`, das `tools/wikitool dist export` vollständig ausschließt: eine
|
||||||
|
ausgelieferte Instanz hat keinen Release-Workflow, keine CI und kein Issue-Board, also braucht
|
||||||
|
sie auch keine Anleitung dafür. `dist_cmd.ROOT_FILES` listet sie deshalb bewusst nicht - der
|
||||||
|
Grund steht dort als Kommentar, damit eine spätere Sitzung die vermeintliche Lücke nicht
|
||||||
|
"repariert". Und weil sie nicht ausgeliefert wird, darf sie - anders als `README.md`,
|
||||||
|
`INSTALL.md` oder `EVALS.md`, die `instructions verify` auf genau diesen Punkt prüft - nach
|
||||||
|
`instructions/dev/` verlinken.
|
||||||
|
|
||||||
|
## Der Release-Ablauf
|
||||||
|
|
||||||
|
Zwischen zwei Releases führt der Stack **einen** laufenden Versionskandidaten statt einer neuen
|
||||||
|
Nummer pro Bump. Das volle Modell - Zustandsort, Eskalationslogik, warum eine Nummer erst durch
|
||||||
|
ein Release verbraucht wird - steht in
|
||||||
|
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) und
|
||||||
|
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||||
|
eine Sitzung ihn tatsächlich durchläuft:
|
||||||
|
|
||||||
|
1. **Bump eröffnet oder eskaliert den Kandidaten.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
|
||||||
|
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
|
||||||
|
denselben Eintrag, statt einen neuen zu eröffnen.
|
||||||
|
|
||||||
|
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
|
||||||
|
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
||||||
|
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
||||||
|
|
||||||
|
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools && .venv/bin/python -m pytest -q
|
||||||
|
tools/wikitool docs verify
|
||||||
|
tools/wikitool instructions verify
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **`version release` fixiert den Kandidaten**, sobald er ausgeliefert werden soll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool version release --title "Zusammenfassender Titel"
|
||||||
|
```
|
||||||
|
|
||||||
|
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
|
||||||
|
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
|
||||||
|
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
|
||||||
|
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
|
5. **Publish bewegt `VERSION` auf `main`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool publish --message "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Mass-Update-Gate und das Publish-Remote-Gate gelten wie bei jedem anderen Publish -
|
||||||
|
siehe [instructions/gates.md](instructions/gates.md).
|
||||||
|
|
||||||
|
6. **CI übernimmt den Rest.** `.gitea/workflows/release.yml` reagiert auf jeden Push, der
|
||||||
|
`VERSION` bewegt: Eine suffixbehaftete `VERSION` (ein Kandidat) lässt den Job sauber
|
||||||
|
überspringen, bevor er die Releases-API überhaupt anfragt - Betas werden nie veröffentlicht.
|
||||||
|
Eine suffixfreie `VERSION` baut die Distribution (`dist export`), erzeugt Tag und Release und
|
||||||
|
lädt Tarball plus Prüfsumme hoch. **CI setzt den Tag, nie eine Sitzung** - das hält
|
||||||
|
Invariante 5 intakt.
|
||||||
|
|
||||||
|
## Verify-Befehle im Überblick
|
||||||
|
|
||||||
|
| Befehl | Prüft |
|
||||||
|
|---|---|
|
||||||
|
| `cd tools && .venv/bin/python -m pytest -q` | Die gesamte Testsuite, hermetisch gegen eine leere Maschine (siehe `instructions/dev/testing-conventions.md`) |
|
||||||
|
| `tools/wikitool docs verify` | CLI-Kommandotabelle, Contract-Präsenz, Type-Drift, `.gitignore`-Kanarienvögel, `VERSION`/`CHANGES.md`-Übereinstimmung, Grenzübertritts-Dokumentation |
|
||||||
|
| `tools/wikitool instructions verify` | Jede Instruction und jeder Skill unter `instructions/`, verwaiste Dateien, `instructions/dev/`-Referenzen von außerhalb |
|
||||||
|
|
||||||
|
Die volle Kommandoreferenz inklusive Fehlerkontrakt: [tools/CONTRACT.md](tools/CONTRACT.md).
|
||||||
|
|
||||||
|
## Die CI-Hälfte
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` läuft auf jeden Push/PR gegen `main` (Content-Pfade ausgenommen) und
|
||||||
|
führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständigen
|
||||||
|
`setup-instance.md`-Replay gegen einen frischen `dist export` aus - derselbe Pfad, den ein neuer
|
||||||
|
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
||||||
|
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
||||||
|
|
||||||
|
## Stack-Entwicklung als eigener Sitzungstyp
|
||||||
|
|
||||||
|
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
||||||
|
Regeln für eine Sitzung, die den Stack selbst statt Wiki-Inhalt bearbeitet: wann
|
||||||
|
Quellenbindung nicht gilt, wo Design endet und die mechanische Phase beginnt (mit dem
|
||||||
|
Modellwechsel-Hinweis), und dass Issue-Abschluss ein Body-Rewrite ist, kein Kommentar. Siehe
|
||||||
|
[instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md) für den
|
||||||
|
Issue-Tracker selbst.
|
||||||
@@ -115,6 +115,7 @@ chemenu/
|
|||||||
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
├── DEVELOPMENT.md # Human-readable: the release workflow (version bump/release/publish/CI)
|
||||||
└── commonplace/ # Vendored, read-only knowledge base
|
└── commonplace/ # Vendored, read-only knowledge base
|
||||||
```
|
```
|
||||||
<!-- dist:strip-end -->
|
<!-- dist:strip-end -->
|
||||||
|
|||||||
@@ -76,6 +76,36 @@ version bump that shipped it was `--major --no-migration`: boundary-crossing and
|
|||||||
corpus, at the same time, which is precisely the combination the two-question split exists to
|
corpus, at the same time, which is precisely the combination the two-question split exists to
|
||||||
make visible.
|
make visible.
|
||||||
|
|
||||||
|
## Why a number is only spent by a release
|
||||||
|
|
||||||
|
Everything above is about what a version number *promises*. A separate question turned out to
|
||||||
|
matter just as much in practice: how many numbers get handed out along the way to making one
|
||||||
|
release. For a while the answer was "one per bump," and that turned out to be the wrong grain
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
The two things that actually consume a version number are a release and CI's version gate - and
|
||||||
|
they disagree about granularity. The gate wants `VERSION` to move on every push that touches
|
||||||
|
stack-shaped paths, which is a *commit*-level question: has this tree changed since the last
|
||||||
|
push. A release wants to know something else: has *this specific number* been published, ever.
|
||||||
|
Handing out a fresh number per bump answers the gate's question by accident and the release's
|
||||||
|
question wrongly - it treats every bump as if it were about to ship, when most of them are steps
|
||||||
|
toward a release that hasn't happened yet. Four bumps in one session, on the same day, for the
|
||||||
|
same eventual release, produced four numbers that a version-check feed would have reported as
|
||||||
|
four different available upgrades, three of which were never real.
|
||||||
|
|
||||||
|
The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still
|
||||||
|
gets that. It's to stop treating every movement as a new number. Between two releases the stack
|
||||||
|
now carries one running candidate, escalating through `-beta.N` as bumps accumulate, and only
|
||||||
|
`version release` spends the number for real by fixing it and closing its changelog entry. A
|
||||||
|
number is proposed by a bump and consumed by a release; conflating the two was the actual defect,
|
||||||
|
not the arithmetic of any single bump.
|
||||||
|
|
||||||
|
This is also why a candidate never gets to a distributed instance. The promise a released version
|
||||||
|
makes - "install this, and it is exactly what its number says" - has no equivalent for something
|
||||||
|
still being decided during a single dev checkout's session. `release.yml`'s only job with respect
|
||||||
|
to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but
|
||||||
|
because there is nothing yet to promise.
|
||||||
|
|
||||||
## Where the procedure lives
|
## Where the procedure lives
|
||||||
|
|
||||||
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||||
|
|||||||
@@ -6,10 +6,24 @@ description: Which Claude model and effort level to run a Claude Code session, a
|
|||||||
|
|
||||||
# Pick the Claude model and effort level for the task at hand
|
# Pick the Claude model and effort level for the task at hand
|
||||||
|
|
||||||
Scale the model and effort to how much judgment the task actually needs. Running everything at
|
Scale the model and effort to **what catches a mistake in this part of the work** - not to how
|
||||||
the most capable model and highest effort is safe but wasteful: the gates in [gates.md](gates.md)
|
important the task feels, and not to its name. Running everything at the most capable model and
|
||||||
are enforced in code, not by model judgment, so a weaker model cannot bypass them - it can only
|
highest effort is safe but wasteful: the gates in [gates.md](gates.md) are enforced in code, not
|
||||||
do a worse job of the calls the gates don't cover.
|
by model judgment, so a weaker model cannot bypass them - it can only do a worse job of the calls
|
||||||
|
the gates don't cover.
|
||||||
|
|
||||||
|
That last clause is the whole rule, turned into a test. Where a check lives in code - `pytest`,
|
||||||
|
`docs verify`, `instructions verify`, CI, the gates - a weaker model's mistake surfaces and costs
|
||||||
|
one more round. Where the only enforcement is a session reading prose, the same mistake does not
|
||||||
|
surface at all: it ships, and it stays until someone happens to notice. The two are not the same
|
||||||
|
risk, and they should not get the same model. This is the argument
|
||||||
|
[docs/why-gates-are-code.md](../docs/why-gates-are-code.md) makes about gates, applied to who is
|
||||||
|
holding the keyboard.
|
||||||
|
|
||||||
|
Both directions cost something, which is why the axis matters rather than a blanket answer:
|
||||||
|
over-provisioning is a standing cost paid every session, while under-provisioning in an unchecked
|
||||||
|
phase is a silent error with a long tail. A corrective session, its bump, its CI runs and its
|
||||||
|
release together cost more compute than the model difference they were saving.
|
||||||
|
|
||||||
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
||||||
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
||||||
@@ -28,17 +42,41 @@ to *make*, not a setting to apply.
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Recommend the session's model and effort by the skill in use**, when asked or when the
|
1. **Recommend the session's model and effort by what catches a mistake in the phase it is in**,
|
||||||
mismatch is worth one sentence. Say it once and continue working either way - a session that
|
when asked or when the mismatch is worth one sentence. Say it once and continue working either
|
||||||
argues about its own model instead of doing the task has already cost more than the model
|
way - a session that argues about its own model instead of doing the task has already cost
|
||||||
difference:
|
more than the model difference:
|
||||||
|
|
||||||
| Skill / task | Model | Effort |
|
| Phase / task | What catches a mistake here | Model | Effort |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `wiki-status`, simple `wiki-query` lookups | Sonnet | default |
|
| `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
|
||||||
| `wiki-lint` | Sonnet | default |
|
| `wiki-lint` | `lint` itself is the check | Sonnet | default |
|
||||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | Sonnet | high |
|
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly - the judgment about a claim is not covered | Sonnet | high |
|
||||||
| Stack development: `tools/`, `types/`, `instructions/` as code | Opus | high |
|
| Stack dev: design, the version part, a boundary-crossing judgment | nothing - `docs verify` checks that a crossing documents itself, never that the part was right | Opus | high |
|
||||||
|
| Stack dev: code, tests, mechanical doc sync (command tables, contract rows) | `pytest`, `docs verify`, `instructions verify`, CI | Sonnet | high |
|
||||||
|
| Stack dev: closing an issue, `docs/` staleness, changelog prose | nothing, by construction - see below | Opus | high |
|
||||||
|
|
||||||
|
**Stack development is not one row**, which is the point of splitting it. The middle phase is
|
||||||
|
where the tokens are and where the checks are, so it is the phase worth running cheaper. The
|
||||||
|
two around it have no mechanical guard at all - a `docs/` page carries no normative sentence,
|
||||||
|
so there is nothing for `docs verify` to check ([AGENTS.md](../AGENTS.md) § File naming), and
|
||||||
|
the same holds for whatever tracker an instance keeps its open work in, which `wikitool`
|
||||||
|
deliberately knows nothing about. Those two phases are short - minutes, not hours - so keeping
|
||||||
|
them on the stronger model is cheap, and it protects the only work in the session that fails
|
||||||
|
silently.
|
||||||
|
|
||||||
|
**Effort is the cheaper lever than the model.** Reach for it first: `medium` deliberately does
|
||||||
|
not appear in this table for stack work, because multi-file consistency is exactly what a
|
||||||
|
reduced effort level gives up. Sonnet at `high` is the floor for anything touching more than
|
||||||
|
one file or a contract; `default` is for a single-file mechanical edit with a test behind it.
|
||||||
|
|
||||||
|
**A session cannot switch its own model**, so these rows only become real if someone offers the
|
||||||
|
switch at the moment the phase changes - once, without arguing about it, and never as a reason
|
||||||
|
to stop work that is already underway.
|
||||||
|
<!-- dist:strip-start -->
|
||||||
|
In this repo those moments are named: the `stack-dev` skill breaks for them at its steps 3
|
||||||
|
(design settled, work turns mechanical) and 6 (publish done, the unchecked tail begins).
|
||||||
|
<!-- dist:strip-end -->
|
||||||
|
|
||||||
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
||||||
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
||||||
@@ -66,8 +104,14 @@ to *make*, not a setting to apply.
|
|||||||
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
||||||
supervising it.
|
supervising it.
|
||||||
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
||||||
the highest effort. Under-provisioning costs one worse answer in one session; reflexively
|
the highest effort. Under-provisioning *where a check exists* costs one worse answer in one
|
||||||
over-provisioning is a standing cost paid every session.
|
session; reflexively over-provisioning is a standing cost paid every session.
|
||||||
|
- **Unsure whether the phase is checked?** Treat it as unchecked. The asymmetry is not symmetric:
|
||||||
|
a needless Opus phase costs money once, an unchecked Sonnet phase can ship something nobody
|
||||||
|
looks at again.
|
||||||
|
- **Mid-session and the phase changed, but nobody switched?** Do the work anyway - never block a
|
||||||
|
publish or an issue close on a model the session cannot change itself. Say which phase ran on
|
||||||
|
which model in the handover, so the gap is visible rather than silent.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||||
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||||
to its final state before closing. Both halves bind; the second is step 5 below. Read it
|
to its final state before closing. Both halves bind; the second is step 6 below. Read it
|
||||||
before filing something for later, before editing or closing an issue, or before deciding
|
before filing something for later, before editing or closing an issue, or before deciding
|
||||||
what to pick up next.
|
what to pick up next.
|
||||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
||||||
@@ -54,14 +54,35 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
establish itself. Read it before adding or changing a test.
|
establish itself. Read it before adding or changing a test.
|
||||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
||||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||||
put in front of the user before a breaking bump. Read it before step 3.
|
put in front of the user before a breaking bump. Read it before step 4.
|
||||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||||
before any fix that would touch `kb/` content.
|
before any fix that would touch `kb/` content.
|
||||||
More instructions are added here incrementally as stack-development needs come up - this
|
More instructions are added here incrementally as stack-development needs come up - this
|
||||||
list grows without needing this skill file to change shape.
|
list grows without needing this skill file to change shape.
|
||||||
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
3. **Settle the design before building - and break there for the model switch.** These are two
|
||||||
|
different kinds of work, and the split is not stylistic: design, the version part and any
|
||||||
|
boundary judgment have **no** mechanical guard, while the code and tests that follow have
|
||||||
|
`pytest`, `docs verify`, `instructions verify` and CI behind them.
|
||||||
|
|
||||||
|
So when the design is settled - the issue body says what will be built, the open questions are
|
||||||
|
answered - stop and say so, in one sentence:
|
||||||
|
|
||||||
|
> Der Plan steht, ab hier ist die Arbeit mechanisch und durch Tests/CI abgedeckt. Wenn du auf
|
||||||
|
> Opus bist, ist jetzt der Moment für `/model sonnet` bei Effort `high`.
|
||||||
|
|
||||||
|
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
||||||
|
setting an agent applies. Offer it once and keep working either way; a session that argues
|
||||||
|
about its own model has already cost more than the difference. If the design turns out not to
|
||||||
|
be settled after all - a boundary crossing surfaces, an assumption breaks - that is a reason to
|
||||||
|
offer the switch back up, not to decide it alone.
|
||||||
|
|
||||||
|
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||||
|
than one file or a contract. Full table and reasoning:
|
||||||
|
[claude-code-model-selection.md](../../claude-code-model-selection.md).
|
||||||
|
|
||||||
|
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||||
version and a changelog entry:
|
version and a changelog entry:
|
||||||
|
|
||||||
@@ -98,18 +119,30 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
||||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||||
|
|
||||||
4. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
5. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
||||||
verify`, and the relevant `pytest` run in `tools/` - the same checks any stack change must
|
verify`, and the relevant `pytest` run in `tools/` - the same checks any stack change must
|
||||||
pass, run explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a
|
pass, run explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a
|
||||||
full `setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves
|
full `setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves
|
||||||
`VERSION` additionally triggers a tagged release. **CI does the tagging** - a session never
|
`VERSION` additionally triggers a tagged release. **CI does the tagging** - a session never
|
||||||
creates a tag, which is what keeps AGENTS.md invariant 5 intact.
|
creates a tag, which is what keeps AGENTS.md invariant 5 intact.
|
||||||
|
|
||||||
5. **Close the issue with a body rewrite, not a comment.** The last act of a session that
|
6. **Close the issue with a body rewrite, not a comment.** The last act of a session that
|
||||||
finished a work package, and the one most easily skipped: by here the change is published and
|
finished a work package, and the one most easily skipped: by here the change is published and
|
||||||
the issue feels done. It is not. The body is the version everyone reads afterwards and nobody
|
the issue feels done. It is not. The body is the version everyone reads afterwards and nobody
|
||||||
revisits, so it is the one place the debt comes due at the worst moment.
|
revisits, so it is the one place the debt comes due at the worst moment.
|
||||||
|
|
||||||
|
**Break here too, in the other direction.** Everything left in the session - this rewrite,
|
||||||
|
whether a `docs/` page's reasoning went stale, the changelog prose - is the unchecked kind of
|
||||||
|
work again, the mirror of step 3. If the session dropped to Sonnet there, say so now:
|
||||||
|
|
||||||
|
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
|
||||||
|
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
|
||||||
|
|
||||||
|
Then **do the work regardless of the answer.** Never block a close on a model switch: the
|
||||||
|
change is already published, and a session that stops here leaves exactly the state this step
|
||||||
|
exists to prevent. If it ran on the cheaper model, name that in the handover rather than
|
||||||
|
leaving it silent.
|
||||||
|
|
||||||
Rewrite it to its final state *first*, then close. The test is what a reader who opens the
|
Rewrite it to its final state *first*, then close. The test is what a reader who opens the
|
||||||
closed issue tomorrow would conclude:
|
closed issue tomorrow would conclude:
|
||||||
|
|
||||||
@@ -138,7 +171,10 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||||
[version-parts.md](../version-parts.md) step 4 has the full shape.
|
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
||||||
|
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||||
|
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||||
|
part was chosen correctly.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,36 @@ Two questions decide a version bump, and they are **not the same question**:
|
|||||||
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
||||||
already (see the case study at the end), which is why this file exists.
|
already (see the case study at the end), which is why this file exists.
|
||||||
|
|
||||||
|
## The candidate model
|
||||||
|
|
||||||
|
Between two releases the stack carries **one running candidate**, not a fresh version per
|
||||||
|
`bump`. Five bumps with no release in between used to mean five numbers, four of which nothing
|
||||||
|
ever consumed - the release-granularity CI's version gate wants (`VERSION` must move on every
|
||||||
|
stack-touching push) was being paid at bump granularity instead. A candidate closes that gap
|
||||||
|
without touching the gate: `VERSION` still moves on every bump, it just escalates the *same*
|
||||||
|
number instead of handing out a new one.
|
||||||
|
|
||||||
|
- **State lives in `VERSION` itself**, as an optional `-beta.N` suffix (`4.4.0-beta.3`). No
|
||||||
|
second state file: the last release is read back out of `CHANGES.md` (the newest entry with no
|
||||||
|
suffix), and the escalation stage is the difference between the candidate's base and that
|
||||||
|
release - derived, not stored.
|
||||||
|
- **`--major`/`--minor`/`--patch` is max-wins escalation**, not a step you can undo. A `--patch`
|
||||||
|
bump on a candidate already at MINOR only advances its bump count (`N`); nothing ever steps a
|
||||||
|
candidate back down. Declaring the part is still your judgment call, made the same way section
|
||||||
|
below describes - `escalate()` only ever raises it further.
|
||||||
|
- **A candidate is never released.** Pre-release is a dev-checkout state; `release.yml` only acts
|
||||||
|
on a suffix-free `VERSION`, so a distributed instance never sees a `-beta.` version at all, and
|
||||||
|
its parser never has to know the suffix exists.
|
||||||
|
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
|
||||||
|
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
|
||||||
|
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
|
||||||
|
heading, date and the bumps list all move, but the entry's own prose (written below the
|
||||||
|
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
||||||
|
release, whatever a candidate's history of bumps looked like.
|
||||||
|
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
|
||||||
|
strips the suffix and turns the entry into a real, closed release - see its own row in
|
||||||
|
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
|
||||||
|
|
||||||
## When to run
|
## When to run
|
||||||
|
|
||||||
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
||||||
@@ -63,9 +93,11 @@ the three-line test below is usually enough.
|
|||||||
| Fix, no interface change | `--patch` |
|
| Fix, no interface change | `--patch` |
|
||||||
| New capability, drop-in in both directions | `--minor` |
|
| New capability, drop-in in both directions | `--minor` |
|
||||||
|
|
||||||
4. **Stop and talk to the user before a boundary-crossing bump.** It is expensive in a way the
|
4. **Stop and talk to the user before the bump that first escalates a candidate past the
|
||||||
other two parts are not: every existing instance pays for it, once, by hand. Put in front of
|
boundary.** It is expensive in a way the other two parts are not: every existing instance pays
|
||||||
them, in this order:
|
for it, once, by hand. That escalation happens exactly once per candidate - a later bump that
|
||||||
|
keeps the candidate at the same stage (another `--major` on one already there, say) does not
|
||||||
|
re-cross anything and needs no second conversation. Put in front of the user, in this order:
|
||||||
|
|
||||||
- **What breaks**, concretely - which file, which name, which call site.
|
- **What breaks**, concretely - which file, which name, which call site.
|
||||||
- **What each existing instance must do**, as the steps they would actually run.
|
- **What each existing instance must do**, as the steps they would actually run.
|
||||||
@@ -81,8 +113,9 @@ 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 bump itself.** A boundary-crossing bump requires
|
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
|
||||||
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
|
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 \
|
||||||
@@ -91,22 +124,33 @@ the three-line test below is usually enough.
|
|||||||
--no-migration "<why no page has to change>" # only if that is true
|
--no-migration "<why no page has to change>" # only if that is true
|
||||||
```
|
```
|
||||||
|
|
||||||
`--breaking` is refused on a bump that crosses nothing, and required on one that does;
|
The line, once written, stays in the entry across every later bump of the same candidate -
|
||||||
`docs verify` checks the newest boundary-crossing entry still carries the line. Write it for
|
a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat
|
||||||
the operator of an instance that has not read this repository: what stops working, and what
|
it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required
|
||||||
they do about it.
|
on the one that does. `docs verify` checks the newest boundary-crossing entry still carries
|
||||||
|
the line. Write it for the operator of an instance that has not read this repository: what
|
||||||
|
stops working, and what they do about it.
|
||||||
|
|
||||||
6. **Then answer the migration question separately.** Boundary-crossing and
|
6. **Then answer the migration question separately.** Boundary-crossing and
|
||||||
content-migrating are independent:
|
content-migrating are independent:
|
||||||
|
|
||||||
- Content must change → write the migration document under `instructions/migrations/` per
|
- Content must change → write the migration document under `instructions/migrations/` per
|
||||||
[migrate-corpus.md](../migrate-corpus.md). `bump` finds it by its `migrates_to:` field.
|
[migrate-corpus.md](../migrate-corpus.md). The escalation bump finds it by the document's
|
||||||
|
`migrates_to:` field, matched against the candidate's **base** - a document targets the
|
||||||
|
release the candidate will become, never a `-beta.N` form of it.
|
||||||
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
||||||
|
|
||||||
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
||||||
migrate, with nothing telling it how, is a dead end.
|
migrate, with nothing telling it how, is a dead end. Like `--breaking`, both persist across
|
||||||
|
later bumps of the same candidate without being repeated.
|
||||||
|
|
||||||
7. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
|
||||||
|
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
||||||
|
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
||||||
|
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
||||||
|
way; without one, the heading simply keeps whichever bump last set it.
|
||||||
|
|
||||||
|
8. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
||||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
cannot reconstruct from the diff, and it is what the next session in this position will read
|
||||||
instead of guessing.
|
instead of guessing.
|
||||||
|
|||||||
+4
-2
@@ -75,7 +75,8 @@ 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>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) 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. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. 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 "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--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. 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` |
|
||||||
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
||||||
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
||||||
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
|
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
|
||||||
@@ -191,7 +192,8 @@ is atomic, and whether a retry is safe.
|
|||||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||||
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to 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 bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps 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`, 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`, or `--breaking`/`--no-migration` on a bump that crosses nothing | 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 |
|
||||||
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
||||||
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
||||||
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ DIST_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "dist_templates"
|
|||||||
# read server is part of what an instance *has*, even though its dependency is
|
# read server is part of what an instance *has*, even though its dependency is
|
||||||
# optional. A distribution whose server is present but undocumented is one
|
# optional. A distribution whose server is present but undocumented is one
|
||||||
# whose operator finds the module by reading the source.
|
# whose operator finds the module by reading the source.
|
||||||
|
#
|
||||||
|
# `DEVELOPMENT.md` is deliberately **absent** from this tuple, unlike every
|
||||||
|
# other root doc above. It documents the release workflow (`version bump` ->
|
||||||
|
# `version release` -> `publish` -> CI tags) and points at `instructions/dev/`,
|
||||||
|
# which this same function excludes wholesale a few lines down - a distributed
|
||||||
|
# instance has no release workflow, no CI and no issue board, so it has
|
||||||
|
# nothing for that document to describe. Do not "fix" this by adding it back:
|
||||||
|
# a root file absent from ROOT_FILES is silently skipped by every export, and
|
||||||
|
# that silence is the correct behaviour here, not a gap.
|
||||||
ROOT_FILES = (
|
ROOT_FILES = (
|
||||||
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
||||||
".gitignore", "VERSION",
|
".gitignore", "VERSION",
|
||||||
@@ -400,8 +409,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
|||||||
# machinery expects - which is exactly what makes the initial declaration
|
# machinery expects - which is exactly what makes the initial declaration
|
||||||
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
||||||
# instance predating this file has to answer that question by hand.
|
# instance predating this file has to answer that question by hand.
|
||||||
|
#
|
||||||
|
# `.base`, not the raw `VERSION`: a content shape has no beta channel
|
||||||
|
# (`kb_state.read_kb_version` refuses one), so exporting mid-candidate
|
||||||
|
# still declares the release the content is shaped for, not the candidate
|
||||||
|
# in progress. The stamp below carries the honest, suffix-inclusive value -
|
||||||
|
# the two files answer different questions.
|
||||||
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
||||||
kb_state.render_kb_state(version_mod.read_version(), [])
|
kb_state.render_kb_state(version_mod.read_version().base, [])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Last, so it can digest everything above it. It is the one file in the
|
# Last, so it can digest everything above it. It is the one file in the
|
||||||
|
|||||||
@@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]:
|
|||||||
|
|
||||||
This is the check that makes `version bump` more than a convenience: a
|
This is the check that makes `version bump` more than a convenience: a
|
||||||
version raised with nothing written about it would ship a release whose
|
version raised with nothing written about it would ship a release whose
|
||||||
notes describe the previous one. A changelog with *no* versioned entry at
|
notes describe the previous one. `VERSION` may name a running candidate
|
||||||
all is fine - that is a fresh distribution, and this repo's own pre-
|
(`-beta.N`) rather than a release - `Version.parse`/equality read the
|
||||||
versioning history, neither of which claims to describe the current
|
suffix like any other component, so a candidate is compared exactly like a
|
||||||
version.
|
release here. A changelog with *no* versioned entry at all is fine - that
|
||||||
|
is a fresh distribution, and this repo's own pre-versioning history,
|
||||||
|
neither of which claims to describe the current version.
|
||||||
"""
|
"""
|
||||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||||
if not version_path.is_file():
|
if not version_path.is_file():
|
||||||
@@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
|
|
||||||
"""The version named by the second-newest versioned entry, or None."""
|
|
||||||
seen = [
|
|
||||||
version_mod.Version.parse(match.group(1))
|
|
||||||
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
|
|
||||||
]
|
|
||||||
return seen[1] if len(seen) > 1 else None
|
|
||||||
|
|
||||||
|
|
||||||
def check_migration_for_boundary() -> list[str]:
|
def check_migration_for_boundary() -> list[str]:
|
||||||
"""A version that crosses the compatibility boundary must say how to cross it.
|
"""A version that crosses the compatibility boundary must say how to cross it.
|
||||||
|
|
||||||
@@ -501,9 +494,12 @@ def check_migration_for_boundary() -> list[str]:
|
|||||||
document targeting it, or an explicit statement in its changelog entry that
|
document targeting it, or an explicit statement in its changelog entry that
|
||||||
no content has to change.
|
no content has to change.
|
||||||
|
|
||||||
Only the newest entry is checked. Older boundaries were either satisfied
|
Only the newest entry is checked, against the **last release** rather than
|
||||||
when they were written or cannot be fixed retroactively, and re-reporting
|
the entry beneath it - between two candidates of the same running upgrade
|
||||||
them forever would make the check noise.
|
(`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and
|
||||||
|
comparing to the entry beneath would find none even when the candidate
|
||||||
|
genuinely crosses one relative to what is actually installed anywhere. See
|
||||||
|
instructions/dev/version-parts.md.
|
||||||
"""
|
"""
|
||||||
from chemenu import kb_state
|
from chemenu import kb_state
|
||||||
|
|
||||||
@@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
text = changes_path.read_text(encoding="utf-8")
|
text = changes_path.read_text(encoding="utf-8")
|
||||||
current = version_mod.top_changes_version(text)
|
current = version_mod.top_changes_version(text)
|
||||||
previous = _second_changes_version(text)
|
previous = version_mod.last_release(text)
|
||||||
if current is None or previous is None:
|
if current is None or previous is None:
|
||||||
return [] # the first versioned entry has no predecessor to cross from
|
return [] # no release recorded yet to cross from (fresh distribution)
|
||||||
if current.compat_key == previous.compat_key:
|
if current.compat_key == previous.compat_key:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
||||||
return []
|
return []
|
||||||
if any(m.target == current for m in kb_state.load_migrations()):
|
if any(m.target == current.base for m in kb_state.load_migrations()):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -544,8 +540,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
import name or flag - satisfies that check and still leaves every existing
|
import name or flag - satisfies that check and still leaves every existing
|
||||||
instance with something to do by hand.
|
instance with something to do by hand.
|
||||||
|
|
||||||
Only the newest entry is checked, for the same reason: older crossings are
|
Only the newest entry is checked, against the **last release** - see
|
||||||
history, and re-reporting them forever would make the check noise.
|
`check_migration_for_boundary` for why the entry beneath it is the wrong
|
||||||
|
comparison once a candidate can span more than one bump.
|
||||||
"""
|
"""
|
||||||
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
||||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||||
@@ -554,9 +551,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
text = changes_path.read_text(encoding="utf-8")
|
text = changes_path.read_text(encoding="utf-8")
|
||||||
current = version_mod.top_changes_version(text)
|
current = version_mod.top_changes_version(text)
|
||||||
previous = _second_changes_version(text)
|
previous = version_mod.last_release(text)
|
||||||
if current is None or previous is None:
|
if current is None or previous is None:
|
||||||
return [] # the first versioned entry has no predecessor to cross from
|
return [] # no release recorded yet to cross from (fresh distribution)
|
||||||
if current.compat_key == previous.compat_key:
|
if current.compat_key == previous.compat_key:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,8 @@ def check_stack_version() -> Check:
|
|||||||
)
|
)
|
||||||
|
|
||||||
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
||||||
return Check("stack-version", "OK", f"{current} ({origin})")
|
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
|
||||||
|
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
|
||||||
|
|
||||||
|
|
||||||
def check_kb_version() -> Check:
|
def check_kb_version() -> Check:
|
||||||
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
|
|||||||
"never lagged behind its machinery",
|
"never lagged behind its machinery",
|
||||||
)
|
)
|
||||||
if kb_version < stack:
|
if kb_version < stack:
|
||||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack)
|
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
|
||||||
if pending:
|
if pending:
|
||||||
return Check(
|
return Check(
|
||||||
"kb-version", "WARN",
|
"kb-version", "WARN",
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ def status_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
pending = kb_state.chain(migrations, kb_version, stack)
|
pending = kb_state.chain(migrations, kb_version, stack.base)
|
||||||
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
||||||
divergent = kb_state.divergent_files()
|
divergent = kb_state.divergent_files()
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ def done_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
expected = kb_state.next_link(migrations, kb_version, stack)
|
expected = kb_state.next_link(migrations, kb_version, stack.base)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
fail(
|
fail(
|
||||||
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
||||||
@@ -297,7 +297,7 @@ def done_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
kb_state.write_kb_state(target, applied)
|
kb_state.write_kb_state(target, applied)
|
||||||
remaining = kb_state.chain(migrations, target, stack)
|
remaining = kb_state.chain(migrations, target, stack.base)
|
||||||
success(
|
success(
|
||||||
f"Content is now {target} ({expected.name}). "
|
f"Content is now {target} ({expected.name}). "
|
||||||
+ (
|
+ (
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""`wikitool version` - report, bump, and check the stack's version.
|
"""`wikitool version` - report, bump, release, and check the stack's version.
|
||||||
|
|
||||||
Three jobs that all hang off one number (see `chemenu/version.py` for what
|
Four jobs that all hang off one number (see `chemenu/version.py` for what that
|
||||||
that number means):
|
number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||||
|
|
||||||
- `version show` answers "which stack is this instance running", offline, from
|
- `version show` answers "which stack is this instance running", offline, from
|
||||||
`VERSION` plus the release stamp `dist export` writes.
|
`VERSION` plus the release stamp `dist export` writes.
|
||||||
- `version bump` moves it, and writes the changelog *heading* that has to
|
- `version bump` raises or continues the one running candidate between two
|
||||||
accompany the move - the same structure-by-tool/prose-by-author split as
|
releases, and writes the changelog *heading* that has to accompany it - the
|
||||||
`new`. `docs verify` then holds the two together.
|
same structure-by-tool/prose-by-author split as `new`. `docs verify` then
|
||||||
|
holds the two together.
|
||||||
|
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
||||||
|
closes its changelog entry. It is the only thing that turns a candidate into
|
||||||
|
a number a release actually consumes.
|
||||||
- `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
|
||||||
@@ -188,34 +192,36 @@ def bump_command(
|
|||||||
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
||||||
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
||||||
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
||||||
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
|
title: str = typer.Option(..., "--title", help="One-line title for the new/updated CHANGES.md entry"),
|
||||||
breaking: Optional[str] = typer.Option(
|
breaking: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--breaking",
|
"--breaking",
|
||||||
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
|
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
|
||||||
),
|
),
|
||||||
no_migration: Optional[str] = typer.Option(
|
no_migration: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--no-migration",
|
"--no-migration",
|
||||||
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
|
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
|
||||||
),
|
),
|
||||||
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 the stack version and open its `CHANGES.md` entry.
|
"""Raise or continue the running candidate, and open or update its
|
||||||
|
`CHANGES.md` entry.
|
||||||
|
|
||||||
Writes `VERSION` and inserts the entry's heading, date and author - the
|
Between two releases the stack carries **one** candidate, not a fresh
|
||||||
entry's body stays the author's to write, the same way `new` produces
|
number per bump: `--patch/--minor/--major` is max-wins escalation against
|
||||||
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
the last release, never a step back down, and the candidate's bump count
|
||||||
the two agree, so a bump with no entry cannot reach a release.
|
(`-beta.N`) advances either way. See
|
||||||
|
`instructions/dev/version-parts.md` for the full model, and
|
||||||
|
`version release` for what fixes a candidate into a release.
|
||||||
|
|
||||||
A bump that crosses the compatibility boundary - one whose new version is
|
A bump whose escalation first crosses the compatibility boundary - the new
|
||||||
not a drop-in replacement, whether or not any content moves - requires
|
version is not a drop-in replacement, whether or not any content moves -
|
||||||
`--breaking "<what stops working>"`, and on top of that either a migration
|
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||||
document for the new version or `--no-migration "<reason>"`. An instance
|
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||||
learning that it must migrate, with nothing telling it what broke or how to
|
lines are written into the entry once and then persist across every later
|
||||||
cross, is the gap these close. Which part to pass stays a judgment call
|
bump at the same stage: a follow-up bump need not repeat them, and passing
|
||||||
this command does not make - it enforces only that a crossing says what it
|
either on a bump that crosses nothing at all is refused."""
|
||||||
costs."""
|
|
||||||
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
||||||
if len(selected) != 1:
|
if len(selected) != 1:
|
||||||
fail("Pass exactly one of --major / --minor / --patch")
|
fail("Pass exactly one of --major / --minor / --patch")
|
||||||
@@ -226,7 +232,6 @@ def bump_command(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
new_version = current.bumped(selected[0])
|
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
fail(str(exc))
|
fail(str(exc))
|
||||||
return
|
return
|
||||||
@@ -236,19 +241,29 @@ def bump_command(
|
|||||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
||||||
return
|
return
|
||||||
text = changes.read_text(encoding="utf-8")
|
text = changes.read_text(encoding="utf-8")
|
||||||
existing = version_mod.top_changes_version(text)
|
|
||||||
if existing is not None and existing >= new_version:
|
top_entry = version_mod.top_changes_version(text)
|
||||||
|
if top_entry is not None and top_entry != current:
|
||||||
fail(
|
fail(
|
||||||
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
|
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||||
f"than {new_version} - bump past it, or fix the changelog"
|
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
|
||||||
|
"Fix whichever is wrong."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
last_release = version_mod.last_release(text)
|
||||||
|
new_version = version_mod.escalate(last_release, current, selected[0])
|
||||||
|
|
||||||
author = config.default_author() or "unknown"
|
author = config.default_author() or "unknown"
|
||||||
crossing = new_version.compat_key != current.compat_key
|
crossing = last_release is not None and new_version.compat_key != last_release.compat_key
|
||||||
|
was_already_crossing = (
|
||||||
|
last_release is not None
|
||||||
|
and current.is_prerelease
|
||||||
|
and current.compat_key != last_release.compat_key
|
||||||
|
)
|
||||||
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
||||||
|
|
||||||
if crossing and not breaking:
|
if crossing and not was_already_crossing and not breaking:
|
||||||
fail(
|
fail(
|
||||||
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
||||||
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
||||||
@@ -265,14 +280,14 @@ def bump_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if crossing and not no_migration:
|
if crossing and not was_already_crossing and not no_migration:
|
||||||
from chemenu import kb_state
|
from chemenu import kb_state
|
||||||
|
|
||||||
if not any(m.target == new_version for m in kb_state.load_migrations()):
|
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
|
||||||
fail(
|
fail(
|
||||||
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
||||||
f"instance must migrate - but no migration document targets {new_version}.\n"
|
f"instance must migrate - but no migration document targets {new_version.base}.\n"
|
||||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
|
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
|
||||||
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
||||||
f're-run with --no-migration "<reason>".'
|
f're-run with --no-migration "<reason>".'
|
||||||
)
|
)
|
||||||
@@ -298,6 +313,76 @@ def bump_command(
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
success(
|
success(
|
||||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
|
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||||
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
|
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
|
||||||
|
f"`version release` once the candidate is ready to ship."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("release")
|
||||||
|
def release_command(
|
||||||
|
title: Optional[str] = typer.Option(
|
||||||
|
None, "--title", help="Replace the entry's heading title (default: the last bump's)"
|
||||||
|
),
|
||||||
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||||
|
):
|
||||||
|
"""Fix the running candidate: strip its `-beta.N` suffix and close its
|
||||||
|
`CHANGES.md` entry.
|
||||||
|
|
||||||
|
Ends the pre-release phase this checkout has been in since its last
|
||||||
|
`version bump` - the candidate's base becomes the release. Without
|
||||||
|
`--title` the heading keeps whichever bump last set it; with it, the
|
||||||
|
heading gets a summarising title instead, which is the normal case for a
|
||||||
|
candidate that collected several bump titles along the way. The
|
||||||
|
machine-managed list of those titles is left in the entry as the record of
|
||||||
|
what happened, not replaced.
|
||||||
|
|
||||||
|
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||||
|
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||||
|
Refuses when `VERSION` is already a release: there is no running candidate
|
||||||
|
to fix."""
|
||||||
|
try:
|
||||||
|
current = version_mod.read_version()
|
||||||
|
except VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current.is_prerelease:
|
||||||
|
fail(
|
||||||
|
f"{version_mod.VERSION_FILENAME} is already {current}, a release - there is no running "
|
||||||
|
"candidate to fix. `version release` only ends a pre-release phase that `version bump` "
|
||||||
|
"started."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
changes = version_mod.changes_file()
|
||||||
|
if not changes.is_file():
|
||||||
|
fail(f"{version_mod.CHANGES_FILENAME} is missing - the candidate has nowhere to be fixed")
|
||||||
|
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 release. "
|
||||||
|
"Fix whichever is wrong."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_version = current.base
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
||||||
|
return
|
||||||
|
|
||||||
|
version_mod.write_version(new_version)
|
||||||
|
changes.write_text(
|
||||||
|
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
success(
|
||||||
|
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
|
||||||
|
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||||
|
"is what release.yml reacts to."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
|
|||||||
None is a real state, not an error: an instance created before the KB
|
None is a real state, not an error: an instance created before the KB
|
||||||
version existed has content of unknown vintage, and guessing would be
|
version existed has content of unknown vintage, and guessing would be
|
||||||
worse than asking (`migrate baseline`).
|
worse than asking (`migrate baseline`).
|
||||||
|
|
||||||
|
Refuses a pre-release (`-beta.N`): a content *shape* has no beta channel,
|
||||||
|
only the machinery does, so a `kb_version` naming one means something
|
||||||
|
wrote a stack version into this field by hand or by mistake.
|
||||||
"""
|
"""
|
||||||
state = read_kb_state()
|
state = read_kb_state()
|
||||||
if state is None:
|
if state is None:
|
||||||
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
|
|||||||
raw = state.get("kb_version")
|
raw = state.get("kb_version")
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
return Version.parse(str(raw))
|
version = Version.parse(str(raw))
|
||||||
|
if version.is_prerelease:
|
||||||
|
raise VersionError(
|
||||||
|
f"{KB_STATE_FILENAME} names a pre-release kb_version ({version}) - content has no "
|
||||||
|
"beta channel, only the stack version does"
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
def read_kb_state() -> Optional[dict]:
|
def read_kb_state() -> Optional[dict]:
|
||||||
@@ -171,6 +181,13 @@ def chain(
|
|||||||
Targets above the installed machinery are excluded: the instance has no code
|
Targets above the installed machinery are excluded: the instance has no code
|
||||||
for them yet.
|
for them yet.
|
||||||
|
|
||||||
|
`stack_version` must be release-shaped (no `-beta.N`) - pass `.base` when
|
||||||
|
the installed machinery is a running candidate. A migration document
|
||||||
|
targets a release (`migrates_to: 4.4.0`), and a candidate's own version
|
||||||
|
sorts *before* that release (`4.4.0-beta.1 < 4.4.0`), so comparing against
|
||||||
|
the raw candidate would drop its own target out of the interval right
|
||||||
|
when the machinery that owes it is installed.
|
||||||
|
|
||||||
`offered` migrations are deliberately absent. They are not links in the
|
`offered` migrations are deliberately absent. They are not links in the
|
||||||
version chain: declining one leaves the content in a shape the machinery
|
version chain: declining one leaves the content in a shape the machinery
|
||||||
still accepts, so counting it as owed would make `kb_version` unreachable
|
still accepts, so counting it as owed would make `kb_version` unreachable
|
||||||
|
|||||||
@@ -430,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo):
|
|||||||
assert state["applied"] == []
|
assert state["applied"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
|
||||||
|
"""Exporting mid-candidate answers two different questions: the stamp says
|
||||||
|
what was actually exported (suffix included - "an export says what it
|
||||||
|
is"), the KB version says what shape the content is built for. A content
|
||||||
|
shape has no beta channel, so it must be the base."""
|
||||||
|
from chemenu import kb_state
|
||||||
|
|
||||||
|
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
|
||||||
|
plan = dist_cmd.build_plan()
|
||||||
|
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
|
||||||
|
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
|
||||||
|
assert stamp["version"] == "0.4.0-beta.2"
|
||||||
|
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
|
||||||
|
assert state["kb_version"] == "0.4.0"
|
||||||
|
|
||||||
|
|
||||||
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
||||||
(repo / "VERSION").unlink()
|
(repo / "VERSION").unlink()
|
||||||
target = tmp_path / "dist"
|
target = tmp_path / "dist"
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for():
|
|||||||
|
|
||||||
|
|
||||||
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
||||||
|
"""A changelog with `current` as the topmost entry and `previous` as the
|
||||||
|
last release beneath it. `current` is normally an open candidate
|
||||||
|
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
|
||||||
|
release** (`version_mod.last_release`), which skips right past a topmost
|
||||||
|
entry that is itself already a release (that one's crossing, if any, was
|
||||||
|
already checked while it was still the open candidate)."""
|
||||||
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
||||||
(tmp_path / "CHANGES.md").write_text(
|
(tmp_path / "CHANGES.md").write_text(
|
||||||
"# Changelog\n\n---\n\n"
|
"# Changelog\n\n---\n\n"
|
||||||
@@ -207,28 +213,41 @@ def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: s
|
|||||||
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
||||||
"""`version check` tells an instance it must migrate; without this, that is
|
"""`version check` tells an instance it must migrate; without this, that is
|
||||||
where the trail ends."""
|
where the trail ends."""
|
||||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||||
issues = docs_verify.check_migration_for_boundary()
|
issues = docs_verify.check_migration_for_boundary()
|
||||||
assert any("2.0.0" in issue and "must migrate" in issue for issue in issues)
|
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
||||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
|
||||||
|
"""Regression for finding #2: comparing against the entry *beneath* the
|
||||||
|
newest one (rather than the last release) would find no boundary between
|
||||||
|
two betas of the same candidate - and would also, wrongly, re-flag an
|
||||||
|
already-fixed release forever. Once `current` is itself a release,
|
||||||
|
`last_release` returns it directly, so there is nothing left to compare."""
|
||||||
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||||
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
||||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
"""The document targets the candidate's *base* (`2.0.0`), not its full
|
||||||
|
pre-release form - matching what `version bump` looks for."""
|
||||||
|
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||||
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
||||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||||
@@ -243,16 +262,16 @@ def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkey
|
|||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
issues = docs_verify.check_breaking_change_for_boundary()
|
issues = docs_verify.check_breaking_change_for_boundary()
|
||||||
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
|
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
||||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +279,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
|||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|||||||
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
|
|||||||
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_running_candidate_is_named_as_such(instance):
|
||||||
|
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
||||||
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
||||||
|
assert "0.2.0-beta.1" in detail
|
||||||
|
assert "candidate" in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
||||||
|
"""Regression for finding #3: comparing the chain against the raw
|
||||||
|
candidate would sort `2.0.0` (the migration's target) *before*
|
||||||
|
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
||||||
|
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||||
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
||||||
|
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
||||||
|
migrations.mkdir(parents=True, exist_ok=True)
|
||||||
|
(migrations / "2.0.0-retype.md").write_text(
|
||||||
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
checks = doctor.run_doctor()
|
||||||
|
assert _status(checks, "kb-version") == "WARN"
|
||||||
|
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
||||||
|
|
||||||
|
|
||||||
def test_no_collections_at_all_fails_structure(instance):
|
def test_no_collections_at_all_fails_structure(instance):
|
||||||
"""Removing one collection is a legitimate state - collections are
|
"""Removing one collection is a legitimate state - collections are
|
||||||
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import typer
|
|||||||
|
|
||||||
from chemenu import config, kb_state
|
from chemenu import config, kb_state
|
||||||
from chemenu.commands import migrate_cmd
|
from chemenu.commands import migrate_cmd
|
||||||
from chemenu.version import Version
|
from chemenu.version import Version, VersionError
|
||||||
|
|
||||||
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
||||||
|
|
||||||
@@ -108,6 +108,19 @@ def test_status_lists_the_chain_in_order(instance, capsys):
|
|||||||
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_chain_still_reaches_a_target_matching_a_running_candidate(instance, capsys):
|
||||||
|
"""Regression for finding #3: a migration targeting `2.0.0` must still be
|
||||||
|
owed while `VERSION` is the running candidate `2.0.0-beta.1` - `2.0.0` sorts
|
||||||
|
*above* its own candidate, so comparing against the raw pre-release would
|
||||||
|
drop it out of the chain right when the machinery that owes it installs."""
|
||||||
|
(instance / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||||
|
set_kb_version(instance, "1.7.0")
|
||||||
|
migrate_cmd.status_command(json_out=True)
|
||||||
|
result = json.loads(capsys.readouterr().out)
|
||||||
|
assert result["stack_version"] == "2.0.0-beta.1"
|
||||||
|
assert [m["migrates_to"] for m in result["pending"]] == ["2.0.0"]
|
||||||
|
|
||||||
|
|
||||||
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
||||||
migrate_cmd.list_command(json_out=True)
|
migrate_cmd.list_command(json_out=True)
|
||||||
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
||||||
@@ -155,6 +168,16 @@ def test_done_without_a_declared_kb_version_is_refused(instance):
|
|||||||
# --- baseline --------------------------------------------------------------
|
# --- baseline --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_kb_version_refuses_a_pre_release(instance):
|
||||||
|
"""A content shape has no beta channel - only the stack version does."""
|
||||||
|
set_kb_version(instance, "1.3.1")
|
||||||
|
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "kb_version": "1.4.0-beta.1", "applied": []}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
with pytest.raises(VersionError):
|
||||||
|
kb_state.read_kb_version()
|
||||||
|
|
||||||
|
|
||||||
def test_baseline_declares_the_version_once(instance):
|
def test_baseline_declares_the_version_once(instance):
|
||||||
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
||||||
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
||||||
|
|||||||
@@ -83,6 +83,86 @@ def test_compare_separates_a_compatible_update_from_a_migration(local, latest, s
|
|||||||
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
||||||
|
|
||||||
|
|
||||||
|
# --- candidates: parsing and ordering ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
|
||||||
|
def test_parse_accepts_a_candidate_suffix(text):
|
||||||
|
version = Version.parse(text)
|
||||||
|
assert version.is_prerelease
|
||||||
|
assert version.beta == int(text.rsplit(".", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_release_has_no_beta():
|
||||||
|
version = Version.parse("4.4.0")
|
||||||
|
assert not version.is_prerelease
|
||||||
|
assert version.beta is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"lesser,greater",
|
||||||
|
[
|
||||||
|
("4.4.0-beta.1", "4.4.0"),
|
||||||
|
("4.4.0-beta.1", "4.4.0-beta.2"),
|
||||||
|
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
|
||||||
|
("4.4.0-beta.9", "4.4.1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
|
||||||
|
assert Version.parse(lesser) < Version.parse(greater)
|
||||||
|
assert Version.parse(greater) > Version.parse(lesser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_strips_the_candidate_suffix():
|
||||||
|
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
|
||||||
|
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumped_always_returns_a_release_even_from_a_candidate():
|
||||||
|
"""`bumped()` answers "what would the next fixed version be" - it is
|
||||||
|
`escalate()` that knows about running candidates."""
|
||||||
|
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
|
||||||
|
|
||||||
|
|
||||||
|
# --- candidates: escalation --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_opens_the_first_candidate_at_beta_one():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
candidate = version_mod.escalate(release, release, "minor")
|
||||||
|
assert str(candidate) == "4.4.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
first = version_mod.escalate(release, release, "minor")
|
||||||
|
second = version_mod.escalate(release, first, "patch")
|
||||||
|
assert str(second) == "4.4.0-beta.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_never_steps_back_down():
|
||||||
|
release = Version.parse("1.4.0")
|
||||||
|
major = version_mod.escalate(release, release, "major")
|
||||||
|
still_major = version_mod.escalate(release, major, "patch")
|
||||||
|
assert still_major.base == major.base
|
||||||
|
assert still_major.beta == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_raises_the_base_and_resets_the_bump_count():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
minor = version_mod.escalate(release, release, "minor")
|
||||||
|
major = version_mod.escalate(release, minor, "major")
|
||||||
|
assert str(major) == "5.0.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
|
||||||
|
"""The fresh-distribution edge case: a changelog with no versioned entry at
|
||||||
|
all opens a candidate straight from `current`, rather than failing."""
|
||||||
|
fresh = Version.parse("0.1.0")
|
||||||
|
candidate = version_mod.escalate(None, fresh, "patch")
|
||||||
|
assert str(candidate) == "0.1.1-beta.1"
|
||||||
|
|
||||||
|
|
||||||
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
||||||
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
||||||
assert "migration" in status.headline.lower()
|
assert "migration" in status.headline.lower()
|
||||||
@@ -167,14 +247,75 @@ def test_changes_section_is_none_for_an_undocumented_version():
|
|||||||
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
||||||
|
|
||||||
|
|
||||||
def test_insert_changes_entry_lands_above_the_newest_entry():
|
def test_last_release_skips_an_open_candidate_above_it():
|
||||||
|
text = (
|
||||||
|
CHANGES_HEADER
|
||||||
|
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
|
||||||
|
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
)
|
||||||
|
assert version_mod.last_release(text) == Version(0, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_release_is_none_with_no_versioned_entry_at_all():
|
||||||
|
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
|
||||||
|
assert version_mod.last_release(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
|
||||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
result = version_mod.insert_changes_entry(
|
result = version_mod.insert_changes_entry(
|
||||||
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
|
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
|
||||||
)
|
)
|
||||||
assert result.index("## 0.2.0") < result.index("## 0.1.0")
|
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
|
||||||
assert "Preamble." in result
|
assert "Preamble." in result
|
||||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
|
assert "- Newer" in result # the bump-title list seeds itself with this title
|
||||||
|
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_updates_an_open_candidate_in_place():
|
||||||
|
"""The second bump of the same candidate must not open a second entry -
|
||||||
|
one entry per running candidate, per instructions/dev/version-parts.md."""
|
||||||
|
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
first = version_mod.insert_changes_entry(
|
||||||
|
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
|
||||||
|
)
|
||||||
|
second = version_mod.insert_changes_entry(
|
||||||
|
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
|
||||||
|
)
|
||||||
|
assert second.count("## 0.2.0") == 1
|
||||||
|
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
|
||||||
|
assert "- First title" in second
|
||||||
|
assert "- Second title" in second
|
||||||
|
assert "## 0.1.0" in second # the older, already-released entry survives untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
|
||||||
|
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
first = version_mod.insert_changes_entry(
|
||||||
|
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
|
||||||
|
breaking_reason="the feed moved", no_migration_reason="kb untouched",
|
||||||
|
)
|
||||||
|
second = version_mod.insert_changes_entry(
|
||||||
|
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
|
||||||
|
)
|
||||||
|
assert version_mod.BREAKING_CHANGE_MARKER in second
|
||||||
|
assert "the feed moved" in second
|
||||||
|
assert version_mod.MIGRATION_NONE_MARKER in second
|
||||||
|
assert "kb untouched" in second
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
|
||||||
|
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
|
||||||
|
released = version_mod.release_entry(text, "2026-09-05")
|
||||||
|
assert "## 0.2.0 - 2026-09-05 - Second title" in released
|
||||||
|
assert "- First title" in released
|
||||||
|
assert "- Second title" in released
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_entry_can_replace_the_title():
|
||||||
|
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
|
||||||
|
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
|
||||||
|
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||||
|
|
||||||
|
|
||||||
# --- version bump ----------------------------------------------------------
|
# --- version bump ----------------------------------------------------------
|
||||||
@@ -185,13 +326,28 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
|||||||
major=False, minor=True, patch=False, title="Something happened",
|
major=False, minor=True, patch=False, title="Something happened",
|
||||||
breaking=None, no_migration=None, dry_run=False,
|
breaking=None, no_migration=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-beta.1"
|
||||||
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-beta.1 - " in changes
|
||||||
assert "Something happened" in changes
|
assert "Something happened" in changes
|
||||||
assert "**Author:** Test Author" in changes
|
assert "**Author:** Test Author" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert changes.count("## 1.1.0") == 1
|
||||||
|
assert "First" in changes and "Second" in changes
|
||||||
|
|
||||||
|
|
||||||
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, dry_run=True
|
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
||||||
@@ -219,9 +375,10 @@ def test_bump_refuses_an_empty_title(tree):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
||||||
"""A changelog documenting a version the tree has not reached means
|
"""A changelog whose newest entry names a different version than VERSION
|
||||||
someone edited one of the two by hand; bumping past it would hide that."""
|
means someone edited one of the two by hand; bumping past it would hide
|
||||||
|
that instead of surfacing it."""
|
||||||
(tree / "CHANGES.md").write_text(
|
(tree / "CHANGES.md").write_text(
|
||||||
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -258,7 +415,31 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
|||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
|
||||||
|
"""Finding #4: the requirement fires once, at the bump that first escalates
|
||||||
|
to the boundary; a later bump of the same candidate is not asked again."""
|
||||||
|
migrations = tree / "instructions" / "migrations"
|
||||||
|
(migrations / "2.0.0-retype.md").write_text(
|
||||||
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
|
"description: Retype every page.\nmanual: true\n"
|
||||||
|
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
|
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Follow-up",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||||
|
assert "every page is retyped" in changes
|
||||||
|
|
||||||
|
|
||||||
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||||
@@ -321,6 +502,65 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
|||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- version release --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert "## 1.1.0 - " in changes
|
||||||
|
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
|
||||||
|
assert "First bump" in changes # kept, since --title was not given
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_can_replace_the_title(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert "## 1.1.0 - " in changes
|
||||||
|
assert "Summary of both bumps" in changes
|
||||||
|
# the machine-managed bump list is left as the record of what happened
|
||||||
|
assert "First bump" in changes
|
||||||
|
assert "Second bump" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_dry_run_writes_nothing(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title=None, dry_run=True)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_when_version_is_already_a_release(tree):
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
# --- version notes ---------------------------------------------------------
|
# --- version notes ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
|||||||
assert "## 1.0.0" in capsys.readouterr().out
|
assert "## 1.0.0" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.notes_command(version=None)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "## 1.1.0-beta.2" in out
|
||||||
|
assert "First bump" in out and "Second bump" in out
|
||||||
|
|
||||||
|
|
||||||
def test_notes_fails_for_a_version_with_no_entry(tree):
|
def test_notes_fails_for_a_version_with_no_entry(tree):
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.notes_command(version="9.9.9")
|
version_cmd.notes_command(version="9.9.9")
|
||||||
|
|||||||
+251
-18
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -42,7 +43,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import blocks, config
|
||||||
|
|
||||||
VERSION_FILENAME = "VERSION"
|
VERSION_FILENAME = "VERSION"
|
||||||
CHANGES_FILENAME = "CHANGES.md"
|
CHANGES_FILENAME = "CHANGES.md"
|
||||||
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
|||||||
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
||||||
|
|
||||||
PARTS = ("major", "minor", "patch")
|
PARTS = ("major", "minor", "patch")
|
||||||
|
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
|
||||||
|
|
||||||
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
|
||||||
# second ordering rule everywhere a version is compared - the release feed, the
|
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
|
||||||
# migration chain, the compatibility check - to serve a workflow this stack does
|
# ordering numeric and total. See "Candidates and releases" below.
|
||||||
# not have.
|
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
|
||||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
|
||||||
|
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
|
||||||
|
# every `--title` a candidate has collected across its bumps. Reuses
|
||||||
|
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
|
||||||
|
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
|
||||||
|
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
|
||||||
|
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
|
||||||
|
# its rendering, belong here instead.
|
||||||
|
BUMPS_BLOCK_NAME = "bumps"
|
||||||
|
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
||||||
|
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
|
||||||
|
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
|
||||||
|
|
||||||
# Written into a CHANGES.md entry whose version crosses a compatibility
|
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||||
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
|||||||
# A changelog entry that names a version. Entries predating versioning start
|
# A changelog entry that names a version. Entries predating versioning start
|
||||||
# with a date instead and are deliberately not matched - they are history, not
|
# with a date instead and are deliberately not matched - they are history, not
|
||||||
# a claim about which version the tree is.
|
# a claim about which version the tree is.
|
||||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
class VersionError(ValueError):
|
class VersionError(ValueError):
|
||||||
@@ -92,11 +105,34 @@ class VersionError(ValueError):
|
|||||||
written to be shown to the user verbatim."""
|
written to be shown to the user verbatim."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, order=True)
|
@functools.total_ordering
|
||||||
|
@dataclass(frozen=True)
|
||||||
class Version:
|
class Version:
|
||||||
|
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
|
||||||
|
(`-beta.N`) between two releases.
|
||||||
|
|
||||||
|
**Candidates and releases.** Between two releases the stack carries at
|
||||||
|
most one running candidate rather than a fresh number per `bump` - see
|
||||||
|
`instructions/dev/version-parts.md`. `VERSION` holds either a release
|
||||||
|
(`beta is None`) or a candidate (`beta` is the bump count since the
|
||||||
|
candidate's base was last raised). `base` strips the suffix; `bumped()`
|
||||||
|
always returns a release-shaped `Version`, because it answers "what would
|
||||||
|
the *next fixed* version be", never "what candidate comes next" - that
|
||||||
|
answer needs `escalate()`, which also knows the last release to escalate
|
||||||
|
against.
|
||||||
|
|
||||||
|
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
|
||||||
|
a real release after every candidate that shares its base - `4.4.0-beta.1
|
||||||
|
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
|
||||||
|
`int` do not compare, and the ordering is inverted relative to field
|
||||||
|
declaration order anyway. `functools.total_ordering` plus an explicit
|
||||||
|
`__lt__` is the direct way to say what the ordering actually is.
|
||||||
|
"""
|
||||||
|
|
||||||
major: int
|
major: int
|
||||||
minor: int
|
minor: int
|
||||||
patch: int
|
patch: int
|
||||||
|
beta: Optional[int] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, text: str) -> "Version":
|
def parse(cls, text: str) -> "Version":
|
||||||
@@ -104,11 +140,32 @@ class Version:
|
|||||||
if not match:
|
if not match:
|
||||||
raise VersionError(
|
raise VersionError(
|
||||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
||||||
|
"or MAJOR.MINOR.PATCH-beta.N"
|
||||||
)
|
)
|
||||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
beta = int(match.group(4)) if match.group(4) is not None else None
|
||||||
|
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
|
||||||
|
|
||||||
def __str__(self) -> str: # noqa: D105 - obvious
|
def __str__(self) -> str: # noqa: D105 - obvious
|
||||||
return f"{self.major}.{self.minor}.{self.patch}"
|
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
|
||||||
|
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
|
||||||
|
|
||||||
|
def _sort_key(self) -> tuple[int, int, int, int, int]:
|
||||||
|
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
|
||||||
|
|
||||||
|
def __lt__(self, other: "Version") -> bool:
|
||||||
|
if not isinstance(other, Version):
|
||||||
|
return NotImplemented
|
||||||
|
return self._sort_key() < other._sort_key()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prerelease(self) -> bool:
|
||||||
|
return self.beta is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base(self) -> "Version":
|
||||||
|
"""This version with any candidate suffix stripped - what it would be
|
||||||
|
once fixed. A no-op on a version that is already a release."""
|
||||||
|
return Version(self.major, self.minor, self.patch)
|
||||||
|
|
||||||
def bumped(self, part: str) -> "Version":
|
def bumped(self, part: str) -> "Version":
|
||||||
if part == "major":
|
if part == "major":
|
||||||
@@ -127,6 +184,10 @@ class Version:
|
|||||||
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
||||||
non-zero component, so it compares by all three - during `0.0.x`
|
non-zero component, so it compares by all three - during `0.0.x`
|
||||||
every release is a breaking one, which is what that range means.
|
every release is a breaking one, which is what that range means.
|
||||||
|
|
||||||
|
Computed over major/minor/patch alone, i.e. over the **base**: a
|
||||||
|
candidate's pre-release suffix carries no compatibility information of
|
||||||
|
its own, it is the base that will be released that does.
|
||||||
"""
|
"""
|
||||||
components = (self.major, self.minor, self.patch)
|
components = (self.major, self.minor, self.patch)
|
||||||
for index, component in enumerate(components):
|
for index, component in enumerate(components):
|
||||||
@@ -135,6 +196,44 @@ class Version:
|
|||||||
return components
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_between(reference: Version, base: Version) -> Optional[str]:
|
||||||
|
"""Which part `base` has escalated past `reference` on, or None if equal.
|
||||||
|
|
||||||
|
Both are release-shaped (no beta): `reference` is the last real release,
|
||||||
|
`base` is a candidate's base. Exactly one of major/minor/patch differs,
|
||||||
|
because `bumped()` always resets everything to the right of the part it
|
||||||
|
raises - so the leftmost differing component *is* the stage.
|
||||||
|
"""
|
||||||
|
for part in PARTS:
|
||||||
|
if getattr(reference, part) != getattr(base, part):
|
||||||
|
return part
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
|
||||||
|
"""The next candidate: `current` escalated by `part` against `last_release`,
|
||||||
|
max-wins.
|
||||||
|
|
||||||
|
A running candidate never steps back down: bumping `--patch` on a MINOR
|
||||||
|
candidate only advances its bump count (`beta`), it does not lower the
|
||||||
|
base. `last_release=None` is the fresh-distribution edge case - a
|
||||||
|
changelog with no versioned entry at all - where there is nothing to
|
||||||
|
escalate against, so the candidate's base is simply `current` bumped by
|
||||||
|
`part`; see instructions/dev/version-parts.md for why that is not an
|
||||||
|
error.
|
||||||
|
"""
|
||||||
|
if part not in _STAGE_RANK:
|
||||||
|
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
||||||
|
reference = last_release if last_release is not None else (
|
||||||
|
current.base if current.is_prerelease else current
|
||||||
|
)
|
||||||
|
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
|
||||||
|
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
|
||||||
|
new_base = reference.bumped(new_stage)
|
||||||
|
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
|
||||||
|
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UpdateStatus:
|
class UpdateStatus:
|
||||||
"""The answer `version check` reports. `state` is the actionable part:
|
"""The answer `version check` reports. `state` is the actionable part:
|
||||||
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
|
|||||||
return Version.parse(match.group(1))
|
return Version.parse(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def last_release(text: str) -> Optional[Version]:
|
||||||
|
"""The newest entry that is a **release**, not a running candidate, or
|
||||||
|
`None` if the changelog names no release at all yet.
|
||||||
|
|
||||||
|
Entries are inserted newest-first (see `insert_changes_entry`), so the
|
||||||
|
first non-pre-release heading found scanning top-down is the last release
|
||||||
|
- whether or not the very top entry is an open candidate sitting above it.
|
||||||
|
A changelog with no versioned entry (a fresh distribution) answers `None`,
|
||||||
|
which `escalate()` treats as its own edge case rather than an error.
|
||||||
|
"""
|
||||||
|
for match in _CHANGES_ENTRY_RE.finditer(text):
|
||||||
|
version = Version.parse(match.group(1))
|
||||||
|
if not version.is_prerelease:
|
||||||
|
return version
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def changes_section(text: str, version: Version) -> Optional[str]:
|
def changes_section(text: str, version: Version) -> Optional[str]:
|
||||||
"""The body of one version's entry, heading included, ready to become
|
"""The body of one version's entry, heading included, ready to become
|
||||||
release notes.
|
release notes.
|
||||||
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bumps_block(titles: list[str]) -> str:
|
||||||
|
lines = "\n".join(f"- {title}" for title in titles)
|
||||||
|
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_titles(section: str) -> list[str]:
|
||||||
|
match = _BUMPS_RE.search(section)
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
line[2:].strip()
|
||||||
|
for line in match.group(1).strip("\n").splitlines()
|
||||||
|
if line.strip().startswith("- ")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||||
|
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
||||||
|
|
||||||
|
Used for the breaking-change and no-migration lines, which - unlike the
|
||||||
|
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||||
|
restates it rather than growing a list nobody would read as history.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||||
|
if pattern.search(section):
|
||||||
|
return pattern.sub(line, section, count=1)
|
||||||
|
anchor = section.find(_BUMPS_CLOSE)
|
||||||
|
if anchor != -1:
|
||||||
|
insert_at = section.find("\n", anchor)
|
||||||
|
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
||||||
|
else:
|
||||||
|
insert_at = len(section)
|
||||||
|
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_span(text: str) -> tuple[int, int]:
|
||||||
|
"""Start/end offsets of the topmost entry, heading included."""
|
||||||
|
match = re.search(r"^## ", text, re.MULTILINE)
|
||||||
|
if not match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
|
||||||
|
start = match.start()
|
||||||
|
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
|
||||||
|
end = start + 1 + following.start() if following else len(text)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _update_open_candidate(
|
||||||
|
text: str,
|
||||||
|
version: Version,
|
||||||
|
date: str,
|
||||||
|
title: str,
|
||||||
|
breaking_reason: Optional[str],
|
||||||
|
no_migration_reason: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||||
|
`title` to its machine-managed bump list, and set the breaking/no-migration
|
||||||
|
lines only where this call supplies them - see `insert_changes_entry`."""
|
||||||
|
start, end = _entry_span(text)
|
||||||
|
section = text[start:end]
|
||||||
|
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
if not heading_match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
|
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||||
|
|
||||||
|
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
||||||
|
|
||||||
|
if breaking_reason:
|
||||||
|
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||||
|
if no_migration_reason:
|
||||||
|
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||||
|
|
||||||
|
return text[:start] + section + text[end:]
|
||||||
|
|
||||||
|
|
||||||
def insert_changes_entry(
|
def insert_changes_entry(
|
||||||
text: str,
|
text: str,
|
||||||
version: Version,
|
version: Version,
|
||||||
@@ -351,18 +542,35 @@ 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,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add a heading for `version` above the newest existing entry.
|
"""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 in place instead.
|
||||||
|
|
||||||
Only the skeleton: heading, date, author, and - when a compatibility
|
`version bump` always lands on a candidate (see `escalate`); only
|
||||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
`version release` fixes one, and it edits the heading directly rather than
|
||||||
content has to change where that applies. The entry's actual content is
|
through this path (`version_cmd.release_command`), which is what makes "is
|
||||||
written afterwards by whoever made the change, which is also why `bump`
|
the topmost heading still a pre-release" the right test for "is a
|
||||||
refuses to invent a title.
|
candidate still open" here.
|
||||||
|
|
||||||
The break comes first: it is what an operator reading the release notes has
|
A fresh entry gets the skeleton only: heading, date, author, the
|
||||||
to act on, and the migration line only qualifies it.
|
machine-managed bump-title list (started with this one title, for a
|
||||||
|
candidate), and - when a compatibility boundary is crossed - the line
|
||||||
|
saying what breaks, plus the line saying no content has to change where
|
||||||
|
that applies. The break comes first: it is what an operator reading the
|
||||||
|
release notes has to act on, and the migration line only qualifies it. The
|
||||||
|
entry's actual prose is written afterwards by whoever made the change,
|
||||||
|
which is also why `bump` refuses to invent a title.
|
||||||
"""
|
"""
|
||||||
|
top = top_changes_version(text)
|
||||||
|
if top is not None and top.is_prerelease:
|
||||||
|
return _update_open_candidate(
|
||||||
|
text, version, date, title,
|
||||||
|
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
||||||
@@ -372,3 +580,28 @@ def insert_changes_entry(
|
|||||||
if anchor:
|
if anchor:
|
||||||
return text[: anchor.start()] + entry + text[anchor.start():]
|
return text[: anchor.start()] + entry + text[anchor.start():]
|
||||||
return text.rstrip() + "\n\n---\n\n" + entry
|
return text.rstrip() + "\n\n---\n\n" + entry
|
||||||
|
|
||||||
|
|
||||||
|
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
|
||||||
|
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
|
||||||
|
today's heading, keeping the previous title unless `title` overrides it.
|
||||||
|
|
||||||
|
Leaves the rest of the entry - the bump-title list included - untouched:
|
||||||
|
it is the record of what happened across the candidate's life, and a
|
||||||
|
release call has no reason to discard it. `version_cmd.release_command`
|
||||||
|
is the only caller; it has already checked the topmost entry names a
|
||||||
|
pre-release, so a non-pre-release version reaching here is a caller bug.
|
||||||
|
"""
|
||||||
|
start, end = _entry_span(text)
|
||||||
|
section = text[start:end]
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
if not heading_match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
|
|
||||||
|
current = Version.parse(heading_match.group(1))
|
||||||
|
rest = heading_match.group(2) or ""
|
||||||
|
_, _, existing_title = rest.partition(" - ")
|
||||||
|
new_title = title if title is not None else existing_title
|
||||||
|
|
||||||
|
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
|
||||||
|
return text[:start] + section + text[end:]
|
||||||
|
|||||||
Reference in New Issue
Block a user