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 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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.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")
|
||||||
|
|||||||
Reference in New Issue
Block a user