1b0158fc8d
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
543 lines
22 KiB
Python
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.")
|