From 98445bc1d24cad03bdf94f52b93b3b6cfc9ffaff Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:02:00 +0530 Subject: [PATCH 1/2] ci: enforce immutable published changelog sections --- .github/workflows/docs.yml | 2 + .github/workflows/package.yml | 4 ++ .github/workflows/tests.yml | 2 + CHANGELOG.md | 10 ++--- scripts/validate_changelog.py | 65 +++++++++++++++++++++++++++++++- tests/test_validate_changelog.py | 32 ++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 60f0c08..45538ba 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,6 +55,8 @@ jobs: steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 38390ec..4825ff5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -59,6 +59,8 @@ jobs: steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -195,6 +197,8 @@ jobs: steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 842d9c0..3fab2e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,8 @@ jobs: shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 38484e1..d2bed15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and versions are tracked in the repo-root `VERSION` file. apply identical bounded width handling to Rich and plain renderers. - Make managed metadata and index replacements atomic, with bounded retries for transient Windows file-sharing locks. +- Enforce the portable `0` through `255` process exit-code contract for command + return values, rejecting booleans and out-of-range integers consistently. +- Add Click 8.5 compatibility coverage across the dependency matrix and + supported OS test lanes; the core dependency window now permits `<8.6`. ## [0.4.3] - 2026-08-29 @@ -60,9 +64,6 @@ boundary. Existing Click and Typer command trees remain supported. ### Fixed -- Enforce the portable `0` through `255` process exit-code contract for command - return values, rejecting booleans and out-of-range integers consistently. - - Keep the Typer adapter compatible with Typer 0.27.2's vendored exit exception layout and validate that release in the compatibility matrix. - Apply the documented count-only 20-bundle retention default to implicit JSON @@ -75,9 +76,6 @@ boundary. Existing Click and Typer command trees remain supported. ### Added -- Click 8.5 compatibility coverage across the dependency matrix and supported - OS test lanes; the core dependency window now permits `<8.6`. - - Automate GitHub Releases from matching version tags with reviewed distributions, checksums, SBOM metadata, and generated comparison notes. - Publish a generated dependency and platform compatibility dashboard linked diff --git a/scripts/validate_changelog.py b/scripts/validate_changelog.py index 2354fe5..ea15732 100644 --- a/scripts/validate_changelog.py +++ b/scripts/validate_changelog.py @@ -4,6 +4,7 @@ from __future__ import annotations import re +import subprocess import sys from pathlib import Path @@ -21,7 +22,7 @@ ) -def validate_changelog(path: Path) -> list[str]: +def validate_changelog(path: Path, *, verify_tags: bool | None = None) -> list[str]: """Return human-readable violations found in ``path``.""" lines = path.read_text(encoding="utf-8").splitlines() errors: list[str] = [] @@ -112,9 +113,71 @@ def validate_changelog(path: Path) -> list[str]: if version not in versions: errors.append(f"release link [{version}] has no matching version section") + if verify_tags is None: + verify_tags = (path.parent / ".git").exists() + if verify_tags: + errors.extend(_validate_published_sections(path, lines, versions)) + return errors +def _validate_published_sections( + path: Path, + lines: list[str], + versions: list[str], +) -> list[str]: + """Ensure every released section remains identical to its version tag.""" + + errors: list[str] = [] + for version in versions: + if version == "Unreleased": + continue + tag = f"v{version}" + try: + completed = subprocess.run( + ["git", "-C", str(path.parent), "show", f"{tag}:CHANGELOG.md"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = getattr(exc, "stderr", None) or str(exc) + errors.append( + f"cannot verify [{version}] against tag {tag}: {detail.strip()}; " + "fetch the release tags before validating" + ) + continue + tagged_lines = completed.stdout.splitlines() + current_section = _section_text(lines, version) + tagged_section = _section_text(tagged_lines, version) + if current_section is None: + continue + if tagged_section is None: + errors.append(f"tag {tag} has no [{version}] changelog section") + elif current_section != tagged_section: + errors.append(f"published changelog section [{version}] differs from tag {tag}") + return errors + + +def _section_text(lines: list[str], version: str) -> str | None: + """Return one complete version section without trailing blank lines.""" + + start: int | None = None + for index, line in enumerate(lines): + match = VERSION_HEADING.fullmatch(line.strip()) + if match is not None and match.group("version") == version: + start = index + break + if start is None: + return None + end = len(lines) + for index in range(start + 1, len(lines)): + if VERSION_HEADING.fullmatch(lines[index].strip()) is not None: + end = index + break + return "\n".join(lines[start:end]).rstrip() + + def main() -> None: path = Path(__file__).resolve().parents[1] / "CHANGELOG.md" errors = validate_changelog(path) diff --git a/tests/test_validate_changelog.py b/tests/test_validate_changelog.py index 218ae3f..7be2b23 100644 --- a/tests/test_validate_changelog.py +++ b/tests/test_validate_changelog.py @@ -1,9 +1,11 @@ from __future__ import annotations +import subprocess import sys import tempfile import unittest from pathlib import Path +from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from scripts import validate_changelog @@ -59,6 +61,36 @@ def test_rejects_missing_release_link_and_internal_planning_text(self) -> None: self.assertTrue(any("internal planning text" in error for error in errors)) self.assertTrue(any("missing release link [1.0.0]" in error for error in errors)) + def test_rejects_edits_to_a_published_section(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "CHANGELOG.md" + current = VALID_CHANGELOG.replace("- Repair a user-visible issue.", "- A later rewrite.") + path.write_text(current, encoding="utf-8") + with mock.patch( + "scripts.validate_changelog.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout=VALID_CHANGELOG, + stderr="", + ), + ): + errors = validate_changelog.validate_changelog(path, verify_tags=True) + self.assertIn("published changelog section [1.0.0] differs from tag v1.0.0", errors) + + def test_reports_unavailable_release_tags(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "CHANGELOG.md" + path.write_text(VALID_CHANGELOG, encoding="utf-8") + missing_tag = subprocess.CalledProcessError( + 128, + ["git"], + stderr="fatal: invalid object name 'v1.0.0'", + ) + with mock.patch("scripts.validate_changelog.subprocess.run", side_effect=missing_tag): + errors = validate_changelog.validate_changelog(path, verify_tags=True) + self.assertTrue(any("fetch the release tags" in error for error in errors)) + if __name__ == "__main__": unittest.main() From c4292ce87686aa3ce5360de0a7c552e620d8e3f9 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:24:50 +0530 Subject: [PATCH 2/2] fix: validate historical changelog sections accurately --- CHANGELOG.md | 15 ++++++++------- scripts/validate_changelog.py | 5 +++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2bed15..971ec27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and versions are tracked in the repo-root `VERSION` file. apply identical bounded width handling to Rich and plain renderers. - Make managed metadata and index replacements atomic, with bounded retries for transient Windows file-sharing locks. +- Select platform-aware cache roots (`XDG_CACHE_HOME`, macOS Caches, and + Windows `LOCALAPPDATA`) and normalize home-relative paths across separators. +- Add README health and support badges for CI, downstream consumers, PyPI, and + supported Python versions. - Enforce the portable `0` through `255` process exit-code contract for command return values, rejecting booleans and out-of-range integers consistently. - Add Click 8.5 compatibility coverage across the dependency matrix and @@ -227,7 +231,6 @@ the API stability policy and migration guide before upgrading from `0.3.x`. ### Changed -- Add README health and support badges for CI, downstream consumers, PyPI, and supported Python versions. - Normalize command returns, Click errors, aborts, interrupts, `SystemExit`, and unexpected exceptions through one core outcome model and clean `run_app()` process boundary. @@ -287,8 +290,6 @@ the API stability policy and migration guide before upgrading from `0.3.x`. ### Changed -- Select platform-aware cache roots (`XDG_CACHE_HOME`, macOS Caches, and - Windows `LOCALAPPDATA`) and normalize home-relative paths across separators. - Make `base_cli.App()` use the consumer-neutral profile by default. - Move manifest discovery, implicit configuration, owner-aware runtime layout, and history persistence out of the generic package. Consumers now provide @@ -322,6 +323,10 @@ the API stability policy and migration guide before upgrading from `0.3.x`. - Initialized the repository with the Base-managed repo baseline. - Added the guarded package build, artifact validation, and protected TestPyPI/PyPI publication workflow. +- Exposed `base_cli.__version__` from the repository and installed package + version contract. +- Pinned the build backend to metadata compatible with the bundled publication + action and made license-file validation portable across setuptools versions. [Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.4.3...HEAD [0.4.3]: https://github.com/basefoundry/base-cli/compare/v0.4.2...v0.4.3 @@ -330,7 +335,3 @@ the API stability policy and migration guide before upgrading from `0.3.x`. [0.4.0]: https://github.com/basefoundry/base-cli/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/basefoundry/base-cli/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/basefoundry/base-cli/releases/tag/v0.2.0 -- Exposed `base_cli.__version__` from the repository and installed package - version contract. -- Pinned the build backend to metadata compatible with the bundled publication - action and made license-file validation portable across setuptools versions. diff --git a/scripts/validate_changelog.py b/scripts/validate_changelog.py index ea15732..ed95b5f 100644 --- a/scripts/validate_changelog.py +++ b/scripts/validate_changelog.py @@ -175,6 +175,11 @@ def _section_text(lines: list[str], version: str) -> str | None: if VERSION_HEADING.fullmatch(lines[index].strip()) is not None: end = index break + # Keep Markdown reference definitions at file scope rather than + # treating them as part of the final release section. + if REFERENCE_LINK.fullmatch(lines[index].strip()) is not None: + end = index + break return "\n".join(lines[start:end]).rstrip()