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 re
import sys
from datetime import date
from typing import Optional
from .changelog_lint import (
ChangelogLintError,
lint_changelog_entry,
transform_changelog_message,
)
from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
@@ -60,31 +66,49 @@ def update_changelog(
"""
today = date.today().isoformat()
if message is None:
if preview:
message = "Automated release."
else:
print(
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
def _entry_for(raw: str) -> tuple[str, str]:
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
def _print_findings(findings: list[str]) -> None:
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()
if not editor_message:
message = "Automated release."
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
message = editor_message
body = message.strip() if message and message.strip() else f"Release {new_version}."
entry = f"## [{new_version}] - {today}\n\n{body}\n\n"
attempt: Optional[str] = None
while True:
print(
"\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):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = _insert_after_h1(changelog, entry)
@@ -94,10 +118,10 @@ def update_changelog(
if preview:
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:
f.write(new_changelog)
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:
tmp_path = tmp.name
tmp.write(
"# Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n"
"# Empty result will fall back to a generic message.\n\n"
"; Write the changelog entry for this release.\n"
"; Lines starting with ';' are ignored.\n"
"; A leading '#' becomes bold; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
@@ -41,5 +42,5 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
except OSError:
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()

View File

@@ -88,8 +88,7 @@ def _release_impl(
print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
if isinstance(changelog_message, str) and changelog_message.strip():
effective_message = changelog_message.strip()
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.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:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")