feat(release): lint and normalise the changelog message interactively

The release flow now sanitises the release message into the changelog house style and validates it before writing: a leading '#' heading becomes a bold line, inline `code` becomes *italic*, and the resulting entry is checked with markdownlint-cli2 (using the target repo's own config, i.e. the same rules it enforces) or a built-in fallback. In an interactive run the editor re-opens with the findings until the entry is clean; with --message it transforms then aborts on any finding. The editor's ignore-marker moved from '#' to ';' so heading lines survive as content.

debian/changelog and the RPM %changelog mirror the transformed body, keeping all three changelogs consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-Birkenbach
2026-06-28 02:32:53 +02:00
parent 87ee6c66c8
commit e8aed49a4c
6 changed files with 211 additions and 26 deletions

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
_FINDING = re.compile(r"\bMD\d{3}\b")
_MULTI_BLANK = re.compile(r"\n{3,}")
class ChangelogLintError(RuntimeError):
"""Raised when a changelog entry cannot be made markdown-lint clean."""
def transform_changelog_message(text: str) -> str:
"""Normalise a free-form release message into the changelog house style.
A leading ``#`` heading becomes a bold line of its own (markdown
headings inside an entry body would collide with the ``## [version]``
structure), and inline ``code`` spans become ``*italic*`` so the entry
stays free of backticks.
"""
text = _INLINE_CODE.sub(r"*\1*", text)
out: list[str] = []
for line in text.split("\n"):
heading = _HEADING.match(line)
if heading:
if out and out[-1].strip():
out.append("")
out.append(f"**{heading.group(1).strip()}**")
out.append("")
else:
out.append(line)
joined = _MULTI_BLANK.sub("\n\n", "\n".join(out))
return joined.strip()
def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
"""Return markdown-lint findings for *entry* (empty list when clean).
The entry is checked inside a minimal ``# Changelog`` document so the
result reflects only the entry, not pre-existing issues in the target
file. ``markdownlint-cli2`` is used when available (picking up the
target repository's own config, i.e. the same rules the repo enforces);
otherwise a small built-in check runs.
"""
document = f"# Changelog\n\n{entry.strip()}\n"
if shutil.which("markdownlint-cli2"):
return _markdownlint(reference_path, document)
return _builtin_lint(document)
def _markdownlint(reference_path: str, document: str) -> list[str]:
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
fd, tmp_path = tempfile.mkstemp(
suffix=".md", prefix=".pkgmgr-changelog-", dir=directory
)
name = os.path.basename(tmp_path)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(document)
proc = subprocess.run(
["markdownlint-cli2", name],
cwd=directory,
capture_output=True,
text=True,
)
if proc.returncode == 0:
return []
output = f"{proc.stdout or ''}{proc.stderr or ''}"
findings = [
line.strip().replace(name, "changelog entry")
for line in output.splitlines()
if _FINDING.search(line)
]
return findings or [output.strip() or "markdown-lint reported an error"]
finally:
try:
os.remove(tmp_path)
except OSError:
pass
def _builtin_lint(document: str) -> list[str]:
errors: list[str] = []
for number, line in enumerate(document.split("\n"), start=1):
if line != line.rstrip():
errors.append(f"line {number}: trailing whitespace (MD009)")
if "`" in line:
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
if "\n\n\n" in document:
errors.append("multiple consecutive blank lines (MD012)")
return errors

View File

@@ -2,9 +2,15 @@ from __future__ import annotations
import os import os
import re import re
import sys
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from .changelog_lint import (
ChangelogLintError,
lint_changelog_entry,
transform_changelog_message,
)
from .editor import _open_editor_for_changelog from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE) H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
@@ -60,31 +66,49 @@ def update_changelog(
""" """
today = date.today().isoformat() today = date.today().isoformat()
if message is None: def _entry_for(raw: str) -> tuple[str, str]:
if preview: body = transform_changelog_message(raw).strip() or f"Release {new_version}"
message = "Automated release." return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
else:
print( def _print_findings(findings: list[str]) -> None:
"\n[INFO] No release message provided, opening editor for changelog entry...\n" print("\n[ERROR] Changelog entry is not markdown-lint clean:")
for finding in findings:
print(f" - {finding}")
print()
if message is not None:
body, entry = _entry_for(message)
findings = lint_changelog_entry(changelog_path, entry)
if findings:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
) )
editor_message = _open_editor_for_changelog() elif preview or not sys.stdin.isatty():
if not editor_message: body, entry = _entry_for(message or f"Release {new_version}")
message = "Automated release."
else: else:
message = editor_message attempt: Optional[str] = None
while True:
body = message.strip() if message and message.strip() else f"Release {new_version}." print(
entry = f"## [{new_version}] - {today}\n\n{body}\n\n" "\n[INFO] Provide the changelog entry — a leading '#' becomes "
"bold, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = lint_changelog_entry(changelog_path, entry)
if not findings:
break
_print_findings(findings)
attempt = body
print("[INFO] Re-opening the editor so you can fix the entry...")
changelog = ""
if os.path.exists(changelog_path): if os.path.exists(changelog_path):
try: try:
with open(changelog_path, "r", encoding="utf-8") as f: with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read() changelog = f.read()
except Exception as exc: except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = _insert_after_h1(changelog, entry) new_changelog = _insert_after_h1(changelog, entry)
@@ -94,10 +118,10 @@ def update_changelog(
if preview: if preview:
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md") print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message return body
with open(changelog_path, "w", encoding="utf-8") as f: with open(changelog_path, "w", encoding="utf-8") as f:
f.write(new_changelog) f.write(new_changelog)
print(f"Updated CHANGELOG.md with version {new_version}") print(f"Updated CHANGELOG.md with version {new_version}")
return message return body

View File

@@ -16,9 +16,10 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
) as tmp: ) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
tmp.write( tmp.write(
"# Write the changelog entry for this release.\n" "; Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n" "; Lines starting with ';' are ignored.\n"
"# Empty result will fall back to a generic message.\n\n" "; A leading '#' becomes bold; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n"
) )
if initial_message: if initial_message:
tmp.write(initial_message.strip() + "\n") tmp.write(initial_message.strip() + "\n")
@@ -41,5 +42,5 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
except OSError: except OSError:
pass pass
lines = [line for line in content.splitlines() if not line.strip().startswith("#")] lines = [line for line in content.splitlines() if not line.strip().startswith(";")]
return "\n".join(lines).strip() return "\n".join(lines).strip()

