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
2 changes: 2 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 12 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ 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
supported OS test lanes; the core dependency window now permits `<8.6`.

## [0.4.3] - 2026-08-29

Expand Down Expand Up @@ -60,9 +68,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
Expand All @@ -75,9 +80,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
Expand Down Expand Up @@ -229,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.
Expand Down Expand Up @@ -289,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
Expand Down Expand Up @@ -324,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
Expand All @@ -332,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.
70 changes: 69 additions & 1 deletion scripts/validate_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

Expand All @@ -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] = []
Expand Down Expand Up @@ -112,9 +113,76 @@ 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
# 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()


def main() -> None:
path = Path(__file__).resolve().parents[1] / "CHANGELOG.md"
errors = validate_changelog(path)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_validate_changelog.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Loading