Skip to content

Commit 64790bb

Browse files
authored
ci: enforce immutable published changelog sections (#321)
Fixes #306 - restore the tagged 0.4.3 changelog section and move post-release notes to Unreleased - compare every published section with its matching release tag - fetch full tag history in changelog-validation workflows - cover edited sections and unavailable/shallow tags Focused changelog tests and Ruff checks pass locally.
1 parent a825597 commit 64790bb

6 files changed

Lines changed: 121 additions & 14 deletions

File tree

.github/workflows/docs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ jobs:
5555
steps:
5656
- name: Check out source
5757
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
58+
with:
59+
fetch-depth: 0
5860

5961
- name: Set up Python
6062
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0

.github/workflows/package.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ jobs:
5959
steps:
6060
- name: Check out source
6161
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
62+
with:
63+
fetch-depth: 0
6264

6365
- name: Set up Python
6466
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -195,6 +197,8 @@ jobs:
195197
steps:
196198
- name: Check out source
197199
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
200+
with:
201+
fetch-depth: 0
198202

199203
- name: Set up Python
200204
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0

.github/workflows/tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ jobs:
2929
shell: bash
3030
steps:
3131
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32+
with:
33+
fetch-depth: 0
3234
- name: Set up Python
3335
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
3436
with:

CHANGELOG.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ and versions are tracked in the repo-root `VERSION` file.
2424
apply identical bounded width handling to Rich and plain renderers.
2525
- Make managed metadata and index replacements atomic, with bounded retries
2626
for transient Windows file-sharing locks.
27+
- Select platform-aware cache roots (`XDG_CACHE_HOME`, macOS Caches, and
28+
Windows `LOCALAPPDATA`) and normalize home-relative paths across separators.
29+
- Add README health and support badges for CI, downstream consumers, PyPI, and
30+
supported Python versions.
31+
- Enforce the portable `0` through `255` process exit-code contract for command
32+
return values, rejecting booleans and out-of-range integers consistently.
33+
- Add Click 8.5 compatibility coverage across the dependency matrix and
34+
supported OS test lanes; the core dependency window now permits `<8.6`.
2735

2836
## [0.4.3] - 2026-08-29
2937

@@ -60,9 +68,6 @@ boundary. Existing Click and Typer command trees remain supported.
6068

6169
### Fixed
6270

63-
- Enforce the portable `0` through `255` process exit-code contract for command
64-
return values, rejecting booleans and out-of-range integers consistently.
65-
6671
- Keep the Typer adapter compatible with Typer 0.27.2's vendored exit
6772
exception layout and validate that release in the compatibility matrix.
6873
- Apply the documented count-only 20-bundle retention default to implicit JSON
@@ -75,9 +80,6 @@ boundary. Existing Click and Typer command trees remain supported.
7580

7681
### Added
7782

78-
- Click 8.5 compatibility coverage across the dependency matrix and supported
79-
OS test lanes; the core dependency window now permits `<8.6`.
80-
8183
- Automate GitHub Releases from matching version tags with reviewed
8284
distributions, checksums, SBOM metadata, and generated comparison notes.
8385
- Publish a generated dependency and platform compatibility dashboard linked
@@ -229,7 +231,6 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
229231

230232
### Changed
231233

232-
- Add README health and support badges for CI, downstream consumers, PyPI, and supported Python versions.
233234
- Normalize command returns, Click errors, aborts, interrupts, `SystemExit`, and
234235
unexpected exceptions through one core outcome model and clean `run_app()`
235236
process boundary.
@@ -289,8 +290,6 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
289290

290291
### Changed
291292

292-
- Select platform-aware cache roots (`XDG_CACHE_HOME`, macOS Caches, and
293-
Windows `LOCALAPPDATA`) and normalize home-relative paths across separators.
294293
- Make `base_cli.App()` use the consumer-neutral profile by default.
295294
- Move manifest discovery, implicit configuration, owner-aware runtime layout,
296295
and history persistence out of the generic package. Consumers now provide
@@ -324,6 +323,10 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
324323
- Initialized the repository with the Base-managed repo baseline.
325324
- Added the guarded package build, artifact validation, and protected
326325
TestPyPI/PyPI publication workflow.
326+
- Exposed `base_cli.__version__` from the repository and installed package
327+
version contract.
328+
- Pinned the build backend to metadata compatible with the bundled publication
329+
action and made license-file validation portable across setuptools versions.
327330

328331
[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.4.3...HEAD
329332
[0.4.3]: https://github.com/basefoundry/base-cli/compare/v0.4.2...v0.4.3
@@ -332,7 +335,3 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
332335
[0.4.0]: https://github.com/basefoundry/base-cli/compare/v0.3.0...v0.4.0
333336
[0.3.0]: https://github.com/basefoundry/base-cli/compare/v0.2.0...v0.3.0
334337
[0.2.0]: https://github.com/basefoundry/base-cli/releases/tag/v0.2.0
335-
- Exposed `base_cli.__version__` from the repository and installed package
336-
version contract.
337-
- Pinned the build backend to metadata compatible with the bundled publication
338-
action and made license-file validation portable across setuptools versions.

scripts/validate_changelog.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import re
7+
import subprocess
78
import sys
89
from pathlib import Path
910

@@ -21,7 +22,7 @@
2122
)
2223

2324

24-
def validate_changelog(path: Path) -> list[str]:
25+
def validate_changelog(path: Path, *, verify_tags: bool | None = None) -> list[str]:
2526
"""Return human-readable violations found in ``path``."""
2627
lines = path.read_text(encoding="utf-8").splitlines()
2728
errors: list[str] = []
@@ -112,9 +113,76 @@ def validate_changelog(path: Path) -> list[str]:
112113
if version not in versions:
113114
errors.append(f"release link [{version}] has no matching version section")
114115

116+
if verify_tags is None:
117+
verify_tags = (path.parent / ".git").exists()
118+
if verify_tags:
119+
errors.extend(_validate_published_sections(path, lines, versions))
120+
115121
return errors
116122

117123

124+
def _validate_published_sections(
125+
path: Path,
126+
lines: list[str],
127+
versions: list[str],
128+
) -> list[str]:
129+
"""Ensure every released section remains identical to its version tag."""
130+
131+
errors: list[str] = []
132+
for version in versions:
133+
if version == "Unreleased":
134+
continue
135+
tag = f"v{version}"
136+
try:
137+
completed = subprocess.run(
138+
["git", "-C", str(path.parent), "show", f"{tag}:CHANGELOG.md"],
139+
check=True,
140+
capture_output=True,
141+
text=True,
142+
)
143+
except (OSError, subprocess.CalledProcessError) as exc:
144+
detail = getattr(exc, "stderr", None) or str(exc)
145+
errors.append(
146+
f"cannot verify [{version}] against tag {tag}: {detail.strip()}; "
147+
"fetch the release tags before validating"
148+
)
149+
continue
150+
tagged_lines = completed.stdout.splitlines()
151+
current_section = _section_text(lines, version)
152+
tagged_section = _section_text(tagged_lines, version)
153+
if current_section is None:
154+
continue
155+
if tagged_section is None:
156+
errors.append(f"tag {tag} has no [{version}] changelog section")
157+
elif current_section != tagged_section:
158+
errors.append(f"published changelog section [{version}] differs from tag {tag}")
159+
return errors
160+
161+
162+
def _section_text(lines: list[str], version: str) -> str | None:
163+
"""Return one complete version section without trailing blank lines."""
164+
165+
start: int | None = None
166+
for index, line in enumerate(lines):
167+
match = VERSION_HEADING.fullmatch(line.strip())
168+
if match is not None and match.group("version") == version:
169+
start = index
170+
break
171+
if start is None:
172+
return None
173+
end = len(lines)
174+
for index in range(start + 1, len(lines)):
175+
if VERSION_HEADING.fullmatch(lines[index].strip()) is not None:
176+
end = index
177+
break
178+
# Keep Markdown reference definitions at file scope rather than
179+
# treating them as part of the final release section.
180+
if REFERENCE_LINK.fullmatch(lines[index].strip()) is not None:
181+
end = index
182+
break
183+
return "\n".join(lines[start:end]).rstrip()
184+
185+
118186
def main() -> None:
119187
path = Path(__file__).resolve().parents[1] / "CHANGELOG.md"
120188
errors = validate_changelog(path)

tests/test_validate_changelog.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import subprocess
34
import sys
45
import tempfile
56
import unittest
67
from pathlib import Path
8+
from unittest import mock
79

810
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
911
from scripts import validate_changelog
@@ -59,6 +61,36 @@ def test_rejects_missing_release_link_and_internal_planning_text(self) -> None:
5961
self.assertTrue(any("internal planning text" in error for error in errors))
6062
self.assertTrue(any("missing release link [1.0.0]" in error for error in errors))
6163

64+
def test_rejects_edits_to_a_published_section(self) -> None:
65+
with tempfile.TemporaryDirectory() as directory:
66+
path = Path(directory) / "CHANGELOG.md"
67+
current = VALID_CHANGELOG.replace("- Repair a user-visible issue.", "- A later rewrite.")
68+
path.write_text(current, encoding="utf-8")
69+
with mock.patch(
70+
"scripts.validate_changelog.subprocess.run",
71+
return_value=subprocess.CompletedProcess(
72+
args=["git"],
73+
returncode=0,
74+
stdout=VALID_CHANGELOG,
75+
stderr="",
76+
),
77+
):
78+
errors = validate_changelog.validate_changelog(path, verify_tags=True)
79+
self.assertIn("published changelog section [1.0.0] differs from tag v1.0.0", errors)
80+
81+
def test_reports_unavailable_release_tags(self) -> None:
82+
with tempfile.TemporaryDirectory() as directory:
83+
path = Path(directory) / "CHANGELOG.md"
84+
path.write_text(VALID_CHANGELOG, encoding="utf-8")
85+
missing_tag = subprocess.CalledProcessError(
86+
128,
87+
["git"],
88+
stderr="fatal: invalid object name 'v1.0.0'",
89+
)
90+
with mock.patch("scripts.validate_changelog.subprocess.run", side_effect=missing_tag):
91+
errors = validate_changelog.validate_changelog(path, verify_tags=True)
92+
self.assertTrue(any("fetch the release tags" in error for error in errors))
93+
6294

6395
if __name__ == "__main__":
6496
unittest.main()

0 commit comments

Comments
 (0)