Files
chemenu/tools/chemenu/commands/version_cmd.py
T
torben 1b0158fc8d
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s
stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-12 18:23:58 +02:00

543 lines
22 KiB
Python

"""`wikitool version` - report, bump, release, and check the stack's version.
Four jobs that all hang off one number (see `chemenu/version.py` for what that
number means, and `instructions/dev/version-parts.md` for the candidate model):
- `version show` answers "which stack is this instance running", offline, from
`VERSION` plus the release stamp `dist export` writes.
- `version bump` raises or continues the one running candidate between two
releases, and writes the changelog *heading* that has to accompany it - the
same structure-by-tool/prose-by-author split as `new`. `docs verify` then
holds the two together.
- `version release` fixes that candidate: strips its `-beta.N` suffix and
closes its changelog entry. It is the only thing that turns a candidate into
a number a release actually consumes. Refuses if the candidate collected
more than one bump and its entry still carries no summary above the
changesets - see `version_mod.summary_prose`.
- `version regrade` lists or changes the impact grade (high/medium/low) of
the running candidate's bump titles, addressed by their position in the
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".
"""
from __future__ import annotations
import json as _json
import re
from typing import Optional
import typer
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
app = typer.Typer(
help="Report, bump, and check the stack version (see tools/CONTRACT.md).",
invoke_without_command=True,
)
@app.callback()
def version_callback(ctx: typer.Context) -> None:
"""Bare `wikitool version` is a convenience alias for `version show`."""
if ctx.invoked_subcommand is None:
show_command(json_out=False)
def _describe_origin(stamp: Optional[dict]) -> str:
if not stamp:
return "development tree (no release stamp)"
parts = []
exported = stamp.get("exported_at")
if exported:
parts.append(f"exported {exported}")
commit = str(stamp.get("source_commit") or "")
if commit:
parts.append(f"from commit {commit[:12]}")
repo = stamp.get("source_repo")
if repo:
parts.append(str(repo))
return "distribution: " + ", ".join(parts) if parts else "distribution"
@app.command("show")
def show_command(
json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"),
):
"""Print this instance's stack version and where it came from. Read-only,
offline, and exempt from the Iteration Budget Gate."""
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
if json_out:
typer.echo(
_json.dumps(
{
"version": str(current),
"compat_key": list(current.compat_key),
"stamp": stamp,
"update_url": version_mod.update_url(stamp),
},
indent=2,
)
)
return
console.print(f"[bold]{current}[/bold] ({_describe_origin(stamp)})")
if stamp and stamp.get("release_url"):
console.print(f"release: {stamp['release_url']}")
@app.command("check")
def check_command(
url: Optional[str] = typer.Option(
None, "--url", help="Release feed to ask (default: the stamp's, else the built-in origin)"
),
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for the feed"),
json_out: bool = typer.Option(False, "--json", help="Print the result as JSON"),
):
"""Ask the origin's release feed whether a newer stack exists.
The only networked command in `wikitool`. Exits 1 if the feed cannot be
reached or does not answer with a release - an unreachable feed is not the
same answer as "up to date", and must never be reported as one."""
import os
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
feed = url or version_mod.update_url(stamp)
token = os.environ.get(version_mod.UPDATE_TOKEN_ENV, "").strip() or None
try:
latest, release_url, published = version_mod.fetch_latest_release(feed, token, timeout)
except VersionError as exc:
fail(str(exc))
return
status = version_mod.UpdateStatus(
local=current,
latest=latest,
state=version_mod.compare(current, latest),
release_url=release_url,
published_at=published,
)
if json_out:
typer.echo(
_json.dumps(
{
"local": str(status.local),
"latest": str(status.latest),
"state": status.state,
"requires_migration": status.state == "migration",
"release_url": status.release_url,
"published_at": status.published_at,
"feed": feed,
},
indent=2,
)
)
return
color = {"current": "green", "ahead": "yellow", "update": "cyan", "migration": "bold yellow"}
console.print(f"[{color[status.state]}]{status.headline}[/{color[status.state]}]")
if status.release_url:
console.print(f"release: {status.release_url}")
if status.state in ("update", "migration"):
console.print(
"Applying it is a separate, manual step - see INSTALL.md "
"§ 'Eine Instanz aktualisieren'."
)
@app.command("notes")
def notes_command(
version: Optional[str] = typer.Option(
None, "--version", help="Which entry to print (default: this tree's VERSION)"
),
):
"""Print one version's `CHANGES.md` entry, for use as release notes.
Mechanical extraction, so the release workflow never has to parse markdown
in shell."""
try:
wanted = Version.parse(version) if version else version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there are no release notes to print")
return
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
if section is None:
fail(
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)
@app.command("bump")
def bump_command(
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
title: str = typer.Option(..., "--title", help="One-line title for the new/updated CHANGES.md entry"),
breaking: Optional[str] = typer.Option(
None,
"--breaking",
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
),
no_migration: Optional[str] = typer.Option(
None,
"--no-migration",
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
),
migration_required: bool = typer.Option(
False,
"--migration-required",
help="Retract this candidate's earlier --no-migration line: a migration is needed after all. "
"Requires a migration document already targeting the new base, and refuses when the entry "
"carries no --no-migration line to retract.",
),
impact: Optional[str] = typer.Option(
None,
"--impact",
help="high|medium|low - how much this bump matters to a reader of the release notes "
"(default: medium). Grouped into the entry's bump list; `version regrade` corrects it "
"later if the running candidate's own judgment changes.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Raise or continue the running candidate, and open or update its
`CHANGES.md` entry.
Between two releases the stack carries **one** candidate, not a fresh
number per bump: `--patch/--minor/--major` is max-wins escalation against
the last release, never a step back down, and the candidate's bump count
(`-beta.N`) advances either way. See
`instructions/dev/version-parts.md` for the full model, and
`version release` for what fixes a candidate into a release.
A bump whose escalation first crosses the compatibility boundary - the new
version is not a drop-in replacement, whether or not any content moves -
requires `--breaking "<what stops working>"`, and on top of that either a
migration document for the new base or `--no-migration "<reason>"`. Both
lines are written into the entry once and then persist across every later
bump at the same stage: a follow-up bump need not repeat them, and passing
either on a bump that crosses nothing at all is refused.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
migration document first, then re-run with this flag instead of
`--no-migration`. There is no other way to take the line back: it is
machine-written, and invariant 1 forbids hand-editing it."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch")
return
if not title.strip():
fail("--title must not be empty - it becomes the changelog entry's heading")
return
if impact is not None and impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
chosen_impact = impact or version_mod.DEFAULT_IMPACT
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry is not None and top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
"Fix whichever is wrong."
)
return
last_release = version_mod.last_release(text)
new_version = version_mod.escalate(last_release, current, selected[0])
author = config.default_author() or "unknown"
crossing = last_release is not None and new_version.compat_key != last_release.compat_key
was_already_crossing = (
last_release is not None
and current.is_prerelease
and current.compat_key != last_release.compat_key
)
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
if crossing and not was_already_crossing and not breaking:
fail(
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
f"instance must do about it>\".\n"
f"If that sentence is hard to write because nothing actually breaks - no hand-work "
f"on update, and the old version can still be put back - then the bump is probably "
f"not --{selected[0]}."
)
return
if breaking and not crossing:
fail(
f"--breaking only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if crossing and not was_already_crossing and not no_migration:
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
fail(
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
f"instance must migrate - but no migration document targets {new_version.base}.\n"
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
f're-run with --no-migration "<reason>".'
)
return
if no_migration and not crossing:
fail(
f"--no-migration only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if migration_required and no_migration:
fail("--migration-required and --no-migration contradict each other on the same bump.")
return
if migration_required and not current.is_prerelease:
fail(
"--migration-required only makes sense on a bump that continues an already-open "
"candidate - there is no running candidate here to retract a no-migration line from."
)
return
if migration_required:
current_section = version_mod.changes_section(text, current) or ""
if version_mod.MIGRATION_NONE_MARKER not in current_section:
fail(
f"{current}'s {version_mod.CHANGES_FILENAME} entry carries no "
f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do."
)
return
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
fail(
f"--migration-required retracts the no-migration line, so a migration document must "
f"target {new_version.base} first - write one under "
f"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
f"(see instructions/migrate-corpus.md), then re-run with --migration-required."
)
return
if dry_run:
success(f"Dry run: {current} -> {new_version}{boundary}. Nothing written.")
return
version_mod.write_version(new_version)
changes.write_text(
version_mod.insert_changes_entry(
text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
impact=chosen_impact,
),
encoding="utf-8",
)
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
f"publishing, and `version release` once the candidate is ready to ship."
)
@app.command("release")
def release_command(
title: Optional[str] = typer.Option(
None, "--title", help="Replace the entry's heading title (default: the last bump's)"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Fix the running candidate: strip its `-beta.N` suffix and close its
`CHANGES.md` entry.
Ends the pre-release phase this checkout has been in since its last
`version bump` - the candidate's base becomes the release. Without
`--title` the heading keeps whichever bump last set it; with it, the
heading gets a summarising title instead, which is the normal case for a
candidate that collected several bump titles along the way. The
machine-managed list of those titles is left in the entry as the record of
what happened, not replaced.
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
Refuses when `VERSION` is already a release: there is no running candidate
to fix. Also refuses - Gitea #95 - when the candidate collected two or
more bumps and its entry still has no summary paragraph above the
individual changesets: a release note that is only a chronological bump
list is exactly the thing this refusal exists to stop shipping. A
candidate with exactly one bump is exempt - there, the bump's own
changeset already is the summary."""
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
if not current.is_prerelease:
fail(
f"{version_mod.VERSION_FILENAME} is already {current}, a release - there is no running "
"candidate to fix. `version release` only ends a pre-release phase that `version bump` "
"started."
)
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - the candidate has nowhere to be fixed")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a release. "
"Fix whichever is wrong."
)
return
section = version_mod.changes_section(text, current) or ""
bump_count = len(version_mod.bump_entries(section))
summary_chars = len(re.sub(r"\s+", "", version_mod.summary_prose(section)))
if bump_count >= 2 and summary_chars < version_mod.SUMMARY_MIN_CHARS:
fail(
f"This candidate collected {bump_count} bumps, but its {version_mod.CHANGES_FILENAME} "
"entry carries no summary above the individual changesets - write a short paragraph "
"(a few sentences on what this release is about) right below the bump list before "
"releasing. `version regrade` (no arguments) shows the bump list first, if that helps."
)
return
new_version = current.base
if dry_run:
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
return
version_mod.write_version(new_version)
changes.write_text(
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
encoding="utf-8",
)
success(
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
"is what release.yml reacts to."
)
@app.command("regrade")
def regrade_command(
indices: Optional[list[int]] = typer.Argument(
None,
help="1-based positions in the rendered bump list to regrade (see the bare listing). "
"Omit to just list.",
),
impact: Optional[str] = typer.Option(
None, "--impact", help="high|medium|low - required together with indices"
),
):
"""List the running candidate's bump titles with their impact grade, or
change one or more of them in a single call.
Positions are `version_mod.bump_entries`'s own rendered order - grouped
High before Medium before Low, chronological within a grade - as it
stands *before* this call: `wikitool version regrade 3 7 --impact high`
regrades both against today's list in one read, not #3 first and then #7
against whatever regrading #3 produced. Run the bare command again
afterwards to see the result and its new numbering.
The bare listing is read-only and, like `version notes`, exempt from the
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
like `version bump`, because that is what it does."""
try:
current = version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there is nothing to regrade")
return
text = changes.read_text(encoding="utf-8")
top_entry = version_mod.top_changes_version(text)
if top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a regrade. "
"Fix whichever is wrong."
)
return
section = version_mod.changes_section(text, current) or ""
entries = version_mod.bump_entries(section)
if not entries:
fail(f"{current}'s {version_mod.CHANGES_FILENAME} entry has no bump list to regrade.")
return
if not indices:
for position, (level, bump_title) in enumerate(entries, start=1):
typer.echo(f"{position}. [{level}] {bump_title}")
return
if impact is None:
fail("--impact is required when regrading - pass one of high/medium/low.")
return
if impact not in version_mod.IMPACT_LEVELS:
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
return
updates = {index: impact for index in indices}
try:
new_text = version_mod.regrade(text, current, updates)
except VersionError as exc:
fail(str(exc))
return
changes.write_text(new_text, encoding="utf-8")
success(f"Regraded {len(indices)} bump title(s) to {impact} impact.")