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")
+111 -3
View File
@@ -995,7 +995,7 @@ def test_release_refuses_when_version_and_changelog_disagree(tree):
def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
version_cmd.notes_command(version=None)
version_cmd.run_notes()
assert "## 1.0.0" in capsys.readouterr().out
@@ -1008,7 +1008,7 @@ def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.notes_command(version=None)
version_cmd.run_notes()
out = capsys.readouterr().out
assert "## 1.1.0-beta.2" in out
assert "First bump" in out and "Second bump" in out
@@ -1016,7 +1016,115 @@ def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
def test_notes_fails_for_a_version_with_no_entry(tree):
with pytest.raises(typer.Exit):
version_cmd.notes_command(version="9.9.9")
version_cmd.run_notes(version="9.9.9")
# --- version notes on a distributed instance --------------------------------
#
# Such an instance receives CHANGES.md as a nine-line stub with no version
# entries, and `dist upgrade` never overwrites it, so the local file it would
# read can never carry the entry - not today and not after any future release.
# The run that found this (a traced 5.0.0 -> 6.0.0 upgrade) only got past the
# step because it read the release page through an MCP server, which is not a
# path INSTALL.md named and not one every instance has.
def _stamped(tree: Path, release_url: str = "https://example.invalid/releases/tag/v2.0.0") -> None:
"""Make `tree` read as a tree that came out of `dist export`. The stamp's
presence is what gates the feed fallback: a dev checkout has none."""
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text(
json.dumps(
{
"schema": version_mod.STAMP_SCHEMA,
"version": "1.0.0",
"release_url": release_url,
"update_url": "https://example.invalid/api/v1/repos/x/y/releases/latest",
"files": {},
}
),
encoding="utf-8",
)
def test_notes_falls_back_to_the_feed_when_the_instance_has_no_entry(tree, capsys):
_stamped(tree)
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub, no entries.\n", encoding="utf-8")
version_cmd.run_notes(
fetcher=_feed({"tag_name": "1.0.0", "body": "## 1.0.0\n\n**Migration:** none required\n"})
)
captured = capsys.readouterr()
assert "**Migration:** none required" in captured.out
# stdout is consumed by `release.yml`'s redirect, so the provenance lines
# must not be on it.
assert "Asking" not in captured.out
assert "Asking" in captured.err
def test_notes_names_the_version_the_feed_answered_when_it_differs(tree, capsys):
"""The main case, not an edge one: the notes are read *before* the swap,
while VERSION still names the release being left."""
_stamped(tree)
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
version_cmd.run_notes(fetcher=_feed({"tag_name": "2.0.0", "body": "## 2.0.0\n\nnotes\n"}))
captured = capsys.readouterr()
assert "## 2.0.0" in captured.out
assert "not 1.0.0's" in " ".join(captured.err.split())
def test_notes_offline_refuses_the_feed_and_names_the_release_page(tree, capsys):
_stamped(tree)
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
with pytest.raises(typer.Exit) as excinfo:
version_cmd.run_notes(offline=True, fetcher=_feed({"tag_name": "2.0.0", "body": "x"}))
assert excinfo.value.exit_code == 1
out = " ".join(capsys.readouterr().out.split())
assert "https://example.invalid/releases/tag/v2.0.0" in out
assert "--offline was passed" in out
def test_notes_on_an_unreachable_feed_still_hands_over_the_release_page(tree, capsys):
"""An instance that cannot reach the feed must not be left with only a
network error: the page is the answer it was after."""
_stamped(tree)
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
def refusing(url: str, token, timeout: float) -> bytes:
raise OSError("no route to host")
with pytest.raises(typer.Exit):
version_cmd.run_notes(fetcher=refusing)
out = " ".join(capsys.readouterr().out.split())
assert "https://example.invalid/releases/tag/v2.0.0" in out
def test_notes_treats_an_empty_release_body_as_an_error(tree):
"""An empty answer must never read as "this release has nothing to
report" - the two lines an operator needs are **Breaking Change:** and
**Migration:**, and their absence is not the same as their being empty."""
_stamped(tree)
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
with pytest.raises(typer.Exit):
version_cmd.run_notes(fetcher=_feed({"tag_name": "1.0.0", "body": " "}))
def test_notes_never_asks_a_feed_without_a_release_stamp(tree, capsys):
"""The guard that keeps the origin repo and CI offline: `release.yml` runs
`version notes > /tmp/release-notes.md` in a tree that has no stamp, so it
can never reach the fallback however its CHANGES.md looks."""
(tree / "CHANGES.md").write_text("# Changelog\n\nno entry here\n", encoding="utf-8")
def exploding(url: str, token, timeout: float) -> bytes:
raise AssertionError("a tree with no release stamp must not ask a feed")
with pytest.raises(typer.Exit):
version_cmd.run_notes(fetcher=exploding)
out = " ".join(capsys.readouterr().out.split())
assert "version bump" in out # the dev-checkout fix, not the instance one
# --- version check ---------------------------------------------------------
+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