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:
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal file
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal 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
|
||||
@@ -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."
|
||||
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"
|
||||
elif preview or not sys.stdin.isatty():
|
||||
body, entry = _entry_for(message or f"Release {new_version}")
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -88,9 +88,8 @@ 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():
|
||||
effective_message = changelog_message.strip()
|
||||
if isinstance(changelog_message, str) and changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
package_name = resolve_package_name(paths)
|
||||
|
||||
|
||||
44
tests/unit/pkgmgr/actions/release/test_changelog_lint.py
Normal file
44
tests/unit/pkgmgr/actions/release/test_changelog_lint.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user