View File

@@ -88,8 +88,7 @@ def _release_impl(
print("[INFO] No RPM spec file found. Skipping spec version update.") print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str): if isinstance(changelog_message, str) and changelog_message.strip():
if changelog_message.strip():
effective_message = changelog_message.strip() effective_message = changelog_message.strip()
package_name = resolve_package_name(paths) package_name = resolve_package_name(paths)

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import unittest
from pkgmgr.actions.release.files.changelog_lint import (
lint_changelog_entry,
transform_changelog_message,
)
class TestTransformChangelogMessage(unittest.TestCase):
def test_heading_becomes_bold(self) -> None:
out = transform_changelog_message("# Title\n\nbody")
self.assertIn("**Title**", out)
self.assertNotIn("# Title", out)
def test_multi_hash_heading_becomes_bold(self) -> None:
self.assertEqual(
transform_changelog_message("### Sub heading"), "**Sub heading**"
)
def test_inline_code_becomes_italic(self) -> None:
out = transform_changelog_message("use `pkgmgr release` now")
self.assertEqual(out, "use *pkgmgr release* now")
self.assertNotIn("`", out)
def test_plain_message_is_unchanged(self) -> None:
self.assertEqual(transform_changelog_message("just text"), "just text")
def test_no_heading_or_backtick_survives(self) -> None:
out = transform_changelog_message("# Heading\n\n* item with `code`")
self.assertNotIn("`", out)
for line in out.split("\n"):
self.assertFalse(line.lstrip().startswith("#"))
class TestLintChangelogEntry(unittest.TestCase):
def test_clean_entry_has_no_findings(self) -> None:
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
if __name__ == "__main__": # pragma: no cover
unittest.main()

View File

@@ -321,6 +321,24 @@ class TestUpdateChangelog(unittest.TestCase):
self.assertIn("\n\n* Provided bullet\n", content) self.assertIn("\n\n* Provided bullet\n", content)
self.assertNotIn("* * Provided bullet", content) self.assertNotIn("* * Provided bullet", content)
def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
update_changelog(
path,
"1.2.3",
message="# Summary\n\n* uses `foo` tool",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("**Summary**", content)
self.assertIn("*foo*", content)
self.assertNotIn("# Summary", content)
self.assertNotIn("`foo`", content)
def test_update_changelog_preview_does_not_write(self) -> None: def test_update_changelog_preview_does_not_write(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")