feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s

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
This commit is contained in:
torben committed 2026-09-03 22:19:41 +02:00
1 parent b1883befc7
commit d29d400dd3
21 files changed
+1063 -119

No files matched your search

+16 -1
View File
@@ -73,6 +73,15 @@ DIST_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "dist_templates"
# read server is part of what an instance *has*, even though its dependency is
# optional. A distribution whose server is present but undocumented is one
# whose operator finds the module by reading the source.
#
# `DEVELOPMENT.md` is deliberately **absent** from this tuple, unlike every
# other root doc above. It documents the release workflow (`version bump` ->
# `version release` -> `publish` -> CI tags) and points at `instructions/dev/`,
# which this same function excludes wholesale a few lines down - a distributed
# instance has no release workflow, no CI and no issue board, so it has
# nothing for that document to describe. Do not "fix" this by adding it back:
# a root file absent from ROOT_FILES is silently skipped by every export, and
# that silence is the correct behaviour here, not a gap.
ROOT_FILES = (
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
".gitignore", "VERSION",
@@ -400,8 +409,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
# machinery expects - which is exactly what makes the initial declaration
# safe to write here rather than leaving it to `migrate baseline`. Only an
# instance predating this file has to answer that question by hand.
#
# `.base`, not the raw `VERSION`: a content shape has no beta channel
# (`kb_state.read_kb_version` refuses one), so exporting mid-candidate
# still declares the release the content is shaped for, not the candidate
# in progress. The stamp below carries the honest, suffix-inclusive value -
# the two files answer different questions.
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
kb_state.render_kb_state(version_mod.read_version(), [])
kb_state.render_kb_state(version_mod.read_version().base, [])
)
# Last, so it can digest everything above it. It is the one file in the
+20 -23
View File
@@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]:
This is the check that makes `version bump` more than a convenience: a
version raised with nothing written about it would ship a release whose
notes describe the previous one. A changelog with *no* versioned entry at
all is fine - that is a fresh distribution, and this repo's own pre-
versioning history, neither of which claims to describe the current
version.
notes describe the previous one. `VERSION` may name a running candidate
(`-beta.N`) rather than a release - `Version.parse`/equality read the
suffix like any other component, so a candidate is compared exactly like a
release here. A changelog with *no* versioned entry at all is fine - that
is a fresh distribution, and this repo's own pre-versioning history,
neither of which claims to describe the current version.
"""
version_path = config.ROOT / version_mod.VERSION_FILENAME
if not version_path.is_file():
@@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]:
return []
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
"""The version named by the second-newest versioned entry, or None."""
seen = [
version_mod.Version.parse(match.group(1))
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
]
return seen[1] if len(seen) > 1 else None
def check_migration_for_boundary() -> list[str]:
"""A version that crosses the compatibility boundary must say how to cross it.
@@ -501,9 +494,12 @@ def check_migration_for_boundary() -> list[str]:
document targeting it, or an explicit statement in its changelog entry that
no content has to change.
Only the newest entry is checked. Older boundaries were either satisfied
when they were written or cannot be fixed retroactively, and re-reporting
them forever would make the check noise.
Only the newest entry is checked, against the **last release** rather than
the entry beneath it - between two candidates of the same running upgrade
(`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and
comparing to the entry beneath would find none even when the candidate
genuinely crosses one relative to what is actually installed anywhere. See
instructions/dev/version-parts.md.
"""
from chemenu import kb_state
@@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]:
text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text)
previous = _second_changes_version(text)
previous = version_mod.last_release(text)
if current is None or previous is None:
return [] # the first versioned entry has no predecessor to cross from
return [] # no release recorded yet to cross from (fresh distribution)
if current.compat_key == previous.compat_key:
return []
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
return []
if any(m.target == current for m in kb_state.load_migrations()):
if any(m.target == current.base for m in kb_state.load_migrations()):
return []
return [
@@ -544,8 +540,9 @@ def check_breaking_change_for_boundary() -> list[str]:
import name or flag - satisfies that check and still leaves every existing
instance with something to do by hand.
Only the newest entry is checked, for the same reason: older crossings are
history, and re-reporting them forever would make the check noise.
Only the newest entry is checked, against the **last release** - see
`check_migration_for_boundary` for why the entry beneath it is the wrong
comparison once a candidate can span more than one bump.
"""
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
version_path = config.ROOT / version_mod.VERSION_FILENAME
@@ -554,9 +551,9 @@ def check_breaking_change_for_boundary() -> list[str]:
text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text)
previous = _second_changes_version(text)
previous = version_mod.last_release(text)
if current is None or previous is None:
return [] # the first versioned entry has no predecessor to cross from
return [] # no release recorded yet to cross from (fresh distribution)
if current.compat_key == previous.compat_key:
return []
+3 -2
View File
@@ -383,7 +383,8 @@ def check_stack_version() -> Check:
)
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
return Check("stack-version", "OK", f"{current} ({origin})")
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
def check_kb_version() -> Check:
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
"never lagged behind its machinery",
)
if kb_version < stack:
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack)
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
if pending:
return Check(
"kb-version", "WARN",
+3 -3
View File
@@ -160,7 +160,7 @@ def status_command(
)
return
pending = kb_state.chain(migrations, kb_version, stack)
pending = kb_state.chain(migrations, kb_version, stack.base)
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
divergent = kb_state.divergent_files()
@@ -271,7 +271,7 @@ def done_command(
)
return
expected = kb_state.next_link(migrations, kb_version, stack)
expected = kb_state.next_link(migrations, kb_version, stack.base)
if expected is None:
fail(
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
@@ -297,7 +297,7 @@ def done_command(
return
kb_state.write_kb_state(target, applied)
remaining = kb_state.chain(migrations, target, stack)
remaining = kb_state.chain(migrations, target, stack.base)
success(
f"Content is now {target} ({expected.name}). "
+ (
+120 -35
View File
@@ -1,13 +1,17 @@
"""`wikitool version` - report, bump, and check the stack's version.
"""`wikitool version` - report, bump, release, and check the stack's version.
Three jobs that all hang off one number (see `chemenu/version.py` for what
that number means):
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` 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 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
@@ -188,34 +192,36 @@ 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"),
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 a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
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 this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
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 the stack version and open its `CHANGES.md` entry.
"""Raise or continue the running candidate, and open or update 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.
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 that crosses the compatibility boundary - one whose 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 version or `--no-migration "<reason>"`. An instance
learning that it must migrate, with nothing telling it what broke or how to
cross, is the gap these close. Which part to pass stays a judgment call
this command does not make - it enforces only that a crossing says what it
costs."""
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")
@@ -226,7 +232,6 @@ def bump_command(
try:
current = version_mod.read_version()
new_version = current.bumped(selected[0])
except VersionError as exc:
fail(str(exc))
return
@@ -236,19 +241,29 @@ def bump_command(
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:
top_entry = version_mod.top_changes_version(text)
if top_entry is not None and top_entry != current:
fail(
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
f"than {new_version} - bump past it, or fix the changelog"
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 = new_version.compat_key != current.compat_key
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 breaking:
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 "
@@ -265,14 +280,14 @@ def bump_command(
)
return
if crossing and not no_migration:
if crossing and not was_already_crossing and not no_migration:
from chemenu import kb_state
if not any(m.target == new_version for m in kb_state.load_migrations()):
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}.\n"
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
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>".'
)
@@ -298,6 +313,76 @@ def bump_command(
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."
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."
)