Files
chemenu/tools/chemenu/commands/version_cmd.py
T
torben d29d400dd3
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
Files changed:
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- README.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/kb_state.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-03 22:19:41 +02:00

389 lines
15 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.
- `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
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)",
),
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."""
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
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 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,
),
encoding="utf-8",
)
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
f"`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."""
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
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."
)