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:
+3
-12
@@ -187,8 +187,8 @@ tools/wikitool <command> --help
|
||||
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors `raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
||||
| `dist upgrade <source> [--dry-run] [--keep-local] [--take-release <path>]... [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list, and its text names the three answers with the command line already filled in, so that no reader takes any of them for the default. `--keep-local` proceeds and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the release's version over the named path, discarding the local change, and re-creates it if it was locally deleted. The two are decided per path and compose on one call: without `--keep-local`, a locally changed path that no `--take-release` names still aborts the run. A `--take-release` path that this run does not report as locally changed is refused, in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a state of the tree, and a path that silently did nothing would report a successful upgrade while keeping the change it was asked to discard. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path taken with `--take-release` is the opposite case and the reason the flag exists: it was written, so it matches the digest the stamp records and stops being reported at all. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), it is a pre-release (`-beta.N`) without `--pre`, or `--take-release` names a path this run does not classify as locally changed. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" |
|
||||
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
|
||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). One of the **two** commands in `wikitool` that make a network call, and the only one whose whole job it is - `version notes` is the other, and only on a distributed instance. Never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||
| `version notes [--version X.Y.Z] [--offline] [--url U] [--timeout S]` | Print one version's release notes (default: this tree's `VERSION`): the `CHANGES.md` entry where there is one, and where there is not, the feed's latest release notes. The fallback exists because an instance's `CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_preserved`), so the local file can never carry the entry - not today and not after any future release, which made the command permanently unanswerable exactly where the release notes are most needed. It is reached **only with a release stamp present**, i.e. only from a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed being asked, and the one naming the release that answered, go to stderr - `release.yml` redirects stdout into the file it posts as the release body. Only the feed's *latest* release can be asked for (`update_url` is the one URL a stamp records, and composing a by-tag URL out of it would be guessing at an API shape), so a returned version other than the one asked for is named on stderr and printed anyway - the expected shape before an upgrade, where `VERSION` still names the release being left. `--offline` refuses the call and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||
@@ -399,7 +399,7 @@ is atomic, and whether a retry is safe.
|
||||
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, a `--take-release` path that is not classified as locally changed (the one refusal a `--dry-run` also raises), or one or more locally changed files that neither `--keep-local` nor a `--take-release` answers for | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For a rejected `--take-release` path: correct it against the locally-changed list the refusal prints. For locally changed files, the refusal names all three answers with the re-run line filled in - `--take-release <path>` to write the release's version over it (which ends the divergence), `--keep-local` to leave them untouched (repeatable, and it reports the same files again on every subsequent run until they stop diverging), or reconcile by hand and retry. An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
||||
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
|
||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
|
||||
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, or a missing `CHANGES.md`. No entry for the requested version is an error only where the feed cannot answer either: in a tree with no release stamp (a dev checkout - write the entry, or `version bump`), with `--offline`, or when the feed could not be reached or returned a release with an empty `body`. Every one of those failures names the stamp's `release_url` where it has one, so a run that cannot read the notes is still told where they are | Read-only | Fix the named argument or file, then retry. A feed failure is transient - retry once, then read the release page the error names. Safe to retry |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
|
||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||
@@ -451,16 +451,7 @@ Run by the LLM through the skills, on this cadence:
|
||||
|
||||
## Future considerations (not implemented)
|
||||
|
||||
- MCP server wrapper exposing these same commands as native tool calls for
|
||||
MCP-capable agents, instead of shell invocation.
|
||||
- A pre-commit hook running `wikitool lint --fail-on-error` before every
|
||||
`wikitool publish`. CI already runs it on every push
|
||||
(`.gitea/workflows/ci.yml`), which catches it after the fact rather than
|
||||
before.
|
||||
- `dist upgrade`: apply a newer release to an instance that already has
|
||||
content. `version check` detects that one exists and says whether it crosses
|
||||
a compatibility boundary; applying it is the manual procedure in
|
||||
[INSTALL.md](../INSTALL.md) § "Eine Instanz aktualisieren". The `files` block
|
||||
of `.wikitool-release.json` is the groundwork - it records what the machinery
|
||||
looked like at install time, which is the only way to tell a file the
|
||||
instance edited from one it merely received.
|
||||
|
||||
@@ -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