18ae28f918
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""`wikitool version` - report, bump, and check the stack's version.
|
|
|
|
Three jobs that all hang off one number (see `chemenu/version.py` for what
|
|
that number means):
|
|
|
|
- `version show` answers "which stack is this instance running", offline, from
|
|
`VERSION` plus the release stamp `dist export` writes.
|
|
- `version bump` moves it, and writes the changelog *heading* that has to
|
|
accompany the move - the same structure-by-tool/prose-by-author split as
|
|
`new`. `docs verify` then holds the two together.
|
|
- `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 CHANGES.md entry"),
|
|
no_migration: Optional[str] = typer.Option(
|
|
None,
|
|
"--no-migration",
|
|
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
|
|
),
|
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
|
):
|
|
"""Raise the stack version and open its `CHANGES.md` entry.
|
|
|
|
Writes `VERSION` and inserts the entry's heading, date and author - the
|
|
entry's body stays the author's to write, the same way `new` produces
|
|
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
|
the two agree, so a bump with no entry cannot reach a release.
|
|
|
|
A bump that crosses the compatibility boundary additionally requires a
|
|
migration document for the new version, or `--no-migration "<reason>"`.
|
|
An instance learning that it must migrate, with nothing telling it how, is
|
|
the gap this closes."""
|
|
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()
|
|
new_version = current.bumped(selected[0])
|
|
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")
|
|
existing = version_mod.top_changes_version(text)
|
|
if existing is not None and existing >= new_version:
|
|
fail(
|
|
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
|
|
f"than {new_version} - bump past it, or fix the changelog"
|
|
)
|
|
return
|
|
|
|
author = config.default_author() or "unknown"
|
|
crossing = new_version.compat_key != current.compat_key
|
|
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
|
|
|
if crossing and not no_migration:
|
|
from chemenu import kb_state
|
|
|
|
if not any(m.target == new_version 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}.\n"
|
|
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<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,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
success(
|
|
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
|
|
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
|
|
)
|