Files
chemenu/tools/chemenu/commands/version_cmd.py
T
torben 0c98080964
CI / verify (push) Successful in 46s
Release / release (push) Successful in 36s
version notes: Fallback auf den Release-Feed, wenn die Instanz keinen lokalen Eintrag hat (#107)
Befund 2 aus dem getraceten 5.0.0-auf-6.0.0-Upgrade-Lauf. Eine ausgelieferte
Instanz bekommt CHANGES.md als Stub und dist upgrade ueberschreibt sie nie, der
Befehl konnte dort also nie antworten - an genau der Stelle, an der Breaking
Change und Migration gelesen werden muessen.

Fehlt der Eintrag lokal, wird der Feed aus update_url gefragt. Nur mit
Release-Stamp, damit Ursprungs-Repo und CI den Pfad nicht betreten koennen;
stdout traegt nur die Notes, Herkunft nach stderr; --offline verweigert den
Aufruf und nennt die release_url, so wie jeder Feed-Fehlerfall auch.

Dazu zwei seit ihrer Umsetzung falsche Eintraege aus tools/CONTRACT.md
"Future considerations" entfernt: MCP-Server-Wrapper und dist upgrade.

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-16 17:54:39 +02:00

651 lines
27 KiB
Python

"""`wikitool version` - report, bump, release, and check the stack's version.
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` 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. Refuses if the candidate collected
more than one bump and its entry still carries no summary above the
changesets - see `version_mod.summary_prose`.
- `version regrade` lists or changes the impact grade (high/medium/low) of
the running candidate's bump titles, addressed by their position in the
rendered list - the correction path for the judgment `version bump
--impact` made at the time, per Gitea #95's fix for an unreadably long,
ungraded bump list.
- `version notes` prints one version's release notes. In a tree that writes
its own `CHANGES.md` that is a mechanical extraction from it; on a
*distributed* instance, whose `CHANGES.md` is a stub `dist upgrade` never
overwrites, it falls back to the release feed, because otherwise the command
can never answer there - not today and not after any future release.
- `version check` and that fallback are the only two network calls in
`wikitool`, and neither is implicit: `check` exists for the call, `notes`
announces the URL on stderr before asking and takes `--offline`. Both need
no key, both time out, and a feed that cannot be reached is reported as an
error rather than silently answered as "up to date" or "no notes".
"""
from __future__ import annotations
import json as _json
import re
from typing import Optional
import typer
from rich.console import Console
from chemenu import config, version as version_mod
from chemenu.commands._util import console, fail, rel_path, success, today_iso
from chemenu.version import Version, VersionError
# `version notes` is the one command whose stdout is consumed by a machine -
# `release.yml` redirects it into the file it posts as the release body - so
# everything it says *about* the notes goes here instead of onto the same
# stream as the notes themselves.
err = Console(stderr=True)
app = typer.Typer(
help="Report, bump, and check the stack version (see tools/CONTRACT.md).",
invoke_without_command=True,
)
@app.callback()
def version_callback(ctx: typer.Context) -> None:
"""Bare `wikitool version` is a convenience alias for `version show`."""
if ctx.invoked_subcommand is None:
show_command(json_out=False)
def _describe_origin(stamp: Optional[dict]) -> str:
if not stamp:
return "development tree (no release stamp)"
parts = []
exported = stamp.get("exported_at")
if exported:
parts.append(f"exported {exported}")
commit = str(stamp.get("source_commit") or "")
if commit:
parts.append(f"from commit {commit[:12]}")
repo = stamp.get("source_repo")
if repo:
parts.append(str(repo))
return "distribution: " + ", ".join(parts) if parts else "distribution"
@app.command("show")
def show_command(
json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"),
):
"""Print this instance's stack version and where it came from. Read-only,
offline, and exempt from the Iteration Budget Gate."""
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
if json_out:
typer.echo(
_json.dumps(
{
"version": str(current),
"compat_key": list(current.compat_key),
"stamp": stamp,
"update_url": version_mod.update_url(stamp),
},
indent=2,
)
)
return
console.print(f"[bold]{current}[/bold] ({_describe_origin(stamp)})")
if stamp and stamp.get("release_url"):
console.print(f"release: {stamp['release_url']}")
@app.command("check")
def check_command(
url: Optional[str] = typer.Option(
None, "--url", help="Release feed to ask (default: the stamp's, else the built-in origin)"
),
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for the feed"),
json_out: bool = typer.Option(False, "--json", help="Print the result as JSON"),
):
"""Ask the origin's release feed whether a newer stack exists.
The only networked command in `wikitool`. Exits 1 if the feed cannot be
reached or does not answer with a release - an unreachable feed is not the
same answer as "up to date", and must never be reported as one."""
import os
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
feed = url or version_mod.update_url(stamp)
token = os.environ.get(version_mod.UPDATE_TOKEN_ENV, "").strip() or None
try:
latest, release_url, published = version_mod.fetch_latest_release(feed, token, timeout)
except VersionError as exc:
fail(str(exc))
return
status = version_mod.UpdateStatus(
local=current,
latest=latest,
state=version_mod.compare(current, latest),
release_url=release_url,
published_at=published,
)
if json_out:
typer.echo(
_json.dumps(
{
"local": str(status.local),
"latest": str(status.latest),
"state": status.state,
"requires_migration": status.state == "migration",
"release_url": status.release_url,
"published_at": status.published_at,
"feed": feed,
},
indent=2,
)
)
return
color = {"current": "green", "ahead": "yellow", "update": "cyan", "migration": "bold yellow"}
console.print(f"[{color[status.state]}]{status.headline}[/{color[status.state]}]")
if status.release_url:
console.print(f"release: {status.release_url}")
if status.state in ("update", "migration"):
console.print(
"Applying it is a separate, manual step - see INSTALL.md "
"§ 'Eine Instanz aktualisieren'."
)
@app.command("notes")
def notes_command(
version: Optional[str] = typer.Option(
None, "--version", help="Which entry to print (default: this tree's VERSION)"
),
offline: bool = typer.Option(
False, "--offline",
help="Never ask the release feed: on a distributed instance, whose CHANGES.md carries no "
"entry to print, fail with the release page instead of fetching the notes",
),
url: Optional[str] = typer.Option(
None, "--url", help="Release feed to ask for the fallback (default: the stamp's, as `version check`)"
),
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for the feed"),
):
"""Print one version's release notes: the `CHANGES.md` entry where there is
one, the installed release's notes from the feed on a distributed instance,
where there never is.
Mechanical extraction, so the release workflow never has to parse markdown
in shell - which is also why **stdout carries nothing but the notes** and
every line about where they came from goes to stderr. `release.yml` does
`version notes > /tmp/release-notes.md`.
The fallback is reached only with a release stamp present, i.e. only from a
tree that came out of `dist export`. A dev checkout keeps the plain error,
so this command cannot make a network call in the origin repository or in
CI. See `version_mod.fetch_latest_notes` for why only the feed's *latest*
release can be asked for."""
run_notes(version=version, offline=offline, url=url, timeout=timeout)
def run_notes(
version: Optional[str] = None,
offline: bool = False,
url: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[version_mod.Fetcher] = None,
) -> None:
"""`version notes` itself, free of Typer's option objects - the same split
`dist_cmd.run_export` makes, and for the same reason. `fetcher` is the
network seam: a test passes one, nothing else does."""
import os
try:
wanted = Version.parse(version) if version else version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there are no release notes to print")
return
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
if section is not None:
typer.echo(section, nl=False)
return
release_url = str((stamp or {}).get("release_url") or "").strip()
if stamp is None or offline:
fail(_no_entry_message(wanted, stamp is not None, release_url))
return
feed = url or version_mod.update_url(stamp)
token = os.environ.get(version_mod.UPDATE_TOKEN_ENV, "").strip() or None
err.print(
f"[dim]{version_mod.CHANGES_FILENAME} has no entry for {wanted} - a distributed instance "
f"receives it as a stub. Asking {feed}[/dim]"
)
try:
latest, body, page = version_mod.fetch_latest_notes(feed, token, timeout, fetcher)
except VersionError as exc:
fail(
f"{exc}. The notes for {wanted} are on the release page instead: "
f"{release_url or '(no release_url in the release stamp)'}"
)
return
if latest == wanted:
err.print(f"[dim]These are {latest}'s notes, from {page or feed}[/dim]")
else:
err.print(
f"[yellow]These are {latest}'s notes, not {wanted}'s[/yellow] - the feed publishes only "
f"its latest release, and this tree declares {wanted}. That is the expected shape "
f"before an upgrade, where VERSION still names the release being left. From "
f"{page or feed}"
)
typer.echo(body)
def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str:
"""Why there is no entry, and where the notes are instead.
Two trees land here and they are not the same mistake: a dev checkout that
has not written its entry yet, and an instance that was told not to go
online (the only way an instance reaches this at all). Naming the wrong one
sends the reader to the wrong fix."""
if not has_stamp:
return (
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted} - "
f"run `wikitool version bump` before releasing, or write the entry"
)
where = (
f"Read them on the release page instead: {release_url}"
if release_url
else f"The release stamp records no `release_url` to point at - `wikitool version check` "
f"names the feed this instance asks."
)
return (
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted}, and a distributed instance "
f"never has one: it receives the file as a stub and `dist upgrade` never overwrites it. "
f"--offline was passed, so the feed was not asked. {where}"
)
@app.command("bump")
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/updated CHANGES.md entry"),
breaking: Optional[str] = typer.Option(
None,
"--breaking",
help="What stops working (recorded in CHANGES.md). Required on the bump that first escalates to a boundary crossing, optional on a later bump of the same crossing candidate - where it joins the reasons already recorded rather than replacing them. Refused on a bump that crosses nothing",
),
no_migration: Optional[str] = typer.Option(
None,
"--no-migration",
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
),
migration_required: bool = typer.Option(
False,
"--migration-required",
help="Retract this candidate's earlier --no-migration line: a migration is needed after all. "
"Requires a migration document already targeting the new base, and refuses when the entry "
"carries no --no-migration line to retract.",
),
impact: Optional[str] = typer.Option(
None,
"--impact",
help="high|medium|low - how much this bump matters to a reader of the release notes "
"(default: medium). Grouped into the entry's bump list; `version regrade` corrects it "
"later if the running candidate's own judgment changes.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Raise or continue the running candidate, and open or update its
`CHANGES.md` entry.
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 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
are written into the entry and persist across every later bump at the same
stage, so a follow-up bump need not repeat them, and passing either on a
bump that crosses nothing at all is refused.
A candidate can cross the boundary more than once, and the two flags part
ways there. A further `--breaking` **joins** the reasons already recorded -
each crossing is its own thing an operator has to act on, and replacing
meant the second one silently deleted the first. A further
`--no-migration` **replaces**: whether content has to change is one
question about the candidate as a whole, not one per crossing.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
migration document first, then re-run with this flag instead of
`--no-migration`. There is no other way to take the line back: it is
machine-written, and invariant 1 forbids hand-editing it."""
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")
return
if not title.strip():
fail("--title must not be empty - it becomes the changelog entry's heading")
return
if impact is not None and impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
chosen_impact = impact or version_mod.DEFAULT_IMPACT
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry is not None and 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 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 = 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 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 "
f"instance must do about it>\".\n"
f"If that sentence is hard to write because nothing actually breaks - no hand-work "
f"on update, and the old version can still be put back - then the bump is probably "
f"not --{selected[0]}."
)
return
if breaking and not crossing:
fail(
f"--breaking only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if crossing and not was_already_crossing and not no_migration:
from chemenu import kb_state
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.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>".'
)
return
if no_migration and not crossing:
fail(
f"--no-migration only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if migration_required and no_migration:
fail("--migration-required and --no-migration contradict each other on the same bump.")
return
if migration_required and not current.is_prerelease:
fail(
"--migration-required only makes sense on a bump that continues an already-open "
"candidate - there is no running candidate here to retract a no-migration line from."
)
return
if migration_required:
current_section = version_mod.changes_section(text, current) or ""
if version_mod.MIGRATION_NONE_MARKER not in current_section:
fail(
f"{current}'s {version_mod.CHANGES_FILENAME} entry carries no "
f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do."
)
return
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
fail(
f"--migration-required retracts the no-migration line, so a migration document must "
f"target {new_version.base} first - write one under "
f"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
f"(see instructions/migrate-corpus.md), then re-run with --migration-required."
)
return
if dry_run:
success(f"Dry run: {current} -> {new_version}{boundary}. Nothing written.")
return
version_mod.write_version(new_version)
changes.write_text(
version_mod.insert_changes_entry(
text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
impact=chosen_impact,
),
encoding="utf-8",
)
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
f"publishing, and `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. Also refuses - Gitea #95 - when the candidate collected two or
more bumps and its entry still has no summary paragraph above the
individual changesets: a release note that is only a chronological bump
list is exactly the thing this refusal exists to stop shipping. A
candidate with exactly one bump is exempt - there, the bump's own
changeset already is the summary."""
try:
current = version_mod.read_version()
except VersionError as exc:
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
section = version_mod.changes_section(text, current) or ""
bump_count = len(version_mod.bump_entries(section))
summary_chars = len(re.sub(r"\s+", "", version_mod.summary_prose(section)))
if bump_count >= 2 and summary_chars < version_mod.SUMMARY_MIN_CHARS:
fail(
f"This candidate collected {bump_count} bumps, but its {version_mod.CHANGES_FILENAME} "
"entry carries no summary above the individual changesets - write a short paragraph "
"(a few sentences on what this release is about) right below the bump list before "
"releasing. `version regrade` (no arguments) shows the bump list first, if that helps."
)
return
new_version = current.base
if dry_run:
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."
)
@app.command("regrade")
def regrade_command(
indices: Optional[list[int]] = typer.Argument(
None,
help="1-based positions in the rendered bump list to regrade (see the bare listing). "
"Omit to just list.",
),
impact: Optional[str] = typer.Option(
None, "--impact", help="high|medium|low - required together with indices"
),
):
"""List the running candidate's bump titles with their impact grade, or
change one or more of them in a single call.
Positions are `version_mod.bump_entries`'s own rendered order - grouped
High before Medium before Low, chronological within a grade - as it
stands *before* this call: `wikitool version regrade 3 7 --impact high`
regrades both against today's list in one read, not #3 first and then #7
against whatever regrading #3 produced. Run the bare command again
afterwards to see the result and its new numbering.
The bare listing is read-only and, like `version notes`, exempt from the
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
like `version bump`, because that is what it does."""
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there is nothing to regrade")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a regrade. "
"Fix whichever is wrong."
)
return
section = version_mod.changes_section(text, current) or ""
entries = version_mod.bump_entries(section)
if not entries:
fail(f"{current}'s {version_mod.CHANGES_FILENAME} entry has no bump list to regrade.")
return
if not indices:
for position, (level, bump_title) in enumerate(entries, start=1):
typer.echo(f"{position}. [{level}] {bump_title}")
return
if impact is None:
fail("--impact is required when regrading - pass one of high/medium/low.")
return
if impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
updates = {index: impact for index in indices}
try:
new_text = version_mod.regrade(text, current, updates)
except VersionError as exc:
fail(str(exc))
return
changes.write_text(new_text, encoding="utf-8")
success(f"Regraded {len(indices)} bump title(s) to {impact} impact.")