Skip to content

Commit 5eb5064

Browse files
committed
Changed: improve internal release scripts and expand test coverage
Refine SemVer regex to support digit-prefixed alphanumeric prerelease identifiers. Ensure GitHub anchors match exact version headers to avoid stable/prerelease collisions. Add unit tests for changelog post-processing and enable type-checking for the release toolset.
1 parent 8aa129a commit 5eb5064

4 files changed

Lines changed: 98 additions & 6 deletions

File tree

justfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ help-workflows:
243243
@echo " just plot-vs-linalg # Plot Criterion results (CSV + SVG)"
244244
@echo " just plot-vs-linalg-readme # Plot + update README benchmark table"
245245
@echo ""
246+
@echo "Changelog & releases:"
247+
@echo " just changelog # Regenerate CHANGELOG.md from full history"
248+
@echo " just changelog-unreleased <ver> # Prepend unreleased changes for a version"
249+
@echo " just tag <ver> # Create annotated tag from CHANGELOG.md"
250+
@echo " just tag-force <ver> # Recreate an existing tag"
251+
@echo ""
246252
@echo "Setup:"
247253
@echo " just setup # Setup project environment (depends on setup-tools)"
248254
@echo " just setup-tools # Install/verify external tooling (best-effort)"
@@ -333,7 +339,7 @@ python-sync: _ensure-uv
333339

334340
python-typecheck: python-sync
335341
uv run ty check scripts/
336-
uv run mypy scripts/criterion_dim_plot.py
342+
uv run mypy scripts/criterion_dim_plot.py scripts/tag_release.py scripts/postprocess_changelog.py scripts/subprocess_utils.py
337343

338344
# Setup
339345
setup: setup-tools

scripts/tag_release.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,17 @@
4444
# ---------------------------------------------------------------------------
4545

