From c0310a1b413dfe9562b555539069e9261c6da4b4 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:36:46 +0530 Subject: [PATCH] docs: enforce changelog release-note quality (#235) --- .github/workflows/docs.yml | 5 +- .github/workflows/package.yml | 4 + .github/workflows/tests.yml | 1 + CHANGELOG.md | 11 ++- docs/releasing.md | 14 ++++ scripts/validate_changelog.py | 129 +++++++++++++++++++++++++++++++ tests/test_validate_changelog.py | 64 +++++++++++++++ 7 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 scripts/validate_changelog.py create mode 100644 tests/test_validate_changelog.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bf0b4bd..98a1037 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,6 +11,7 @@ on: - "mkdocs.yml" - "pyproject.toml" - "scripts/validate_docs.py" + - "scripts/validate_changelog.py" - "scripts/generate_api_reference.py" - "tests/validate.sh" - ".github/workflows/docs.yml" @@ -52,7 +53,9 @@ jobs: run: python -m pip install ".[docs]" - name: Validate repository links and examples - run: python scripts/validate_docs.py + run: | + python scripts/validate_docs.py + python scripts/validate_changelog.py - name: Validate generated public API reference run: python scripts/generate_api_reference.py --check diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 55b0bdb..745bddd 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -14,6 +14,7 @@ on: - "scripts/validate_examples.py" - "scripts/generate_release_metadata.py" - "scripts/validate_release_metadata.py" + - "scripts/validate_changelog.py" - "examples/**" - "compatibility/**" - "docs/releasing.md" @@ -80,6 +81,9 @@ jobs: - name: Validate repository baseline run: ./tests/validate.sh + - name: Validate changelog + run: python scripts/validate_changelog.py + - name: Prepare clean artifact destination run: | git clean -ffdx diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d99ecd..243c563 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,7 @@ jobs: python -m mypy --strict examples/typed_consumer.py python -m mypy --strict lib/python/base_cli python scripts/validate_docs.py + python scripts/validate_changelog.py python scripts/benchmark_runtime.py --check python -m compileall -q examples - name: Run tests with coverage threshold diff --git a/CHANGELOG.md b/CHANGELOG.md index 63661f9..b0dcc27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,10 +53,6 @@ and versions are tracked in the repo-root `VERSION` file. stable facade export remains searchable and documented. - Add a permissioned-adopter evidence policy and dated compatibility-run artifacts without presenting maintained fixtures as customer adoption. -- Add a permissioned-adopter evidence policy and dated compatibility-run - artifacts without presenting maintained fixtures as customer adoption. - -### Changed - Improve PyPI description and search keywords to make the framework's lifecycle, logging, configuration, and CLI integration surface discoverable. @@ -284,6 +280,13 @@ 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. + +[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.4.2...HEAD +[0.4.2]: https://github.com/basefoundry/base-cli/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/basefoundry/base-cli/compare/v0.4.0...v0.4.1 +[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 diff --git a/docs/releasing.md b/docs/releasing.md index d3bca27..1814271 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -63,6 +63,20 @@ the downloaded artifact's digest with `SHA256SUMS`, confirm the SBOM namespace contains the expected tag commit, and inspect the attestation's workflow and repository identity before installation. +## Changelog and release notes + +`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Keep `[Unreleased]` first, use one section per change category, and write +user-facing bullets rather than internal implementation notes. Every released +version must include its release date and a reference link at the bottom of the +file. Move entries from `[Unreleased]` into the dated section when the release +PR is prepared; do not rewrite an already published section. + +The repository validates these rules in CI with +`python scripts/validate_changelog.py`, including duplicate bullets, duplicate +categories, missing release links, malformed dates, and accidental internal +planning text. + ## Documentation site The Documentation workflow builds this site with `mkdocs build --strict` and diff --git a/scripts/validate_changelog.py b/scripts/validate_changelog.py new file mode 100644 index 0000000..2354fe5 --- /dev/null +++ b/scripts/validate_changelog.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Validate the repository changelog and its release-link contract.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +VERSION_HEADING = re.compile(r"^## \[(?PUnreleased|\d+\.\d+\.\d+)\](?: - (?P\d{4}-\d{2}-\d{2}))?$") +CATEGORY_HEADING = re.compile(r"^### (?P.+?)\s*$") +BULLET = re.compile(r"^\s*[-*+]\s+(?P.+?)\s*$") +REFERENCE_LINK = re.compile(r"^\[(?PUnreleased|\d+\.\d+\.\d+)\]:\s+(?P\S+)\s*$") +INTERNAL_MARKERS = ( + "agentic", + "superpowers", + "implementation plan", + "private workflow", + "unchecked implementation", + "codex", +) + + +def validate_changelog(path: Path) -> list[str]: + """Return human-readable violations found in ``path``.""" + lines = path.read_text(encoding="utf-8").splitlines() + errors: list[str] = [] + nonempty = [(index, line.strip()) for index, line in enumerate(lines) if line.strip()] + if not nonempty or nonempty[0][1] != "# Changelog": + errors.append("the first non-empty line must be '# Changelog'") + + sections: list[tuple[str, str | None, int, int]] = [] + for index, line in enumerate(lines): + match = VERSION_HEADING.fullmatch(line.strip()) + if match is None: + continue + if sections: + previous = sections[-1] + sections[-1] = (*previous[:3], index) + sections.append((match.group("version"), match.group("date"), index, len(lines))) + + if not sections: + errors.append("no version sections found") + return errors + if sections[0][0] != "Unreleased": + errors.append("[Unreleased] must be the first version section") + + versions = [version for version, _date, _start, _end in sections] + for version in sorted(set(versions)): + if versions.count(version) > 1: + errors.append(f"duplicate version section [{version}]") + + for version, date, start, end in sections: + if version == "Unreleased" and date is not None: + errors.append("[Unreleased] must not have a release date") + if version != "Unreleased" and date is None: + errors.append(f"released section [{version}] is missing a YYYY-MM-DD date") + + categories: dict[str, int] = {} + bullets: set[str] = set() + saw_bullet = False + current_category: str | None = None + category_has_content = False + for line_number in range(start + 1, end): + line = lines[line_number] + category_match = CATEGORY_HEADING.fullmatch(line.strip()) + if category_match is not None: + if current_category is not None and not category_has_content: + errors.append(f"[{version}] section '{current_category}' is empty") + current_category = category_match.group("category") + category_has_content = False + categories[current_category] = categories.get(current_category, 0) + 1 + if categories[current_category] > 1: + errors.append(f"[{version}] has duplicate '{current_category}' sections") + continue + + bullet_match = BULLET.fullmatch(line) + if bullet_match is None: + if line.strip() and not line.lstrip().startswith("["): + category_has_content = True + continue + saw_bullet = True + category_has_content = True + text = " ".join(bullet_match.group("text").split()).casefold() + if text in bullets: + errors.append(f"[{version}] contains a duplicate bullet: {bullet_match.group('text')}") + bullets.add(text) + if any(marker in text for marker in INTERNAL_MARKERS): + errors.append(f"[{version}] contains internal planning text: {bullet_match.group('text')}") + + if current_category is not None and not category_has_content: + errors.append(f"[{version}] section '{current_category}' is empty") + if not saw_bullet: + errors.append(f"[{version}] contains no changelog bullets") + + references: dict[str, str] = {} + for line in lines: + match = REFERENCE_LINK.fullmatch(line.strip()) + if match is None: + continue + version = match.group("version") + if version in references: + errors.append(f"duplicate release link [{version}]") + references[version] = match.group("url") + if not match.group("url").startswith(("https://", "http://")): + errors.append(f"release link [{version}] must use an absolute HTTP(S) URL") + + for version in versions: + if version not in references: + errors.append(f"missing release link [{version}]") + for version in references: + if version not in versions: + errors.append(f"release link [{version}] has no matching version section") + + return errors + + +def main() -> None: + path = Path(__file__).resolve().parents[1] / "CHANGELOG.md" + errors = validate_changelog(path) + if errors: + for error in errors: + print(f"changelog validation failed: {error}", file=sys.stderr) + raise SystemExit(1) + print(f"Validated changelog: {path.name}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_validate_changelog.py b/tests/test_validate_changelog.py new file mode 100644 index 0000000..218ae3f --- /dev/null +++ b/tests/test_validate_changelog.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts import validate_changelog + +VALID_CHANGELOG = """\ +# Changelog + +## [Unreleased] + +### Added + +- Add a useful feature. + +## [1.0.0] - 2026-08-28 + +### Fixed + +- Repair a user-visible issue. + +[Unreleased]: https://github.com/basefoundry/base-cli/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/basefoundry/base-cli/releases/tag/v1.0.0 +""" + + +class ChangelogValidationTests(unittest.TestCase): + def validate(self, text: str) -> list[str]: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "CHANGELOG.md" + path.write_text(text, encoding="utf-8") + return validate_changelog.validate_changelog(path) + + def test_accepts_valid_changelog(self) -> None: + self.assertEqual(self.validate(VALID_CHANGELOG), []) + + def test_rejects_duplicate_bullets_and_categories(self) -> None: + text = VALID_CHANGELOG.replace( + "- Add a useful feature.", + "- Add a useful feature.\n- Add a useful feature.", + ).replace("### Fixed", "### Fixed\n\n- Another fix.\n\n### Fixed") + errors = self.validate(text) + self.assertTrue(any("duplicate bullet" in error for error in errors)) + self.assertTrue(any("duplicate 'Fixed'" in error for error in errors)) + + def test_rejects_missing_release_link_and_internal_planning_text(self) -> None: + text = VALID_CHANGELOG.replace( + "- Add a useful feature.", + "- Add an implementation plan for an agentic worker.", + ).replace( + "[1.0.0]: https://github.com/basefoundry/base-cli/releases/tag/v1.0.0\n", + "", + ) + errors = self.validate(text) + 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)) + + +if __name__ == "__main__": + unittest.main()