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
34 changes: 33 additions & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@ on:
- "scripts/verify_release_assets.py"
- "scripts/validate_changelog.py"
- "scripts/validate_release_ref.py"
- "scripts/validate_release_docs.py"
- "scripts/validate_release_provenance.py"
- "scripts/verify_pypi_metadata.py"
- "CHANGELOG.md"
- "examples/**"
- "compatibility/**"
- "docs/releasing.md"
- "tests/test_package_workflow.py"
- "tests/test_validate_package_artifact.py"
- "tests/test_validate_release_docs.py"
- "tests/test_verify_pypi_metadata.py"
- "tests/test_verify_release_assets.py"
- "tests/test_validate_release_provenance.py"
- "requirements/release.in"
Expand Down Expand Up @@ -133,6 +138,11 @@ jobs:
RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }}
run: python scripts/validate_release_ref.py

- name: Validate release-facing documentation
env:
RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }}
run: python scripts/validate_release_docs.py

- name: Prepare clean artifact destination
run: |
git clean -ffdx
Expand Down Expand Up @@ -371,9 +381,31 @@ jobs:
subject-checksums: dist/SHA256SUMS
sbom-path: dist/SBOM.spdx.json

verify_pypi:
name: Verify published PyPI metadata
needs: [build, publish, attest]
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.build.outputs.source_commit }}

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

- name: Verify PyPI description and version
run: python scripts/verify_pypi_metadata.py --version "${{ needs.build.outputs.version }}"

release:
name: Create GitHub Release
needs: [build, smoke, provenance, publish, attest]
needs: [build, smoke, provenance, publish, attest, verify_pypi]
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }}
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ and versions are tracked in the repo-root `VERSION` file.

