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
This commit is contained in:
1 parent
72d01beef8
commit
0c98080964
8 files changed
+360
-52
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user