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:
@@ -1,11 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
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(
|
||||
changelog_path: str,
|
||||
@@ -13,9 +52,11 @@ def update_changelog(
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Prepend a new release section to CHANGELOG.md with the new version,
|
||||
current date, and a message.
|
||||
"""Insert a new release entry into CHANGELOG.md.
|
||||
|
||||
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()
|
||||
|
||||
@@ -32,8 +73,7 @@ def update_changelog(
|
||||
else:
|
||||
message = editor_message
|
||||
|
||||
header = f"## [{new_version}] - {today}\n"
|
||||
header += f"\n* {message}\n\n"
|
||||
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
|
||||
|
||||
if os.path.exists(changelog_path):
|
||||
try:
|
||||
@@ -45,14 +85,14 @@ def update_changelog(
|
||||
else:
|
||||
changelog = ""
|
||||
|
||||
new_changelog = header + "\n" + changelog if changelog else header
|
||||
new_changelog = _insert_after_h1(changelog, entry)
|
||||
|
||||
print("\n================ CHANGELOG ENTRY ================")
|
||||
print(header.rstrip())
|
||||
print(entry.rstrip())
|
||||
print("=================================================\n")
|
||||
|
||||
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
|
||||
|
||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||
|
||||
@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(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:
|
||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||
self.assertFalse(os.path.exists(path))
|
||||
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
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)
|
||||
|
||||
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:
|
||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||
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:
|
||||
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)
|
||||
|
||||
def test_update_changelog_preview_does_not_write(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user