diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 075f9ff..69b5630 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -66,6 +66,26 @@ jobs: run: | set -eu 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}" echo "version=${version}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT" @@ -82,6 +102,7 @@ jobs: - name: Release notes from CHANGES.md # `version notes` fails when the changelog has no entry for this # version, which is the last place that mistake can still be caught. + if: steps.version.outputs.skip != 'true' run: | set -eu tools/wikitool docs verify @@ -90,6 +111,7 @@ jobs: - name: Build the distribution tarball id: build + if: steps.version.outputs.skip != 'true' env: VERSION: ${{ steps.version.outputs.version }} TAG: ${{ steps.version.outputs.tag }} @@ -109,6 +131,7 @@ jobs: echo "name=${name}" >> "$GITHUB_OUTPUT" - name: Publish the release + if: steps.version.outputs.skip != 'true' env: API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} TOKEN: ${{ gitea.token }} diff --git a/AGENTS.md b/AGENTS.md index 1b6b5c2..d5ef52b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | | `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 | | `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 | diff --git a/CHANGES.md b/CHANGES.md index e3d21e7..e84635e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,62 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest versioned entry disagree. Entries below `0.1.0` predate versioning and keep their date-only headings. +Since `4.4.0` the stack carries **one running candidate** between two +releases rather than a fresh version per bump - see +`instructions/dev/version-parts.md`. While a candidate is open its heading +names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - - `), 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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..5b62e32 --- /dev/null +++ b/DEVELOPMENT.md @@ -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. diff --git a/README.md b/README.md index b39ad85..07fb9c4 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ chemenu/ 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 ``` <!-- dist:strip-end --> diff --git a/VERSION b/VERSION index e91d9be..fdc6698 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.3 +4.4.0 diff --git a/docs/version-model.md b/docs/version-model.md index 93b1376..0f1a24e 100644 --- a/docs/version-model.md +++ b/docs/version-model.md @@ -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 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 The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the diff --git a/instructions/dev/version-parts.md b/instructions/dev/version-parts.md index 068fbaa..8866343 100644 --- a/instructions/dev/version-parts.md +++ b/instructions/dev/version-parts.md @@ -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 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 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` | | 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 - other two parts are not: every existing instance pays for it, once, by hand. Put in front of - them, in this order: +4. **Stop and talk to the user before the bump that first escalates a candidate past the + boundary.** It is expensive in a way the other two parts are not: every existing instance pays + 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 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. -5. **Record the break in the bump itself.** A boundary-crossing bump requires - `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry: +5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary + requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the + entry: ```bash 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 ``` - `--breaking` is refused on a bump that crosses nothing, and required on 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. + The line, once written, stays in the entry across every later bump of the same candidate - + a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat + it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required + 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 content-migrating are independent: - 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. 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 cannot reconstruct from the diff, and it is what the next session in this position will read instead of guessing. diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 2685385..022a5d6 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -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 check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate | | `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate | -| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--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 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 | @@ -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 | | `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 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 | | `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 | diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index 54453d2..d15836d 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -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 # optional. A distribution whose server is present but undocumented is one # 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 = ( "AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md", ".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 # safe to write here rather than leaving it to `migrate baseline`. Only an # 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( - 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 diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index 97f63ef..ae7e38e 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]: 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 - notes describe the previous one. 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. + notes describe the previous one. `VERSION` may name a running candidate + (`-beta.N`) rather than a release - `Version.parse`/equality read the + suffix like any other component, so a candidate is compared exactly like a + 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 if not version_path.is_file(): @@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]: 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]: """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 no content has to change. - Only the newest entry is checked. Older boundaries were either satisfied - when they were written or cannot be fixed retroactively, and re-reporting - them forever would make the check noise. + Only the newest entry is checked, against the **last release** rather than + the entry beneath it - between two candidates of the same running upgrade + (`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 @@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]: text = changes_path.read_text(encoding="utf-8") 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: - 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: return [] if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""): 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 [ @@ -544,8 +540,9 @@ def check_breaking_change_for_boundary() -> list[str]: import name or flag - satisfies that check and still leaves every existing instance with something to do by hand. - Only the newest entry is checked, for the same reason: older crossings are - history, and re-reporting them forever would make the check noise. + Only the newest entry is checked, against the **last release** - see + `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 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") 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: - 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: return [] diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index 6186e7c..6decc75 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -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')}" - 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: @@ -420,7 +421,7 @@ def check_kb_version() -> Check: "never lagged behind its machinery", ) 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: return Check( "kb-version", "WARN", diff --git a/tools/chemenu/commands/migrate_cmd.py b/tools/chemenu/commands/migrate_cmd.py index ff19f88..ff484ec 100644 --- a/tools/chemenu/commands/migrate_cmd.py +++ b/tools/chemenu/commands/migrate_cmd.py @@ -160,7 +160,7 @@ def status_command( ) 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())) divergent = kb_state.divergent_files() @@ -271,7 +271,7 @@ def done_command( ) return - expected = kb_state.next_link(migrations, kb_version, stack) + expected = kb_state.next_link(migrations, kb_version, stack.base) if expected is None: fail( f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no " @@ -297,7 +297,7 @@ def done_command( return kb_state.write_kb_state(target, applied) - remaining = kb_state.chain(migrations, target, stack) + remaining = kb_state.chain(migrations, target, stack.base) success( f"Content is now {target} ({expected.name}). " + ( diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index 9a573b6..912da4e 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -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 -that number means): +Four jobs that all hang off one number (see `chemenu/version.py` for what that +number means, and `instructions/dev/version-parts.md` for the candidate model): - `version show` answers "which stack is this instance running", offline, from `VERSION` plus the release stamp `dist export` writes. -- `version bump` moves it, and writes the changelog *heading* that has to - accompany the move - the same structure-by-tool/prose-by-author split as - `new`. `docs verify` then holds the two together. +- `version bump` raises or continues the one running candidate between two + releases, and writes the changelog *heading* that has to accompany it - the + 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. 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 @@ -188,34 +192,36 @@ def bump_command( major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"), minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets 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( None, "--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( None, "--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"), ): - """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 - entry's body stays the author's to write, the same way `new` produces - frontmatter and leaves the prose. `docs verify` afterwards enforces that - the two agree, so a bump with no entry cannot reach a release. + Between two releases the stack carries **one** candidate, not a fresh + number per bump: `--patch/--minor/--major` is max-wins escalation against + the last release, never a step back down, and the candidate's bump count + (`-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 - not a drop-in replacement, whether or not any content moves - requires - `--breaking "<what stops working>"`, and on top of that either a migration - document for the new version or `--no-migration "<reason>"`. An instance - learning that it must migrate, with nothing telling it what broke or how to - cross, is the gap these close. Which part to pass stays a judgment call - this command does not make - it enforces only that a crossing says what it - costs.""" + A bump whose escalation first crosses the compatibility boundary - the new + version is not a drop-in replacement, whether or not any content moves - + requires `--breaking "<what stops working>"`, and on top of that either a + migration document for the new base or `--no-migration "<reason>"`. Both + lines are written into the entry once and then persist across every later + bump at the same stage: a follow-up bump need not repeat them, and passing + either on a bump that crosses nothing at all is refused.""" selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen] if len(selected) != 1: fail("Pass exactly one of --major / --minor / --patch") @@ -226,7 +232,6 @@ def bump_command( try: current = version_mod.read_version() - new_version = current.bumped(selected[0]) except VersionError as exc: fail(str(exc)) return @@ -236,19 +241,29 @@ def bump_command( fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself") return 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( - f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older " - f"than {new_version} - bump past it, or fix the changelog" + f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but " + f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. " + "Fix whichever is wrong." ) return + last_release = version_mod.last_release(text) + new_version = version_mod.escalate(last_release, current, selected[0]) + 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 "" - if crossing and not breaking: + if crossing and not was_already_crossing and not breaking: fail( 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 " @@ -265,14 +280,14 @@ def bump_command( ) return - if crossing and not no_migration: + if crossing and not was_already_crossing and not no_migration: 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( f"{current} -> {new_version} crosses the compatibility boundary, so every existing " - f"instance must migrate - but no migration document targets {new_version}.\n" - f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md " + 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.base}-<slug>.md " f"(see instructions/migrate-corpus.md), or, if no content actually has to change, " f're-run with --no-migration "<reason>".' ) @@ -298,6 +313,76 @@ def bump_command( encoding="utf-8", ) success( - f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened " - f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing." + f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and " + f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and " + f"`version release` once the candidate is ready to ship." + ) + + +@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." ) diff --git a/tools/chemenu/kb_state.py b/tools/chemenu/kb_state.py index a80b10a..85b57f3 100644 --- a/tools/chemenu/kb_state.py +++ b/tools/chemenu/kb_state.py @@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]: 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 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() if state is None: @@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]: raw = state.get("kb_version") if not raw: 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]: @@ -171,6 +181,13 @@ def chain( Targets above the installed machinery are excluded: the instance has no code 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 version chain: declining one leaves the content in a shape the machinery still accepts, so counting it as owed would make `kb_version` unreachable diff --git a/tools/chemenu/tests/test_dist_cmd.py b/tools/chemenu/tests/test_dist_cmd.py index 29a4acd..7bb04e4 100644 --- a/tools/chemenu/tests/test_dist_cmd.py +++ b/tools/chemenu/tests/test_dist_cmd.py @@ -430,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo): 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): (repo / "VERSION").unlink() target = tmp_path / "dist" diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index 85450d8..b5f5178 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for(): 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 / "CHANGES.md").write_text( "# 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): """`version check` tells an instance it must migrate; without this, that is 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() - 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): - _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() == [] +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): from chemenu import version as version_mod _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", ) assert docs_verify.check_migration_for_boundary() == [] 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( "---\ntype: types/instruction.md\nname: 2.0.0-retype\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 _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", ) assert docs_verify.check_migration_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): - _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() == [] @@ -260,7 +279,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch): from chemenu import version as version_mod _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", ) assert docs_verify.check_breaking_change_for_boundary() == [] diff --git a/tools/chemenu/tests/test_doctor.py b/tools/chemenu/tests/test_doctor.py index b116d32..24e76c8 100644 --- a/tools/chemenu/tests/test_doctor.py +++ b/tools/chemenu/tests/test_doctor.py @@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance): 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): """Removing one collection is a legitimate state - collections are discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md). diff --git a/tools/chemenu/tests/test_migrate_cmd.py b/tools/chemenu/tests/test_migrate_cmd.py index 9b6c6c3..a227269 100644 --- a/tools/chemenu/tests/test_migrate_cmd.py +++ b/tools/chemenu/tests/test_migrate_cmd.py @@ -11,7 +11,7 @@ import typer from chemenu import config, kb_state 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" @@ -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"] +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): migrate_cmd.list_command(json_out=True) 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 -------------------------------------------------------------- +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): migrate_cmd.baseline_command(version="1.3.1", force=False) assert kb_state.read_kb_version() == Version(1, 3, 1) diff --git a/tools/chemenu/tests/test_version_cmd.py b/tools/chemenu/tests/test_version_cmd.py index c7028c2..9fe50e0 100644 --- a/tools/chemenu/tests/test_version_cmd.py +++ b/tools/chemenu/tests/test_version_cmd.py @@ -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 +# --- 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(): status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration") 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 -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" 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 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 ---------------------------------------------------------- @@ -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", 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") - assert "## 1.1.0 - " in changes + assert "## 1.1.0-beta.1 - " in changes assert "Something happened" 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): version_cmd.bump_command( 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): - """A changelog documenting a version the tree has not reached means - someone edited one of the two by hand; bumping past it would hide that.""" +def test_bump_refuses_when_version_and_changelog_disagree(tree): + """A changelog whose newest entry names a different version than VERSION + means someone edited one of the two by hand; bumping past it would hide + that instead of surfacing it.""" (tree / "CHANGES.md").write_text( 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", 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): @@ -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" +# --- 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 --------------------------------------------------------- @@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys): 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): with pytest.raises(typer.Exit): version_cmd.notes_command(version="9.9.9") diff --git a/tools/chemenu/version.py b/tools/chemenu/version.py index 4470755..db62f08 100644 --- a/tools/chemenu/version.py +++ b/tools/chemenu/version.py @@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root. """ from __future__ import annotations +import functools import json import os import re @@ -42,7 +43,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional -from chemenu import config +from chemenu import blocks, config VERSION_FILENAME = "VERSION" CHANGES_FILENAME = "CHANGES.md" @@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL" UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN" 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 -# second ordering rule everywhere a version is compared - the release feed, the -# migration chain, the compatibility check - to serve a workflow this stack does -# not have. -_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$") +# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`. +# Deliberately not a general SemVer pre-release alphabet - one channel keeps the +# ordering numeric and total. See "Candidates and releases" below. +_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\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 # 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 # with a date instead and are deliberately not matched - they are history, not # 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): @@ -92,23 +105,67 @@ class VersionError(ValueError): written to be shown to the user verbatim.""" -@dataclass(frozen=True, order=True) +@functools.total_ordering +@dataclass(frozen=True) 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 minor: int patch: int + beta: Optional[int] = None @classmethod def parse(cls, text: str) -> "Version": match = _SEMVER_RE.match(text or "") if not match: 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 - 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": 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 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. + + 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) for index, component in enumerate(components): @@ -135,6 +196,44 @@ class Version: 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) class UpdateStatus: """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)) +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]: """The body of one version's entry, heading included, ready to become release notes. @@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]: return None +def _bumps_block(titles: list[str]) -> str: + lines = "\n".join(f"- {title}" for title in titles) + return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}" + + +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( text: str, version: Version, @@ -351,18 +542,35 @@ def insert_changes_entry( no_migration_reason: Optional[str] = None, breaking_reason: Optional[str] = None, ) -> 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 - boundary is crossed - the line saying what breaks, plus the line saying no - content has to change where that applies. The entry's actual content is - written afterwards by whoever made the change, which is also why `bump` - refuses to invent a title. + `version bump` always lands on a candidate (see `escalate`); only + `version release` fixes one, and it edits the heading directly rather than + through this path (`version_cmd.release_command`), which is what makes "is + the topmost heading still a pre-release" the right test for "is a + candidate still open" here. - The break comes first: it is what an operator reading the release notes has - to act on, and the migration line only qualifies it. + A fresh entry gets the skeleton only: heading, date, author, the + machine-managed bump-title list (started with this one title, for a + candidate), and - when a compatibility boundary is crossed - the line + saying what breaks, plus the line saying no content has to change where + that applies. The break comes first: it is what an operator reading the + release notes has to act on, and the migration line only qualifies it. The + entry's actual prose is written afterwards by whoever made the change, + which is also why `bump` refuses to invent a title. """ + 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}", ""] + if version.is_prerelease: + lines += [_bumps_block([title]), ""] if breaking_reason: lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""] if no_migration_reason: @@ -372,3 +580,28 @@ def insert_changes_entry( if anchor: return text[: anchor.start()] + entry + text[anchor.start():] 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:]