- Include `uv.lock` freshness in the authoritative local validation aggregate (#424).

## [0.5.1] - 2026-10-10

### Changed

- Align the release-facing README and reference-consumer documentation with the
published `base-cli` 0.5.x and `base-cli-demo` 0.2.0 lines.
- Add release preflight, package-description, and PyPI metadata readback gates so
published documentation stays bound to the reviewed tag.

## [0.5.0] - 2026-10-03

### Added
Expand Down Expand Up @@ -401,7 +410,8 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
- 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.5.0...HEAD
[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.5.1...HEAD
[0.5.1]: https://github.com/basefoundry/base-cli/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/basefoundry/base-cli/compare/v0.4.3...v0.5.0
[0.4.3]: https://github.com/basefoundry/base-cli/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/basefoundry/base-cli/compare/v0.4.1...v0.4.2
Expand Down
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@

| Version | License | Install | Release notes |
| --- | --- | --- | --- |
| `0.5.0` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.5.0](https://github.com/basefoundry/base-cli/releases/tag/v0.5.0) |
| `0.5.1` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.5.1](https://github.com/basefoundry/base-cli/releases/tag/v0.5.1) |

The 0.5.0 release is bound to the immutable `v0.5.0` tag; PyPI remains the authority for available versions.
The adopter documentation uses `~=0.5.0` for the release-preparation target;
use that pin only after the 0.5.0 artifact is published and verified.
The 0.5.1 release is bound to the immutable `v0.5.1` tag; PyPI remains the authority for available versions.
The adopter documentation uses `~=0.5.0` for the supported minor-release line.

`base-cli` is the production lifecycle layer for Click and Typer Python CLIs.
It standardizes context, logging, configuration, cleanup, and machine-readable
Expand Down Expand Up @@ -73,9 +72,8 @@ options are documented below.

For a complete maintained example, see the independent
[`base-cli-demo`](https://github.com/basefoundry/base-cli-demo) reference
consumer. It is versioned separately (`0.1.0`) and currently tests the released
`base-cli>=0.4.3,<0.5` line; its 0.5.0 upgrade is tracked in
[base-cli-demo#47](https://github.com/basefoundry/base-cli-demo/issues/47).
consumer. It is versioned separately (`0.2.0`) and currently tests the released
`base-cli>=0.5.0,<0.6` line.

Release builds, TestPyPI rehearsals, and protected PyPI publication are
documented in [`docs/releasing.md`](https://basefoundry.github.io/base-cli/releasing/). The package exposes
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.0
0.5.1
8 changes: 3 additions & 5 deletions docs/adoption-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,12 @@ tests. Base, base-demo, `base-cli-demo`, and other adjacent repositories are
explicitly excluded from the independent-adopter count.

`base-cli-demo` is a maintained reference and learning consumer, versioned
independently (`0.1.0`). Its CI demonstrates that a real consumer can exercise
independently (`0.2.0`). Its CI demonstrates that a real consumer can exercise
the public facade, lifecycle, output, error, and optional-integration paths for
the exact framework dependency line it declares; it is not an independent
adopter result or a substitute for a dated compatibility-fixture artifact.
While its current released line is `base-cli>=0.4.3,<0.5`, the 0.5.0 upgrade is
tracked in [base-cli-demo#47](https://github.com/basefoundry/base-cli-demo/issues/47)
and must be green against the published framework before being described as
0.5.0 evidence.
Its current released line is `base-cli>=0.5.0,<0.6`, and its release evidence is
maintained against the published framework package.

## Adopter program

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,6 @@ Continue with the framework guides:
- [`base-cli-demo`](https://github.com/basefoundry/base-cli-demo) for the
maintained end-to-end reference consumer, optional integration scenarios,
and runnable documentation examples. It is versioned independently and its
current released dependency line is `base-cli>=0.4.3,<0.5`.
current released dependency line is `base-cli>=0.5.0,<0.6`.
- [Installation and packaging](adopter-readiness.md) for dependency extras,
distribution checks, and release guidance.
16 changes: 16 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ 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.

## Release-facing documentation guardrails

The package README is part of the published PyPI metadata. Before any tagged
build, the Package workflow runs `scripts/validate_release_docs.py` to require a
single release row whose version and GitHub release link match `VERSION` and to
reject pre-publication wording. `scripts/validate_package_artifact.py` then
compares the wheel metadata and sdist `PKG-INFO` description with the tagged
README, and the release BOM records the README SHA-256.

After a production PyPI upload, the workflow runs
`scripts/verify_pypi_metadata.py` before creating the GitHub Release. It reads
the published PyPI JSON metadata, retries propagation delays, and requires the
published version and long description to match the reviewed source. A mismatch
blocks release completion; published PyPI bytes remain immutable and require a
new patch version for correction.

## Changelog and release notes

`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Expand Down
1 change: 1 addition & 0 deletions scripts/generate_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def generate(dist: Path, root: Path) -> None:
"version": version,
"tag": f"v{version}",
"commit": revision,
"readme_sha256": sha256_file(root / "README.md"),
"source_mode": "release",
"api_schema_version": f"base-cli-api@{version}",
"platforms": ["macos", "ubuntu", "windows"],
Expand Down
50 changes: 46 additions & 4 deletions scripts/validate_package_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,31 @@ def expected_package_files() -> set[str]:
return {path.relative_to(package_root).as_posix() for path in package_root.rglob("*") if path.is_file()}


def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -> None:
def read_expected_readme() -> str:
readme_path = Path(__file__).resolve().parents[1] / "README.md"
try:
return readme_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
fail(f"could not read the source README.md: {exc}")


def normalized_text(value: str) -> str:
return value.replace("\r\n", "\n").strip()


def validate_metadata_description(metadata: email.message.Message, expected_readme: str, artifact_name: str) -> None:
payload = metadata.get_payload(decode=True)
if not isinstance(payload, bytes):
fail(f"{artifact_name} has no text long description")
try:
description = payload.decode("utf-8")
except UnicodeDecodeError as exc:
fail(f"{artifact_name} long description is not UTF-8: {exc}")
if normalized_text(description) != normalized_text(expected_readme):
fail(f"{artifact_name} long description does not match the source README.md")


def validate_wheel(path: Path, expected_version: str, package_files: set[str], expected_readme: str) -> None:
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")]
Expand All @@ -84,6 +108,7 @@ def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -
for header, expected in expected_headers.items():
if metadata.get(header) != expected:
fail(f"{path.name} has {header}={metadata.get(header)!r}; expected {expected!r}")
validate_metadata_description(metadata, expected_readme, path.name)

dependencies = set(metadata.get_all("Requires-Dist", []))
for dependency in REQUIRED_DEPENDENCIES:
Expand Down Expand Up @@ -131,7 +156,7 @@ def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -
fail(f"{path.name} contains non-allowlisted archive member {name!r}")


def validate_sdist(path: Path, expected_version: str) -> None:
def validate_sdist(path: Path, expected_version: str, expected_readme: str) -> None:
with tarfile.open(path, "r:gz") as archive:
members = archive.getmembers()
names = [member.name for member in members]
Expand Down Expand Up @@ -160,6 +185,22 @@ def validate_sdist(path: Path, expected_version: str) -> None:
version_text = archive.extractfile(version_members[0])
if version_text is None or version_text.read().decode().splitlines()[0].strip() != expected_version:
fail(f"{path.name} VERSION does not match {expected_version}")
readme_members = [member for member in members if member.name == f"{root_prefix}README.md"]
if len(readme_members) != 1:
fail(f"{path.name} must contain exactly one README.md")
readme_text = archive.extractfile(readme_members[0])
if readme_text is None or normalized_text(readme_text.read().decode("utf-8")) != normalized_text(
expected_readme
):
fail(f"{path.name} README.md does not match the source README.md")
pkg_info_members = [member for member in members if member.name == f"{root_prefix}PKG-INFO"]
if len(pkg_info_members) != 1:
fail(f"{path.name} must contain exactly one PKG-INFO")
pkg_info = archive.extractfile(pkg_info_members[0])
if pkg_info is None:
fail(f"{path.name} PKG-INFO could not be read")
metadata = email.message_from_bytes(pkg_info.read())
validate_metadata_description(metadata, expected_readme, path.name)


def main() -> None:
Expand All @@ -178,8 +219,9 @@ def main() -> None:
if not wheels[0].name.startswith(expected_stem) or not sdists[0].name.startswith(expected_stem):
fail(f"artifact filenames do not match version {expected_version}")

validate_wheel(wheels[0], expected_version, expected_package_files())
validate_sdist(sdists[0], expected_version)
expected_readme = read_expected_readme()
validate_wheel(wheels[0], expected_version, expected_package_files(), expected_readme)
validate_sdist(sdists[0], expected_version, expected_readme)
print(f"Validated {PACKAGE_NAME} {expected_version}: wheel, sdist, metadata, package data, and test boundary.")


Expand Down
66 changes: 66 additions & 0 deletions scripts/validate_release_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate release-facing documentation before building a tagged package."""

from __future__ import annotations

import argparse
import os
import re
from pathlib import Path

VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+)+(?:[-+][0-9A-Za-z.-]+)?$")
RELEASE_ROW_PATTERN = re.compile(
r"^\| `(?P<version>[^`]+)` \| \[Apache-2\.0\]\(LICENSE\) \| "
r"`python -m pip install base-cli` \| \[v(?P<link_version>[^]]+)\]"
r"\(https://github\.com/basefoundry/base-cli/releases/tag/v(?P<tag_version>[^)]+)\) \|$"
)


def validate_release_docs(version_path: Path, readme_path: Path, tag: str = "") -> list[str]:
"""Return release documentation violations for *version_path* and README."""

errors: list[str] = []
version = version_path.read_text(encoding="utf-8").strip()
if not VERSION_PATTERN.fullmatch(version):
errors.append(f"VERSION is not a valid release version: {version!r}")
return errors
expected_tag = f"v{version}"
if tag and tag != expected_tag:
errors.append(f"release tag {tag!r} does not match VERSION {version!r}")

readme = readme_path.read_text(encoding="utf-8")
rows = [RELEASE_ROW_PATTERN.match(line) for line in readme.splitlines()]
matching_rows = [match for match in rows if match is not None]
if len(matching_rows) != 1:
errors.append("README.md must contain exactly one canonical release row")
else:
row = matching_rows[0]
assert row is not None
values = (row.group("version"), row.group("link_version"), row.group("tag_version"))
if values != (version, version, version):
errors.append(f"README.md release row versions {values!r} do not match {version!r}")

lowered = readme.casefold()
for forbidden in ("source release candidate", "publication is pending"):
if forbidden in lowered:
errors.append(f"README.md still contains pre-publication wording: {forbidden!r}")
return errors


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version-file", type=Path, default=Path("VERSION"))
parser.add_argument("--readme", type=Path, default=Path("README.md"))
parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG", ""))
args = parser.parse_args()
errors = validate_release_docs(args.version_file, args.readme, args.tag)
if errors:
for error in errors:
print(f"release documentation validation failed: {error}")
raise SystemExit(1)
version = args.version_file.read_text(encoding="utf-8").strip()
print(f"Validated release-facing documentation for {args.tag or version}.")


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions scripts/validate_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def main() -> None:
_fail(f"{BOM_ROW_NAME} repository must be basefoundry/base-cli")
if bom_row.get("version") != version or bom_row.get("tag") != f"v{version}":
_fail(f"{BOM_ROW_NAME} version/tag does not match the release")
readme_path = Path(__file__).resolve().parents[1] / "README.md"
if bom_row.get("readme_sha256") != sha256_file(readme_path):
_fail(f"{BOM_ROW_NAME} README hash is not bound to the reviewed source")
commit = bom_row.get("commit")
if not isinstance(commit, str) or not SHA_RE.fullmatch(commit):
_fail(f"{BOM_ROW_NAME} commit must be a lowercase full 40-character SHA")
Expand Down
Loading
Loading