Skip to content

Commit 3fb8b1d

Browse files
TMHSDigitalclaude
andcommitted
fix(release-doc-sync): keep [Unreleased] on top and match version headings only
The CHANGELOG stub is now inserted before the first versioned heading, so a Keep a Changelog [Unreleased] section stays above it. Idempotency checks for a "## [X.Y.Z]" heading instead of any "[X.Y.Z]" substring, so prose mentions and footer link refs no longer suppress the stub. Removes the unused _CHANGELOG_VERSION_RE and writes LF line endings. Closes #108 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent f42c8d9 commit 3fb8b1d

2 files changed

Lines changed: 67 additions & 11 deletions

File tree

‎scripts/release_doc_sync/sync.py‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,16 @@ def changed(self) -> bool:
8585
# ---------------------------------------------------------------------------
8686

8787

88-
_CHANGELOG_VERSION_RE = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE)
89-
_CHANGELOG_FIRST_RELEASE_RE = re.compile(r"^##\s*\[", re.MULTILINE)
88+
# First versioned release heading. ``## [Unreleased]`` is deliberately not
89+
# matched: in Keep a Changelog order it stays above every release.
90+
_CHANGELOG_FIRST_RELEASE_RE = re.compile(r"^##\s*\[\d", re.MULTILINE)
91+
92+
93+
def _has_release_heading(text: str, version: str) -> bool:
94+
"""True only when ``## [version]`` exists as a heading. Mentions in
95+
prose or footer link refs (``[X.Y.Z]: url``) do not count."""
96+
pattern = re.compile(r"^##\s*\[" + re.escape(version) + r"\]", re.MULTILINE)
97+
return pattern.search(text) is not None
9098

9199

92100
def sync_changelog(
@@ -109,12 +117,14 @@ def sync_changelog(
109117
110118
See [release notes](https://github.com/<repo>/releases/tag/vX.Y.Z) for details.
111119
112-
Idempotency: if ``## [X.Y.Z]`` already appears anywhere in the file,
113-
the function returns ``idempotent`` without touching the file.
120+
Idempotency: if a ``## [X.Y.Z]`` heading already exists, the function
121+
returns ``idempotent`` without touching the file. Prose mentions and
122+
footer link refs are ignored.
114123
115-
Insertion point: immediately before the first existing ``## [`` release
116-
section. If no release sections exist yet, append after the file's
117-
existing content (one trailing blank line is normalized).
124+
Insertion point: immediately before the first existing versioned
125+
``## [N...`` release section, which keeps ``## [Unreleased]`` on top.
126+
If no release sections exist yet, append after the file's existing
127+
content (one trailing blank line is normalized).
118128
119129
Footer link refs (``[X.Y.Z]: <url>``) are intentionally NOT auto-added.
120130
Steam's Keep-a-Changelog format uses them, but maintaining the link
@@ -128,7 +138,7 @@ def sync_changelog(
128138

129139
text = path.read_text(encoding="utf-8")
130140

131-
if f"[{plugin_version}]" in text:
141+
if _has_release_heading(text, plugin_version):
132142
return FileResult(
133143
path=path,
134144
action="idempotent",
@@ -154,7 +164,7 @@ def sync_changelog(
154164
text = text + "\n"
155165
new_text = text + stub
156166

157-
path.write_text(new_text, encoding="utf-8")
167+
path.write_text(new_text, encoding="utf-8", newline="\n")
158168
return FileResult(
159169
path=path,
160170
action="inserted",
@@ -237,7 +247,7 @@ def _rewrite_version_line(match: re.Match) -> str:
237247
detail="no version patterns matched or already aligned",
238248
)
239249

240-
path.write_text(text, encoding="utf-8")
250+
path.write_text(text, encoding="utf-8", newline="\n")
241251
return FileResult(
242252
path=path,
243253
action="updated",
@@ -301,7 +311,7 @@ def sync_roadmap(path: Path, *, plugin_version: str) -> FileResult:
301311
+ f"{match.group(1)}v{plugin_version}"
302312
+ text[match.end() :]
303313
)
304-
path.write_text(new_text, encoding="utf-8")
314+
path.write_text(new_text, encoding="utf-8", newline="\n")
305315
return FileResult(
306316
path=path,
307317
action="updated",

‎tests/test_release_doc_sync.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,52 @@ def test_missing_file_returns_missing(self, tmp_path: Path):
260260
assert result.changed is False
261261
assert not path.exists()
262262

263+
def test_inserts_below_unreleased_section(self, tmp_path: Path):
264+
path = tmp_path / "CHANGELOG.md"
265+
path.write_text(
266+
"# Changelog\n\n"
267+
"## [Unreleased]\n\n- pending change\n\n"
268+
"## [1.0.0] - 2026-01-01\n\n- first\n",
269+
encoding="utf-8",
270+
)
271+
result = sync_changelog(
272+
path,
273+
plugin_version="1.1.0",
274+
repository="TMHSDigital/X",
275+
release_date="2026-04-25",
276+
)
277+
assert result.action == "inserted"
278+
text = path.read_text(encoding="utf-8")
279+
assert text.index("## [Unreleased]") < text.index("## [1.1.0]") < text.index("## [1.0.0]")
280+
281+
def test_prose_and_link_refs_do_not_count_as_present(self, tmp_path: Path):
282+
path = tmp_path / "CHANGELOG.md"
283+
path.write_text(
284+
"# Changelog\n\nUpgrading to [2.0.0] soon.\n\n"
285+
"## [1.0.0] - 2026-01-01\n\n- first\n\n"
286+
"[2.0.0]: https://example.invalid/v2.0.0\n",
287+
encoding="utf-8",
288+
)
289+
result = sync_changelog(
290+
path,
291+
plugin_version="2.0.0",
292+
repository="TMHSDigital/X",
293+
release_date="2026-04-25",
294+
)
295+
assert result.action == "inserted"
296+
assert "## [2.0.0] - 2026-04-25" in path.read_text(encoding="utf-8")
297+
298+
def test_writes_lf_line_endings(self, tmp_path: Path):
299+
path = tmp_path / "CHANGELOG.md"
300+
path.write_bytes(b"# Changelog\n\n## [1.0.0] - 2026-01-01\n")
301+
sync_changelog(
302+
path,
303+
plugin_version="1.0.1",
304+
repository="TMHSDigital/X",
305+
release_date="2026-04-25",
306+
)
307+
assert b"\r\n" not in path.read_bytes()
308+
263309

264310
# ---------------------------------------------------------------------------
265311
# CLAUDE tests

0 commit comments

Comments
 (0)