fix(release/changelog): insert new entry under H1 instead of above it

update_changelog used to blindly prepend the new ## [version] entry to
the entire file body. When CHANGELOG.md leads with a # Changelog H1
the result was:

  ## [new] - YYYY-MM-DD
  ...
  # Changelog
  ## [old] - ...

which trips markdownlint MD041 (first-line-h1) and MD012
(no-multiple-blanks), and reads as if the document were two stacked
changelogs.

The new _insert_after_h1 helper:

* Synthesises # Changelog when the file is empty.
* Inserts the entry between the existing H1 (plus any intro prose)
  and the first existing ## release entry.
* For legacy headerless files (file starts with ##) prepends the
  synthesised H1 + entry, so the resulting document is also
  MD041-clean.

Tests cover the three layouts (empty, H1+existing-entries,
legacy-headerless). All 61 release-unit tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:17:35 +02:00
parent a4099717be
commit ece575cc73
2 changed files with 78 additions and 12 deletions

View File

@@ -1,11 +1,50 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from .editor import _open_editor_for_changelog from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
def _insert_after_h1(existing: str, entry: str) -> str:
"""Place *entry* after the H1 (and any intro prose), above the first H2.
If the file has no H1 we synthesise ``# Changelog`` so the resulting
document is markdown-lint-clean (MD041 first-line-h1).
If the file has no H2 yet we append *entry* after the H1 block.
Existing behaviour for legacy headerless files (file starts with
``## ``) is preserved: *entry* is prepended unchanged.
"""
if not existing.strip():
return f"# Changelog\n\n{entry}"
if not H1_RE.search(existing):
# Legacy layout: file starts with `## [version]` and has no H1.
# Synthesise the H1 so the merged file is lint-clean.
return f"# Changelog\n\n{entry}{existing.lstrip()}"
# File has an H1. Find the first H2 (existing release section).
h2_match = H2_RE.search(existing)
if h2_match is None:
# H1 + optional intro but no release entries yet — append entry
# after a single blank line.
suffix = (
""
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
return f"{existing}{suffix}{entry}"
# Insert new entry just before the first H2.
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
tail = existing[h2_match.start() :]
return f"{head}{entry}{tail}"
def update_changelog( def update_changelog(
changelog_path: str, changelog_path: str,
@@ -13,9 +52,11 @@ def update_changelog(
message: Optional[str] = None, message: Optional[str] = None,
preview: bool = False, preview: bool = False,
) -> str: ) -> str:
""" """Insert a new release entry into CHANGELOG.md.
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message. The entry is placed after the documents H1 heading (creating one if
missing) and above any existing release entries, so the result stays
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
""" """
today = date.today().isoformat() today = date.today().isoformat()
@@ -32,8 +73,7 @@ def update_changelog(
else: else:
message = editor_message message = editor_message
header = f"## [{new_version}] - {today}\n" entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
header += f"\n* {message}\n\n"
if os.path.exists(changelog_path): if os.path.exists(changelog_path):
try: try:
@@ -45,14 +85,14 @@ def update_changelog(
else: else:
changelog = "" changelog = ""
new_changelog = header + "\n" + changelog if changelog else header new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================") print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip()) print(entry.rstrip())
print("=================================================\n") print("=================================================\n")
if preview: if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md") print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message return message
with open(changelog_path, "w", encoding="utf-8") as f: with open(changelog_path, "w", encoding="utf-8") as f:

View File

@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
class TestUpdateChangelog(unittest.TestCase): class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None: def test_update_changelog_creates_file_with_h1_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md") path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path)) self.assertFalse(os.path.exists(path))
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertIn("## [1.2.3]", content) # New file must lead with an H1 so markdownlint MD041 is happy.
self.assertTrue(content.startswith("# Changelog\n\n## [1.2.3]"))
self.assertIn("First release", content) self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None: def test_update_changelog_inserts_below_existing_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
existing = "# Changelog\n\n## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(existing)
update_changelog(path, "1.0.0", message="Second release", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# H1 still on top, new entry above the existing one.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
# Exactly one H1.
self.assertEqual(
content.count("\n# Changelog\n") + content.startswith("# Changelog\n"), 1
)
# Old entry still present, after the new one.
self.assertLess(
content.index("## [1.0.0]"),
content.index("## [0.1.0]"),
)
def test_update_changelog_legacy_headerless_gets_h1_synthesized(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md") path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@@ -281,7 +306,8 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertTrue(content.startswith("## [1.0.0]")) # An H1 is added so MD041 is satisfied even for legacy files.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content) self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_preview_does_not_write(self) -> None: def test_update_changelog_preview_does_not_write(self) -> None: