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
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user