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

+67 -10
View File
@@ -349,8 +349,12 @@ def fetch_latest(
The network call sits behind `fetcher` so every caller above this line -
and every test - can run without a network. This is the one place in
`wikitool` that talks to a remote host, and it is reached only from
`version check`, never implicitly from another command.
`wikitool` that talks to a remote host, and only two commands reach it:
`version check`, whose whole job it is, and `version notes` on a
*distributed* instance, whose local `CHANGES.md` is a stub with no entry to
print (see `fetch_latest_notes`). Neither is implicit - `check` exists for
the call, and `notes` announces the URL it is asking before it asks, on
stderr, and takes `--offline` for a caller that wants none of it.
"""
fetch = fetcher or _urlopen_fetch
try:
@@ -378,14 +382,18 @@ def fetch_latest(
return Version.parse(tag)
def fetch_latest_release(
def _fetch_latest_object(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> tuple[Version, Optional[str], Optional[str]]:
"""`fetch_latest` plus the two display fields a report wants: the release's
own page and its publication date."""
token: Optional[str],
timeout: float,
fetcher: Optional[Fetcher],
) -> tuple[Version, dict]:
"""`fetch_latest`'s version plus the whole release object it came out of.
`fetch_latest` deliberately answers one question and validates only the
field that answers it. The two callers below want further fields off the
same response, and neither may make a second request for them - so the
payload is captured on the way through rather than re-fetched."""
fetch = fetcher or _urlopen_fetch
captured: dict = {}
@@ -399,12 +407,61 @@ def fetch_latest_release(
pass
return payload
version = fetch_latest(url, token, timeout, capturing)
return fetch_latest(url, token, timeout, capturing), captured
def fetch_latest_release(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> tuple[Version, Optional[str], Optional[str]]:
"""`fetch_latest` plus the two display fields a report wants: the release's
own page and its publication date."""
version, captured = _fetch_latest_object(url, token, timeout, fetcher)
html_url = captured.get("html_url") or captured.get("url")
published = captured.get("published_at") or captured.get("created_at")
return version, (str(html_url) if html_url else None), (str(published) if published else None)
def fetch_latest_notes(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> tuple[Version, str, Optional[str]]:
"""The latest release's notes text, its version, and its own page.
This is what makes `version notes` answer on a distributed instance at
all. Such an instance receives `CHANGES.md` as a nine-line stub with no
version entries, and `dist upgrade` never overwrites it
(`ownership.is_upgrade_preserved`), so the local file it would read can
never carry the entry - not today and not after any future release. The
release the feed publishes carries the same text in its `body`, because
`release.yml` builds that body out of `version notes` in the origin repo.
Only the feed's *latest* release can be asked for: `update_url` is the one
URL a release stamp records, and composing a `/releases/tags/<tag>` URL out
of it would be guessing at an API shape rather than reading a recorded one
(AGENTS.md invariant 7). The caller therefore compares the returned version
against what it asked for and says so - which is not the edge case but the
main one: an operator reads the notes *before* the swap, while `VERSION`
still names the release being left.
Raises `VersionError` for an unreachable feed, a non-release answer, or a
release with an empty body - an empty answer must never read as "this
release has no breaking change to report"."""
version, captured = _fetch_latest_object(url, token, timeout, fetcher)
body = str(captured.get("body") or "").strip()
if not body:
raise VersionError(
f"{url} answered with release {version} but no notes text (`body` is empty) - "
"nothing to print, and an empty answer must not read as 'nothing to report'"
)
html_url = captured.get("html_url") or captured.get("url")
return version, body, (str(html_url) if html_url else None)
# --- CHANGES.md ------------------------------------------------------------
#
# The changelog is prose and stays the author's job. What is mechanical is the