version notes: Fallback auf den Release-Feed, wenn die Instanz keinen lokalen Eintrag hat (#107)
CI / verify (push) Successful in 46s
Release / release (push) Successful in 36s

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
This commit is contained in:
torben committed 2026-09-16 17:54:39 +02:00
1 parent 72d01beef8
commit 0c98080964
8 files changed
+360 -52

No files matched your search

+110 -9
View File
@@ -19,10 +19,16 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
rendered list - the correction path for the judgment `version bump
--impact` made at the time, per Gitea #95's fix for an unreadably long,
ungraded bump list.
- `version check` is the one command in `wikitool` that makes a network call.
It is deliberately its own command: nothing else reaches for it implicitly,
it needs no key, it times out, and a feed that cannot be reached is reported
as an error rather than silently answered as "up to date".
- `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
@@ -32,10 +38,18 @@ 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,
@@ -169,13 +183,48 @@ 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 `CHANGES.md` entry, for use as release notes.
"""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."""
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
@@ -186,13 +235,65 @@ def notes_command(
return
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
if section is None:
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"
)
return
typer.echo(section, nl=False)
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")