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)
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:
1 parent
2ddcd6be19
commit
044943ae51
12 files changed
+718
-170
No files matched your search
@@ -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"]
|
||||
|
||||
@@ -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 --------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user