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:
2026-09-03 22:19:41 +02:00
parent b1883befc7
commit d29d400dd3
21 changed files with 1063 additions and 119 deletions
+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."
)
+18 -1
View File
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
None is a real state, not an error: an instance created before the KB
version existed has content of unknown vintage, and guessing would be
worse than asking (`migrate baseline`).
Refuses a pre-release (`-beta.N`): a content *shape* has no beta channel,
only the machinery does, so a `kb_version` naming one means something
wrote a stack version into this field by hand or by mistake.
"""
state = read_kb_state()
if state is None:
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
raw = state.get("kb_version")
if not raw:
return None
return Version.parse(str(raw))
version = Version.parse(str(raw))
if version.is_prerelease:
raise VersionError(
f"{KB_STATE_FILENAME} names a pre-release kb_version ({version}) - content has no "
"beta channel, only the stack version does"
)
return version
def read_kb_state() -> Optional[dict]:
@@ -171,6 +181,13 @@ def chain(
Targets above the installed machinery are excluded: the instance has no code
for them yet.
`stack_version` must be release-shaped (no `-beta.N`) - pass `.base` when
the installed machinery is a running candidate. A migration document
targets a release (`migrates_to: 4.4.0`), and a candidate's own version
sorts *before* that release (`4.4.0-beta.1 < 4.4.0`), so comparing against
the raw candidate would drop its own target out of the interval right
when the machinery that owes it is installed.
`offered` migrations are deliberately absent. They are not links in the
version chain: declining one leaves the content in a shape the machinery
still accepts, so counting it as owed would make `kb_version` unreachable
+16
View File
@@ -430,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo):
assert state["applied"] == []
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
"""Exporting mid-candidate answers two different questions: the stamp says
what was actually exported (suffix included - "an export says what it
is"), the KB version says what shape the content is built for. A content
shape has no beta channel, so it must be the base."""
from chemenu import kb_state
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
plan = dist_cmd.build_plan()
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
assert stamp["version"] == "0.4.0-beta.2"
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
assert state["kb_version"] == "0.4.0"
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
(repo / "VERSION").unlink()
target = tmp_path / "dist"
+28 -9
View File
@@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for():
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
"""A changelog with `current` as the topmost entry and `previous` as the
last release beneath it. `current` is normally an open candidate
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
release** (`version_mod.last_release`), which skips right past a topmost
entry that is itself already a release (that one's crossing, if any, was
already checked while it was still the open candidate)."""
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
(tmp_path / "CHANGES.md").write_text(
"# Changelog\n\n---\n\n"
@@ -207,28 +213,41 @@ def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: s
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
"""`version check` tells an instance it must migrate; without this, that is
where the trail ends."""
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
issues = docs_verify.check_migration_for_boundary()
assert any("2.0.0" in issue and "must migrate" in issue for issue in issues)
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
assert docs_verify.check_migration_for_boundary() == []
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
"""Regression for finding #2: comparing against the entry *beneath* the
newest one (rather than the last release) would find no boundary between
two betas of the same candidate - and would also, wrongly, re-flag an
already-fixed release forever. Once `current` is itself a release,
`last_release` returns it directly, so there is nothing left to compare."""
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
assert docs_verify.check_migration_for_boundary() == []
assert docs_verify.check_breaking_change_for_boundary() == []
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
from chemenu import version as version_mod
_boundary_tree(
tmp_path, monkeypatch, "2.0.0", "1.4.0",
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
)
assert docs_verify.check_migration_for_boundary() == []
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
"""The document targets the candidate's *base* (`2.0.0`), not its full
pre-release form - matching what `version bump` looks for."""
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
@@ -243,16 +262,16 @@ def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkey
from chemenu import version as version_mod
_boundary_tree(
tmp_path, monkeypatch, "2.0.0", "1.4.0",
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
)
assert docs_verify.check_migration_for_boundary() == []
issues = docs_verify.check_breaking_change_for_boundary()
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
assert docs_verify.check_breaking_change_for_boundary() == []
@@ -260,7 +279,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
from chemenu import version as version_mod
_boundary_tree(
tmp_path, monkeypatch, "2.0.0", "1.4.0",
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
)
assert docs_verify.check_breaking_change_for_boundary() == []
+27
View File
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
def test_a_running_candidate_is_named_as_such(instance):
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
assert "0.2.0-beta.1" in detail
assert "candidate" in detail
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
"""Regression for finding #3: comparing the chain against the raw
candidate would sort `2.0.0` (the migration's target) *before*
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
(config.ROOT / ".wikitool-kb.json").write_text(
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
)
migrations = config.INSTRUCTIONS_DIR / "migrations"
migrations.mkdir(parents=True, exist_ok=True)
(migrations / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "kb-version") == "WARN"
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
def test_no_collections_at_all_fails_structure(instance):
"""Removing one collection is a legitimate state - collections are
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
+24 -1
View File
@@ -11,7 +11,7 @@ import typer
from chemenu import config, kb_state
from chemenu.commands import migrate_cmd
from chemenu.version import Version
from chemenu.version import Version, VersionError
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
@@ -108,6 +108,19 @@ def test_status_lists_the_chain_in_order(instance, capsys):
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
def test_status_chain_still_reaches_a_target_matching_a_running_candidate(instance, capsys):
"""Regression for finding #3: a migration targeting `2.0.0` must still be
owed while `VERSION` is the running candidate `2.0.0-beta.1` - `2.0.0` sorts
*above* its own candidate, so comparing against the raw pre-release would
drop it out of the chain right when the machinery that owes it installs."""
(instance / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
set_kb_version(instance, "1.7.0")
migrate_cmd.status_command(json_out=True)
result = json.loads(capsys.readouterr().out)
assert result["stack_version"] == "2.0.0-beta.1"
assert [m["migrates_to"] for m in result["pending"]] == ["2.0.0"]
def test_list_reports_every_document_sorted_by_target(instance, capsys):
migrate_cmd.list_command(json_out=True)
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
@@ -155,6 +168,16 @@ def test_done_without_a_declared_kb_version_is_refused(instance):
# --- baseline --------------------------------------------------------------
def test_read_kb_version_refuses_a_pre_release(instance):
"""A content shape has no beta channel - only the stack version does."""
set_kb_version(instance, "1.3.1")
(instance / kb_state.KB_STATE_FILENAME).write_text(
json.dumps({"schema": 1, "kb_version": "1.4.0-beta.1", "applied": []}), encoding="utf-8"
)
with pytest.raises(VersionError):
kb_state.read_kb_version()
def test_baseline_declares_the_version_once(instance):
migrate_cmd.baseline_command(version="1.3.1", force=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
+265 -10
View File
@@ -83,6 +83,86 @@ def test_compare_separates_a_compatible_update_from_a_migration(local, latest, s
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
# --- candidates: parsing and ordering ---------------------------------------
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
def test_parse_accepts_a_candidate_suffix(text):
version = Version.parse(text)
assert version.is_prerelease
assert version.beta == int(text.rsplit(".", 1)[1])
def test_a_release_has_no_beta():
version = Version.parse("4.4.0")
assert not version.is_prerelease
assert version.beta is None
@pytest.mark.parametrize(
"lesser,greater",
[
("4.4.0-beta.1", "4.4.0"),
("4.4.0-beta.1", "4.4.0-beta.2"),
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
("4.4.0-beta.9", "4.4.1"),
],
)
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
assert Version.parse(lesser) < Version.parse(greater)
assert Version.parse(greater) > Version.parse(lesser)
def test_base_strips_the_candidate_suffix():
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
def test_bumped_always_returns_a_release_even_from_a_candidate():
"""`bumped()` answers "what would the next fixed version be" - it is
`escalate()` that knows about running candidates."""
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
# --- candidates: escalation --------------------------------------------------
def test_escalate_opens_the_first_candidate_at_beta_one():
release = Version.parse("4.3.3")
candidate = version_mod.escalate(release, release, "minor")
assert str(candidate) == "4.4.0-beta.1"
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
release = Version.parse("4.3.3")
first = version_mod.escalate(release, release, "minor")
second = version_mod.escalate(release, first, "patch")
assert str(second) == "4.4.0-beta.2"
def test_escalate_never_steps_back_down():
release = Version.parse("1.4.0")
major = version_mod.escalate(release, release, "major")
still_major = version_mod.escalate(release, major, "patch")
assert still_major.base == major.base
assert still_major.beta == 2
def test_escalate_raises_the_base_and_resets_the_bump_count():
release = Version.parse("4.3.3")
minor = version_mod.escalate(release, release, "minor")
major = version_mod.escalate(release, minor, "major")
assert str(major) == "5.0.0-beta.1"
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
"""The fresh-distribution edge case: a changelog with no versioned entry at
all opens a candidate straight from `current`, rather than failing."""
fresh = Version.parse("0.1.0")
candidate = version_mod.escalate(None, fresh, "patch")
assert str(candidate) == "0.1.1-beta.1"
def test_a_migration_headline_says_so_rather_than_just_being_louder():
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
assert "migration" in status.headline.lower()
@@ -167,14 +247,75 @@ def test_changes_section_is_none_for_an_undocumented_version():
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
def test_insert_changes_entry_lands_above_the_newest_entry():
def test_last_release_skips_an_open_candidate_above_it():
text = (
CHANGES_HEADER
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
)
assert version_mod.last_release(text) == Version(0, 1, 0)
def test_last_release_is_none_with_no_versioned_entry_at_all():
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
assert version_mod.last_release(text) is None
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
result = version_mod.insert_changes_entry(
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
)
assert result.index("## 0.2.0") < result.index("## 0.1.0")
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
assert "Preamble." in result
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
assert "- Newer" in result # the bump-title list seeds itself with this title
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
def test_insert_changes_entry_updates_an_open_candidate_in_place():
"""The second bump of the same candidate must not open a second entry -
one entry per running candidate, per instructions/dev/version-parts.md."""
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
)
second = version_mod.insert_changes_entry(
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
)
assert second.count("## 0.2.0") == 1
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
assert "- First title" in second
assert "- Second title" in second
assert "## 0.1.0" in second # the older, already-released entry survives untouched
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
breaking_reason="the feed moved", no_migration_reason="kb untouched",
)
second = version_mod.insert_changes_entry(
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
)
assert version_mod.BREAKING_CHANGE_MARKER in second
assert "the feed moved" in second
assert version_mod.MIGRATION_NONE_MARKER in second
assert "kb untouched" in second
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
released = version_mod.release_entry(text, "2026-09-05")
assert "## 0.2.0 - 2026-09-05 - Second title" in released
assert "- First title" in released
assert "- Second title" in released
def test_release_entry_can_replace_the_title():
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
# --- version bump ----------------------------------------------------------
@@ -185,13 +326,28 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
major=False, minor=True, patch=False, title="Something happened",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
assert "## 1.1.0-beta.1 - " in changes
assert "Something happened" in changes
assert "**Author:** Test Author" in changes
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert changes.count("## 1.1.0") == 1
assert "First" in changes and "Second" in changes
def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
@@ -219,9 +375,10 @@ def test_bump_refuses_an_empty_title(tree):
)
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
"""A changelog documenting a version the tree has not reached means
someone edited one of the two by hand; bumping past it would hide that."""
def test_bump_refuses_when_version_and_changelog_disagree(tree):
"""A changelog whose newest entry names a different version than VERSION
means someone edited one of the two by hand; bumping past it would hide
that instead of surfacing it."""
(tree / "CHANGES.md").write_text(
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
)
@@ -258,7 +415,31 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
"""Finding #4: the requirement fires once, at the bump that first escalates
to the boundary; a later bump of the same candidate is not asked again."""
migrations = tree / "instructions" / "migrations"
(migrations / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype every page.\nmanual: true\n"
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
encoding="utf-8",
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Follow-up",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
assert "every page is retyped" in changes
def test_no_migration_records_the_reason_in_the_changelog(tree):
@@ -321,6 +502,65 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
# --- version release --------------------------------------------------------
def test_release_fixes_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
assert "First bump" in changes # kept, since --title was not given
def test_release_can_replace_the_title(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
assert "Summary of both bumps" in changes
# the machine-managed bump list is left as the record of what happened
assert "First bump" in changes
assert "Second bump" in changes
def test_release_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=True)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
def test_release_refuses_when_version_is_already_a_release(tree):
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_release_refuses_when_version_and_changelog_disagree(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=False)
# --- version notes ---------------------------------------------------------
@@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
assert "## 1.0.0" in capsys.readouterr().out
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.notes_command(version=None)
out = capsys.readouterr().out
assert "## 1.1.0-beta.2" in out
assert "First bump" in out and "Second bump" in out
def test_notes_fails_for_a_version_with_no_entry(tree):
with pytest.raises(typer.Exit):
version_cmd.notes_command(version="9.9.9")
+252 -19
View File
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
"""
from __future__ import annotations
import functools
import json
import os
import re
@@ -42,7 +43,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from chemenu import config
from chemenu import blocks, config
VERSION_FILENAME = "VERSION"
CHANGES_FILENAME = "CHANGES.md"
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
PARTS = ("major", "minor", "patch")
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
# second ordering rule everywhere a version is compared - the release feed, the
# migration chain, the compatibility check - to serve a workflow this stack does
# not have.
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
# ordering numeric and total. See "Candidates and releases" below.
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
# every `--title` a candidate has collected across its bumps. Reuses
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
# its rendering, belong here instead.
BUMPS_BLOCK_NAME = "bumps"
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
# Written into a CHANGES.md entry whose version crosses a compatibility
# boundary that needs no content migration. `docs verify` accepts it in place
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
# A changelog entry that names a version. Entries predating versioning start
# with a date instead and are deliberately not matched - they are history, not
# a claim about which version the tree is.
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
class VersionError(ValueError):
@@ -92,23 +105,67 @@ class VersionError(ValueError):
written to be shown to the user verbatim."""
@dataclass(frozen=True, order=True)
@functools.total_ordering
@dataclass(frozen=True)
class Version:
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
(`-beta.N`) between two releases.
**Candidates and releases.** Between two releases the stack carries at
most one running candidate rather than a fresh number per `bump` - see
`instructions/dev/version-parts.md`. `VERSION` holds either a release
(`beta is None`) or a candidate (`beta` is the bump count since the
candidate's base was last raised). `base` strips the suffix; `bumped()`
always returns a release-shaped `Version`, because it answers "what would
the *next fixed* version be", never "what candidate comes next" - that
answer needs `escalate()`, which also knows the last release to escalate
against.
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
a real release after every candidate that shares its base - `4.4.0-beta.1
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
`int` do not compare, and the ordering is inverted relative to field
declaration order anyway. `functools.total_ordering` plus an explicit
`__lt__` is the direct way to say what the ordering actually is.
"""
major: int
minor: int
patch: int
beta: Optional[int] = None
@classmethod
def parse(cls, text: str) -> "Version":
match = _SEMVER_RE.match(text or "")
if not match:
raise VersionError(
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH"
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
"or MAJOR.MINOR.PATCH-beta.N"
)
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
beta = int(match.group(4)) if match.group(4) is not None else None
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
def __str__(self) -> str: # noqa: D105 - obvious
return f"{self.major}.{self.minor}.{self.patch}"
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
def _sort_key(self) -> tuple[int, int, int, int, int]:
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
def __lt__(self, other: "Version") -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._sort_key() < other._sort_key()
@property
def is_prerelease(self) -> bool:
return self.beta is not None
@property
def base(self) -> "Version":
"""This version with any candidate suffix stripped - what it would be
once fixed. A no-op on a version that is already a release."""
return Version(self.major, self.minor, self.patch)
def bumped(self, part: str) -> "Version":
if part == "major":
@@ -127,6 +184,10 @@ class Version:
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
non-zero component, so it compares by all three - during `0.0.x`
every release is a breaking one, which is what that range means.
Computed over major/minor/patch alone, i.e. over the **base**: a
candidate's pre-release suffix carries no compatibility information of
its own, it is the base that will be released that does.
"""
components = (self.major, self.minor, self.patch)
for index, component in enumerate(components):
@@ -135,6 +196,44 @@ class Version:
return components
def _stage_between(reference: Version, base: Version) -> Optional[str]:
"""Which part `base` has escalated past `reference` on, or None if equal.
Both are release-shaped (no beta): `reference` is the last real release,
`base` is a candidate's base. Exactly one of major/minor/patch differs,
because `bumped()` always resets everything to the right of the part it
raises - so the leftmost differing component *is* the stage.
"""
for part in PARTS:
if getattr(reference, part) != getattr(base, part):
return part
return None
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
"""The next candidate: `current` escalated by `part` against `last_release`,
max-wins.
A running candidate never steps back down: bumping `--patch` on a MINOR
candidate only advances its bump count (`beta`), it does not lower the
base. `last_release=None` is the fresh-distribution edge case - a
changelog with no versioned entry at all - where there is nothing to
escalate against, so the candidate's base is simply `current` bumped by
`part`; see instructions/dev/version-parts.md for why that is not an
error.
"""
if part not in _STAGE_RANK:
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
reference = last_release if last_release is not None else (
current.base if current.is_prerelease else current
)
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
new_base = reference.bumped(new_stage)
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
@dataclass(frozen=True)
class UpdateStatus:
"""The answer `version check` reports. `state` is the actionable part:
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
return Version.parse(match.group(1))
def last_release(text: str) -> Optional[Version]:
"""The newest entry that is a **release**, not a running candidate, or
`None` if the changelog names no release at all yet.
Entries are inserted newest-first (see `insert_changes_entry`), so the
first non-pre-release heading found scanning top-down is the last release
- whether or not the very top entry is an open candidate sitting above it.
A changelog with no versioned entry (a fresh distribution) answers `None`,
which `escalate()` treats as its own edge case rather than an error.
"""
for match in _CHANGES_ENTRY_RE.finditer(text):
version = Version.parse(match.group(1))
if not version.is_prerelease:
return version
return None
def changes_section(text: str, version: Version) -> Optional[str]:
"""The body of one version's entry, heading included, ready to become
release notes.
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
return None
def _bumps_block(titles: list[str]) -> str:
lines = "\n".join(f"- {title}" for title in titles)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
def _bump_titles(section: str) -> list[str]:
match = _BUMPS_RE.search(section)
if not match:
return []
return [
line[2:].strip()
for line in match.group(1).strip("\n").splitlines()
if line.strip().startswith("- ")
]
def _set_marker_line(section: str, marker: str, line: str) -> str:
"""Add or replace the one-line `marker ...` paragraph in `section`.
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
if pattern.search(section):
return pattern.sub(line, section, count=1)
anchor = section.find(_BUMPS_CLOSE)
if anchor != -1:
insert_at = section.find("\n", anchor)
insert_at = insert_at + 1 if insert_at != -1 else len(section)
else:
insert_at = len(section)
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
def _entry_span(text: str) -> tuple[int, int]:
"""Start/end offsets of the topmost entry, heading included."""
match = re.search(r"^## ", text, re.MULTILINE)
if not match:
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
start = match.start()
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
end = start + 1 + following.start() if following else len(text)
return start, end
def _update_open_candidate(
text: str,
version: Version,
date: str,
title: str,
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`title` to its machine-managed bump list, and set the breaking/no-migration
lines only where this call supplies them - see `insert_changes_entry`."""
start, end = _entry_span(text)
section = text[start:end]
heading_match = _CHANGES_ENTRY_RE.match(section)
if not heading_match:
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
return text[:start] + section + text[end:]
def insert_changes_entry(
text: str,
version: Version,
@@ -351,18 +542,35 @@ def insert_changes_entry(
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
) -> str:
"""Add a heading for `version` above the newest existing entry.
"""Open a new entry above the newest existing one, or - when the topmost
entry is still an open candidate (a pre-release heading) - update that
entry in place instead.
Only the skeleton: heading, date, author, and - when a compatibility
boundary is crossed - the line saying what breaks, plus the line saying no
content has to change where that applies. The entry's actual content is
written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
`version bump` always lands on a candidate (see `escalate`); only
`version release` fixes one, and it edits the heading directly rather than
through this path (`version_cmd.release_command`), which is what makes "is
the topmost heading still a pre-release" the right test for "is a
candidate still open" here.
The break comes first: it is what an operator reading the release notes has
to act on, and the migration line only qualifies it.
A fresh entry gets the skeleton only: heading, date, author, the
machine-managed bump-title list (started with this one title, for a
candidate), and - when a compatibility boundary is crossed - the line
saying what breaks, plus the line saying no content has to change where
that applies. The break comes first: it is what an operator reading the
release notes has to act on, and the migration line only qualifies it. The
entry's actual prose is written afterwards by whoever made the change,
which is also why `bump` refuses to invent a title.
"""
top = top_changes_version(text)
if top is not None and top.is_prerelease:
return _update_open_candidate(
text, version, date, title,
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if version.is_prerelease:
lines += [_bumps_block([title]), ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason:
@@ -372,3 +580,28 @@ def insert_changes_entry(
if anchor:
return text[: anchor.start()] + entry + text[anchor.start():]
return text.rstrip() + "\n\n---\n\n" + entry
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
today's heading, keeping the previous title unless `title` overrides it.
Leaves the rest of the entry - the bump-title list included - untouched:
it is the record of what happened across the candidate's life, and a
release call has no reason to discard it. `version_cmd.release_command`
is the only caller; it has already checked the topmost entry names a
pre-release, so a non-pre-release version reaching here is a caller bug.
"""
start, end = _entry_span(text)
section = text[start:end]
heading_match = _CHANGES_ENTRY_RE.match(section)
if not heading_match:
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
current = Version.parse(heading_match.group(1))
rest = heading_match.group(2) or ""
_, _, existing_title = rest.partition(" - ")
new_title = title if title is not None else existing_title
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
return text[:start] + section + text[end:]