fix(release): lint the changelog entry inside the file it lands in
The entry was linted alone, so rules that compare it with the rest of CHANGELOG.md never fired. infinito-nexus 16.0.0 passed this check with "-" bullets on top of older "*" entries, and the repository's own markdown lint then reported 400 MD004 findings and refused the push. update_changelog now also lints the merged file and reports only the findings the existing file does not already produce (lint_changelog_context), so pre-existing issues are not blamed on the entry. markdownlint-cli2 runs with --no-globs, so a target config with its own globs no longer lints the whole repository and blocks a release on unrelated files. Without markdownlint-cli2 the built-in check now requires "-" list markers (MD004 dash), the strictest style. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,13 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
_HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
|
_HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||||
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
||||||
_FINDING = re.compile(r"\bMD\d{3}\b")
|
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||||
|
_LOCATION = re.compile(r"^.*?:\d+(?::\d+)?\s+")
|
||||||
|
_NON_DASH_ITEM = re.compile(r"^\s*[*+]\s")
|
||||||
_MULTI_BLANK = re.compile(r"\n{3,}")
|
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||||
_ENTRY_LEVEL = 3
|
_ENTRY_LEVEL = 3
|
||||||
|
|
||||||
@@ -59,6 +62,35 @@ def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
|
|||||||
return _builtin_lint(document)
|
return _builtin_lint(document)
|
||||||
|
|
||||||
|
|
||||||
|
def lint_changelog_context(reference_path: str, existing: str, merged: str) -> list[str]:
|
||||||
|
"""Return the findings *merged* has beyond those *existing* already has.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_path: path of the target CHANGELOG.md; its directory supplies
|
||||||
|
the markdownlint config.
|
||||||
|
existing: the target file before the entry is inserted.
|
||||||
|
merged: the target file with the entry inserted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Findings of *merged* that *existing* does not produce. Empty when
|
||||||
|
``markdownlint-cli2`` is unavailable.
|
||||||
|
"""
|
||||||
|
if not shutil.which("markdownlint-cli2"):
|
||||||
|
return []
|
||||||
|
known = Counter(
|
||||||
|
_LOCATION.sub("", finding)
|
||||||
|
for finding in (_markdownlint(reference_path, existing) if existing.strip() else [])
|
||||||
|
)
|
||||||
|
new: list[str] = []
|
||||||
|
for finding in _markdownlint(reference_path, merged):
|
||||||
|
key = _LOCATION.sub("", finding)
|
||||||
|
if known[key]:
|
||||||
|
known[key] -= 1
|
||||||
|
else:
|
||||||
|
new.append(finding)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||||
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
||||||
fd, tmp_path = tempfile.mkstemp(
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
@@ -69,7 +101,7 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
|
|||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
handle.write(document)
|
handle.write(document)
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["markdownlint-cli2", name],
|
["markdownlint-cli2", "--no-globs", name],
|
||||||
cwd=directory,
|
cwd=directory,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -96,6 +128,8 @@ def _builtin_lint(document: str) -> list[str]:
|
|||||||
errors.append(f"line {number}: trailing whitespace (MD009)")
|
errors.append(f"line {number}: trailing whitespace (MD009)")
|
||||||
if "`" in line:
|
if "`" in line:
|
||||||
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
||||||
|
if _NON_DASH_ITEM.match(line):
|
||||||
|
errors.append(f"line {number}: list items use '-' (MD004)")
|
||||||
if "\n\n\n" in document:
|
if "\n\n\n" in document:
|
||||||
errors.append("multiple consecutive blank lines (MD012)")
|
errors.append("multiple consecutive blank lines (MD012)")
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import date
|
|||||||
|
|
||||||
from .changelog_lint import (
|
from .changelog_lint import (
|
||||||
ChangelogLintError,
|
ChangelogLintError,
|
||||||
|
lint_changelog_context,
|
||||||
lint_changelog_entry,
|
lint_changelog_entry,
|
||||||
transform_changelog_message,
|
transform_changelog_message,
|
||||||
)
|
)
|
||||||
@@ -86,9 +87,22 @@ def update_changelog(
|
|||||||
except EOFError:
|
except EOFError:
|
||||||
print("[INFO] Re-opening the editor so you can fix the entry...")
|
print("[INFO] Re-opening the editor so you can fix the entry...")
|
||||||
|
|
||||||
|
changelog = ""
|
||||||
|
if os.path.exists(changelog_path):
|
||||||
|
try:
|
||||||
|
with open(changelog_path, encoding="utf-8") as f:
|
||||||
|
changelog = f.read()
|
||||||
|
except (OSError, UnicodeDecodeError) as exc:
|
||||||
|
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||||
|
|
||||||
|
def _findings_for(entry: str) -> list[str]:
|
||||||
|
return lint_changelog_entry(changelog_path, entry) or lint_changelog_context(
|
||||||
|
changelog_path, changelog, _insert_after_h1(changelog, entry)
|
||||||
|
)
|
||||||
|
|
||||||
if message is not None:
|
if message is not None:
|
||||||
body, entry = _entry_for(message)
|
body, entry = _entry_for(message)
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = _findings_for(entry)
|
||||||
if findings:
|
if findings:
|
||||||
_print_findings(findings)
|
_print_findings(findings)
|
||||||
raise ChangelogLintError(
|
raise ChangelogLintError(
|
||||||
@@ -106,7 +120,7 @@ def update_changelog(
|
|||||||
)
|
)
|
||||||
raw = _open_editor_for_changelog(attempt, rejected)
|
raw = _open_editor_for_changelog(attempt, rejected)
|
||||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = _findings_for(entry)
|
||||||
if not findings:
|
if not findings:
|
||||||
break
|
break
|
||||||
_print_findings(findings)
|
_print_findings(findings)
|
||||||
@@ -114,14 +128,6 @@ def update_changelog(
|
|||||||
rejected = findings
|
rejected = findings
|
||||||
_wait_for_reader()
|
_wait_for_reader()
|
||||||
|
|
||||||
changelog = ""
|
|
||||||
if os.path.exists(changelog_path):
|
|
||||||
try:
|
|
||||||
with open(changelog_path, encoding="utf-8") as f:
|
|
||||||
changelog = f.read()
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
|
||||||
|
|
||||||
new_changelog = _insert_after_h1(changelog, entry)
|
new_changelog = _insert_after_h1(changelog, entry)
|
||||||
|
|
||||||
print("\n================ CHANGELOG ENTRY ================")
|
print("\n================ CHANGELOG ENTRY ================")
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.files import changelog_lint
|
||||||
from pkgmgr.actions.release.files.changelog_lint import (
|
from pkgmgr.actions.release.files.changelog_lint import (
|
||||||
|
lint_changelog_context,
|
||||||
lint_changelog_entry,
|
lint_changelog_entry,
|
||||||
transform_changelog_message,
|
transform_changelog_message,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.actions.release.files.changelog_md import _insert_after_h1
|
||||||
|
|
||||||
|
_OLD_ASTERISK_FILE = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet\n"
|
||||||
|
|
||||||
|
|
||||||
class TestTransformChangelogMessage(unittest.TestCase):
|
class TestTransformChangelogMessage(unittest.TestCase):
|
||||||
@@ -38,16 +45,42 @@ class TestTransformChangelogMessage(unittest.TestCase):
|
|||||||
|
|
||||||
class TestLintChangelogEntry(unittest.TestCase):
|
class TestLintChangelogEntry(unittest.TestCase):
|
||||||
def test_clean_entry_has_no_findings(self) -> None:
|
def test_clean_entry_has_no_findings(self) -> None:
|
||||||
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
|
entry = "## [1.0.0] - 2026-01-01\n\n- a clean bullet\n\n"
|
||||||
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
||||||
|
|
||||||
def test_a_structured_message_survives_its_own_transform(self) -> None:
|
def test_a_structured_message_survives_its_own_transform(self) -> None:
|
||||||
body = transform_changelog_message(
|
body = transform_changelog_message(
|
||||||
"# Security\n\n* a bullet\n\n## Fixed\n\n* another bullet"
|
"# Security\n\n- a bullet\n\n## Fixed\n\n- another bullet"
|
||||||
)
|
)
|
||||||
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
|
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
|
||||||
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
||||||
|
|
||||||
|
def test_the_builtin_check_refuses_a_non_dash_list_marker(self) -> None:
|
||||||
|
entry = "## [1.0.0] - 2026-01-01\n\n* a bullet\n\n"
|
||||||
|
with patch.object(changelog_lint.shutil, "which", return_value=None):
|
||||||
|
findings = lint_changelog_entry("CHANGELOG.md", entry)
|
||||||
|
self.assertTrue(any("MD004" in finding for finding in findings), findings)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("markdownlint-cli2"), "markdownlint-cli2 is not installed")
|
||||||
|
class TestLintChangelogContext(unittest.TestCase):
|
||||||
|
def test_a_marker_that_breaks_the_files_consistency_is_reported(self) -> None:
|
||||||
|
entry = "## [1.1.0] - 2026-02-01\n\n- new bullet\n\n"
|
||||||
|
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
|
||||||
|
findings = lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged)
|
||||||
|
self.assertTrue(any("MD004" in finding for finding in findings), findings)
|
||||||
|
|
||||||
|
def test_a_finding_the_file_already_has_is_not_blamed_on_the_entry(self) -> None:
|
||||||
|
existing = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet \n"
|
||||||
|
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
|
||||||
|
merged = _insert_after_h1(existing, entry)
|
||||||
|
self.assertEqual(lint_changelog_context("CHANGELOG.md", existing, merged), [])
|
||||||
|
|
||||||
|
def test_a_consistent_entry_has_no_findings(self) -> None:
|
||||||
|
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
|
||||||
|
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
|
||||||
|
self.assertEqual(lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -313,13 +313,13 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
def test_update_changelog_does_not_inject_a_leading_bullet(self) -> None:
|
def test_update_changelog_does_not_inject_a_leading_bullet(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")
|
||||||
update_changelog(path, "1.2.3", message="* Provided bullet", preview=False)
|
update_changelog(path, "1.2.3", message="- Provided bullet", preview=False)
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
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:
|
def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -327,7 +327,7 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
update_changelog(
|
update_changelog(
|
||||||
path,
|
path,
|
||||||
"1.2.3",
|
"1.2.3",
|
||||||
message="# Summary\n\n* uses `foo` tool",
|
message="# Summary\n\n- uses `foo` tool",
|
||||||
preview=False,
|
preview=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user