4646
# SemVer 2.0.0 strict with required 'v' prefix
47+
# Alphanumeric prerelease identifier: any [0-9A-Za-z-]+ containing at least one
48+
# non-digit. This permits identifiers like "1a" that start with a digit but are
49+
# not purely numeric (SemVer 2.0.0 §9).
50+
_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)"
4751
_SEMVER_RE = re.compile(
4852
r"^v"
4953
r"(0|[1-9]\d*)\."
5054
r"(0|[1-9]\d*)\."
5155
r"(0|[1-9]\d*)"
52-
r"(?:-(?:(?:0|[1-9]\d*)|(?:[A-Za-z-][0-9A-Za-z-]*))"
53-
r"(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*"
56+
rf"(?:-(?:(?:0|[1-9]\d*)|{_ALNUM_ID})"
57+
rf"(?:\.(?:(?:0|[1-9]\d*)|{_ALNUM_ID}))*"
5458
r")?"
5559
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
5660
)
@@ -94,8 +98,7 @@ def extract_changelog_section(changelog: Path, version: str) -> str:
9498
LookupError: If the version section is not found or empty.
9599
"""
96100
content = changelog.read_text(encoding="utf-8")
97-
escaped = re.escape(version)
98-
header_re = re.compile(rf"^##\s*\[?v?{escaped}\]?(?:$|\s|\()", re.MULTILINE)
101+
header_re = _version_header_re(version)
99102

100103
lines = content.split("\n")
101104
section: list[str] = []
@@ -166,11 +169,17 @@ def _get_repo_url() -> str:
166169
return raw # best-effort fallback
167170

168171

172+
def _version_header_re(version: str) -> re.Pattern[str]:
173+
"""Build the header regex for *version*, matching ``extract_changelog_section``."""
174+
return re.compile(rf"^##\s*\[?v?{re.escape(version)}\]?(?:$|\s|\()")
175+
176+
169177
def _github_anchor(changelog: Path, version: str) -> str:
170178
"""Build a GitHub-compatible heading anchor (matches ``github-slugger``)."""
179+
header_re = _version_header_re(version)
171180
try:
172181
for line in changelog.read_text(encoding="utf-8").splitlines():
173-
if line.startswith("## ") and (f"v{version}" in line or f"[{version}]" in line):
182+
if header_re.match(line):
174183
heading = line.removeprefix("## ").strip()
175184
# Strip inline-link markup [text](url) → text
176185
heading = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", heading)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Tests for postprocess_changelog.py — trailing blank line hygiene."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from postprocess_changelog import postprocess
8+
9+
if TYPE_CHECKING:
10+
from pathlib import Path
11+
12+
13+
class TestPostprocess:
14+
def test_strips_trailing_blank_lines(self, tmp_path: Path) -> None:
15+
f = tmp_path / "CHANGELOG.md"
16+
f.write_text("# Changelog\n\n- Item\n\n\n\n", encoding="utf-8")
17+
18+
postprocess(f)
19+
20+
assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"
21+
22+
def test_preserves_single_trailing_newline(self, tmp_path: Path) -> None:
23+
f = tmp_path / "CHANGELOG.md"
24+
f.write_text("# Changelog\n\n- Item\n", encoding="utf-8")
25+
26+
postprocess(f)
27+
28+
assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"
29+
30+
def test_adds_trailing_newline_if_missing(self, tmp_path: Path) -> None:
31+
f = tmp_path / "CHANGELOG.md"
32+
f.write_text("# Changelog\n\n- Item", encoding="utf-8")
33+
34+
postprocess(f)
35+
36+
assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"
37+
38+
def test_preserves_internal_blank_lines(self, tmp_path: Path) -> None:
39+
content = "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n\n\n\n"
40+
f = tmp_path / "CHANGELOG.md"
41+
f.write_text(content, encoding="utf-8")
42+
43+
postprocess(f)
44+
45+
result = f.read_text(encoding="utf-8")
46+
# Internal blank lines preserved, only trailing ones stripped
47+
assert result == "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n"
48+
49+
def test_single_newline_file(self, tmp_path: Path) -> None:
50+
f = tmp_path / "CHANGELOG.md"
51+
f.write_text("\n", encoding="utf-8")
52+
53+
postprocess(f)
54+
55+
assert f.read_text(encoding="utf-8") == "\n"
56+
57+
def test_empty_file(self, tmp_path: Path) -> None:
58+
f = tmp_path / "CHANGELOG.md"
59+
f.write_text("", encoding="utf-8")
60+
61+
postprocess(f)
62+
63+
assert f.read_text(encoding="utf-8") == "\n"

scripts/tests/test_tag_release.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ class TestValidateSemver:
3636
"v1.2.3-alpha",
3737
"v1.2.3+build.42",
3838
"v1.2.3-beta.1+build.123",
39+
"v1.0.0-1a", # digit-prefixed alphanumeric prerelease
40+
"v1.0.0-0a", # leading zero OK when not purely numeric
41+
"v1.0.0-1a.2b", # dot-separated digit-prefixed IDs
3942
],
4043
)
4144
def test_valid_versions(self, version: str) -> None:
@@ -50,6 +53,7 @@ def test_valid_versions(self, version: str) -> None:
5053
"v01.2.3", # leading zero
5154
"v1.02.3", # leading zero
5255
"v1.2.03", # leading zero
56+
"v1.0.0-01", # leading zero in purely numeric prerelease
5357
"vfoo", # garbage
5458
"", # empty
5559
],
@@ -175,6 +179,16 @@ def test_fallback_when_not_found(self, tmp_path: Path) -> None:
175179
changelog.write_text("# Changelog\n", encoding="utf-8")
176180
assert _github_anchor(changelog, "9.9.9") == "v999"
177181

182+
def test_does_not_match_prerelease_heading(self, tmp_path: Path) -> None:
183+
"""Looking for 1.0.0 must not match ## [1.0.0-rc.1]."""
184+
changelog = tmp_path / "CHANGELOG.md"
185+
changelog.write_text(
186+
"# Changelog\n\n## [1.0.0-rc.1] - 2025-01-01\n\n- Item\n",
187+
encoding="utf-8",
188+
)
189+
# Should fall back since no exact 1.0.0 heading exists
190+
assert _github_anchor(changelog, "1.0.0") == "v100"
191+
178192

179193
# ---------------------------------------------------------------------------
180194
# Tag size limit handling

0 commit comments

Comments
 (0)