feat: changelog entries stay compact - one paragraph per topic, a size budget in docs verify and version release, version bump names required migration documents (#184)
CI / verify (push) Successful in 6m0s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 33s

Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-build/SKILL.md
- instructions/dev/version-parts.md
- instructions/migrate-corpus.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-06 16:48:02 +02:00
1 parent 2ddcd6be19
commit 044943ae51
12 files changed
+718 -170

No files matched your search

+124
View File
@@ -623,6 +623,130 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
assert docs_verify.check_breaking_change_for_boundary() == []
def _required_migration(root, target: str = "2.0.0", obligation: str = "required") -> str:
path = root / "instructions" / "migrations" / f"{target}-retype.md"
path.write_text(
f"---\ntype: types/instruction.md\nname: {target}-retype\ndescription: Retype.\n"
f"manual: true\nmigrates_to: {target}\nobligation: {obligation}\n---\n",
encoding="utf-8",
)
return f"instructions/migrations/{target}-retype.md"
def test_this_repos_migration_line_names_its_documents():
assert docs_verify.check_migration_line_names_documents() == []
def test_a_crossing_entry_that_does_not_name_its_migration_document_is_reported(
tmp_path, monkeypatch
):
"""The document's bare existence satisfies `check_migration_for_boundary`;
the release notes still have to point an operator at it."""
from chemenu import version as version_mod
root = _boundary_tree(
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
)
document = _required_migration(root)
assert docs_verify.check_migration_for_boundary() == []
issues = docs_verify.check_migration_line_names_documents()
assert any(document in issue and "2.0.0-beta.1" in issue for issue in issues)
def test_a_crossing_entry_naming_its_migration_document_passes(tmp_path, monkeypatch):
root = _boundary_tree(
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker="**Migration:** required - instructions/migrations/2.0.0-retype.md\n\n",
)
_required_migration(root)
assert docs_verify.check_migration_line_names_documents() == []
def test_an_offered_migration_document_need_not_be_named(tmp_path, monkeypatch):
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
_required_migration(root, obligation="offered")
assert docs_verify.check_migration_line_names_documents() == []
def test_a_compatible_entry_is_not_held_to_naming_documents(tmp_path, monkeypatch):
root = _boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
_required_migration(root, target="1.5.0")
assert docs_verify.check_migration_line_names_documents() == []
# --- the changelog budget (Gitea #184) --------------------------------------
def _budget_tree(tmp_path, monkeypatch, sections: str, older: str = "Body.\n"):
"""A changelog whose topmost entry carries `sections` below its summary,
above an older entry carrying `older`."""
_versioned_tree(
tmp_path, monkeypatch, "2.0.0\n",
"# Changelog\n\nPreamble.\n\n---\n\n"
f"## 2.0.0 - 2026-10-06 - New\n\n**Author:** Someone\n\nSummary.\n{sections}\n---\n\n"
f"## 1.0.0 - 2026-09-01 - Old\n\n**Author:** Someone\n\n{older}",
)
def test_this_repos_newest_changelog_entry_is_inside_its_budget():
"""The 8.0.0 entry, condensed by hand to 22 KB in 15 one-paragraph
sections (largest 895 bytes), is the reference shape - it must pass."""
assert docs_verify.check_changelog_budget() == []
def test_an_entry_over_32000_bytes_is_reported(tmp_path, monkeypatch):
sections = "".join(f"\n### Topic {n}\n\n{'x' * 900}\n" for n in range(40))
_budget_tree(tmp_path, monkeypatch, sections)
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "2.0.0" in issues[0] and "32000" in issues[0]
def test_a_section_with_two_paragraphs_is_reported(tmp_path, monkeypatch):
_budget_tree(tmp_path, monkeypatch, "\n### Two\n\nFirst.\n\nSecond.\n")
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "### Two" in issues[0] and "2 paragraphs" in issues[0]
def test_a_paragraph_over_1000_bytes_is_reported_in_bytes_not_characters(tmp_path, monkeypatch):
"""501 characters, 1002 bytes: the limit counts what Gitea's column counts."""
_budget_tree(tmp_path, monkeypatch, f"\n### Long\n\n{'ä' * 501}\n")
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "### Long" in issues[0] and "1002 bytes" in issues[0]
def test_a_paragraph_of_exactly_1000_bytes_passes_and_its_heading_is_not_counted(
tmp_path, monkeypatch
):
long_heading = "A heading that is itself quite long " * 3
_budget_tree(
tmp_path, monkeypatch,
f"\n### {long_heading}\n\n\n{'ä' * 500}\n\n\n### Wrapped\n\nOne paragraph\non two lines.\n",
)
assert docs_verify.check_changelog_budget() == []
def test_an_older_entry_over_both_limits_is_not_reported(tmp_path, monkeypatch):
older = "".join(f"### Old {n}\n\n{'z' * 1500}\n\nSecond paragraph.\n\n" for n in range(30))
_budget_tree(tmp_path, monkeypatch, "\n### Fine\n\nShort.\n", older=older)
assert docs_verify.check_changelog_budget() == []
def test_a_changelog_with_no_versioned_entry_has_no_budget_to_check(tmp_path, monkeypatch):
"""A distributed instance's stub carries no versioned entry at all."""
_versioned_tree(tmp_path, monkeypatch, "8.0.0\n", "# Changelog\n\nStub.\n")
assert docs_verify.check_changelog_budget() == []
def test_verify_raises_when_the_changelog_is_over_budget(monkeypatch):
monkeypatch.setattr(docs_verify, "check_changelog_budget", lambda: ["too long"])
with pytest.raises(typer.Exit):
docs_verify.verify()
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
monkeypatch.setattr(
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
+170 -2
View File
@@ -654,6 +654,64 @@ def test_release_passes_with_a_single_bump_and_no_summary(tree):
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
# --- version release: the size budget (Gitea #184) --------------------------
def _append_sections(tree: Path, *bodies: str) -> None:
"""Append `### Topic N` sections to the open candidate's entry, the way
an author writes them below the summary."""
path = tree / "CHANGES.md"
text = path.read_text(encoding="utf-8")
top_start = text.index("## 1.1.0-beta.")
next_entry = text.index("\n---\n", top_start)
sections = "".join(f"\n### Topic {n}\n\n{body}\n" for n, body in enumerate(bodies, start=1))
path.write_text(text[:next_entry] + sections + text[next_entry:], encoding="utf-8")
@pytest.mark.parametrize(
"bodies",
[
("x" * 900,) * 40, # every paragraph fits, the entry does not
("First paragraph.\n\nSecond paragraph.",),
("y" * 1001,),
],
ids=["entry-over-32000", "two-paragraphs", "paragraph-over-1000"],
)
def test_release_refuses_an_entry_over_budget_and_writes_nothing(tree, bodies):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, *bodies)
version_before = (tree / "VERSION").read_bytes()
changes_before = (tree / "CHANGES.md").read_bytes()
with pytest.raises(typer.Exit) as excinfo:
version_cmd.release_command(title=None, dry_run=False)
assert excinfo.value.exit_code == 1
assert (tree / "VERSION").read_bytes() == version_before
assert (tree / "CHANGES.md").read_bytes() == changes_before
def test_release_dry_run_also_refuses_an_entry_over_budget(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, "y" * 1001)
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=True)
def test_release_passes_an_entry_inside_its_budget(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, "y" * 1000, "One paragraph,\nwrapped over two lines.")
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
# --- version bump ----------------------------------------------------------
@@ -827,6 +885,9 @@ def test_migration_required_retracts_the_no_migration_line(tree):
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER not in changes
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-retype.md"
]
assert version_mod.BREAKING_CHANGE_MARKER in changes # untouched by the retraction
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
@@ -876,7 +937,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
)
def test_insert_changes_entry_migration_required_clears_the_no_migration_line():
def test_insert_changes_entry_migration_documents_replace_the_no_migration_line():
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",
@@ -884,13 +945,120 @@ def test_insert_changes_entry_migration_required_clears_the_no_migration_line():
)
second = version_mod.insert_changes_entry(
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
migration_required=True,
migration_documents=["instructions/migrations/2.0.0-retype.md"],
)
assert version_mod.MIGRATION_NONE_MARKER not in second
assert "**Migration:** required - instructions/migrations/2.0.0-retype.md\n" in second
assert version_mod.BREAKING_CHANGE_MARKER in second
assert "the feed moved" in second
# --- version bump: naming the migration documents (Gitea #184) --------------
def _migration_lines(tree: Path) -> list[str]:
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, version_mod.top_changes_version(changes))
return [line for line in section.splitlines() if line.startswith(version_mod.MIGRATION_MARKER)]
def test_bump_names_a_required_migration_document_and_a_second_bump_keeps_it(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
first = _migration_lines(tree)
assert first == ["**Migration:** required - instructions/migrations/2.0.0-retype.md"]
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert _migration_lines(tree) == first
def test_bump_lists_several_migration_documents_sorted(tree):
_migration_document(tree, "2.0.0", slug="zeta")
_migration_document(tree, "2.0.0", slug="alpha")
_migration_document(tree, "3.0.0", slug="later") # targets another base: not named
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-alpha.md, "
"instructions/migrations/2.0.0-zeta.md"
]
def test_a_later_bump_names_a_document_written_after_the_crossing(tree):
"""A plain bump finding a required document replaces `none required` -
the two lines exclude each other, and the document is the fact."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-retype.md"
]
def test_a_bump_drops_the_line_once_its_document_no_longer_targets_the_base(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
(tree / "instructions" / "migrations" / "2.0.0-retype.md").unlink()
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert _migration_lines(tree) == []
assert "\n\n\n" not in changes
def test_no_migration_is_refused_while_a_required_document_targets_the_base(tree):
_migration_document(tree, "2.0.0")
before = (tree / "CHANGES.md").read_text(encoding="utf-8")
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert (tree / "CHANGES.md").read_text(encoding="utf-8") == before
def test_an_offered_migration_document_is_not_named_as_required(tree):
"""An `obligation: offered` document is an improvement an instance may
decline - 6.0.0 shipped one beside a `none required` line, correctly."""
(tree / "instructions" / "migrations" / "2.0.0-optional.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-optional\n"
"description: Optional.\nmanual: true\nmigrates_to: 2.0.0\n"
"migration_kind: assisted\nobligation: offered\n---\n\n# M\n",
encoding="utf-8",
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
assert _migration_lines(tree) == [f"{version_mod.MIGRATION_NONE_MARKER} - kb/ untouched"]
# --- version bump: the breaking-change note --------------------------------