Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions scripts/validate_changelog.py
Original file line number Diff line number Diff line change
@@ -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"^## \[(?P<version>Unreleased|\d+\.\d+\.\d+)\](?: - (?P<date>\d{4}-\d{2}-\d{2}))?$")
CATEGORY_HEADING = re.compile(r"^### (?P<category>.+?)\s*$")
BULLET = re.compile(r"^\s*[-*+]\s+(?P<text>.+?)\s*$")
REFERENCE_LINK = re.compile(r"^\[(?P<version>Unreleased|\d+\.\d+\.\d+)\]:\s+(?P<url>\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()
64 changes: 64 additions & 0 deletions tests/test_validate_changelog.py
Original file line number Diff line number Diff line change
@@ -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()
Loading