Skip to content

Commit aa20cfe

Browse files
authored
release: publish base-cli 0.5.1 with metadata guardrails (#458)
Closes #457 ## Summary - prepare base-cli 0.5.1 with corrected release-facing documentation and base-cli-demo 0.2.0 dependency references - add pre-tag README/version validation and exact wheel/sdist README metadata checks - bind the release BOM to the README hash and verify PyPI metadata before creating the GitHub Release - add regression coverage and keep the release validation aggregate green ## Validation - full `tests/full_validate.sh` passed locally - package build, twine check, release metadata validation, and clean-wheel install passed - focused guardrail tests, Ruff, and `git diff --check` passed The tag will be created only after this PR is merged and hosted checks are green.
1 parent 60fa38d commit aa20cfe

17 files changed

Lines changed: 365 additions & 26 deletions

‎.github/workflows/package.yml‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@ on:
1717
- "scripts/verify_release_assets.py"
1818
- "scripts/validate_changelog.py"
1919
- "scripts/validate_release_ref.py"
20+
- "scripts/validate_release_docs.py"
2021
- "scripts/validate_release_provenance.py"
22+
- "scripts/verify_pypi_metadata.py"
2123
- "CHANGELOG.md"
2224
- "examples/**"
2325
- "compatibility/**"
2426
- "docs/releasing.md"
2527
- "tests/test_package_workflow.py"
28+
- "tests/test_validate_package_artifact.py"
29+
- "tests/test_validate_release_docs.py"
30+
- "tests/test_verify_pypi_metadata.py"
2631
- "tests/test_verify_release_assets.py"
2732
- "tests/test_validate_release_provenance.py"
2833
- "requirements/release.in"
@@ -133,6 +138,11 @@ jobs:
133138
RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }}
134139
run: python scripts/validate_release_ref.py
135140

141+
- name: Validate release-facing documentation
142+
env:
143+
RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }}
144+
run: python scripts/validate_release_docs.py
145+
136146
- name: Prepare clean artifact destination
137147
run: |
138148
git clean -ffdx
@@ -371,9 +381,31 @@ jobs:
371381
subject-checksums: dist/SHA256SUMS
372382
sbom-path: dist/SBOM.spdx.json
373383

384+
verify_pypi:
385+
name: Verify published PyPI metadata
386+
needs: [build, publish, attest]
387+
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }}
388+
runs-on: ubuntu-latest
389+
timeout-minutes: 5
390+
permissions:
391+
contents: read
392+
steps:
393+
- name: Check out source
394+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
395+
with:
396+
ref: ${{ needs.build.outputs.source_commit }}
397+
398+
- name: Set up Python
399+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
400+
with:
401+
python-version: "3.13"
402+
403+
- name: Verify PyPI description and version
404+
run: python scripts/verify_pypi_metadata.py --version "${{ needs.build.outputs.version }}"
405+
374406
release:
375407
name: Create GitHub Release
376-
needs: [build, smoke, provenance, publish, attest]
408+
needs: [build, smoke, provenance, publish, attest, verify_pypi]
377409
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }}
378410
runs-on: ubuntu-latest
379411
timeout-minutes: 10

‎CHANGELOG.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ and versions are tracked in the repo-root `VERSION` file.
1515

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

18+
## [0.5.1] - 2026-10-10
19+
20+
### Changed
21+
22+
- Align the release-facing README and reference-consumer documentation with the
23+
published `base-cli` 0.5.x and `base-cli-demo` 0.2.0 lines.
24+
- Add release preflight, package-description, and PyPI metadata readback gates so
25+
published documentation stays bound to the reviewed tag.
26+
1827
## [0.5.0] - 2026-10-03
1928

2029
### Added
@@ -401,7 +410,8 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
401410
- Pinned the build backend to metadata compatible with the bundled publication
402411
action and made license-file validation portable across setuptools versions.
403412

404-
[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.5.0...HEAD
413+
[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.5.1...HEAD
414+
[0.5.1]: https://github.com/basefoundry/base-cli/compare/v0.5.0...v0.5.1
405415
[0.5.0]: https://github.com/basefoundry/base-cli/compare/v0.4.3...v0.5.0
406416
[0.4.3]: https://github.com/basefoundry/base-cli/compare/v0.4.2...v0.4.3
407417
[0.4.2]: https://github.com/basefoundry/base-cli/compare/v0.4.1...v0.4.2

‎README.md‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77

88
| Version | License | Install | Release notes |
99
| --- | --- | --- | --- |
10-
| `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) |
10+
| `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) |
1111

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

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

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

8078
Release builds, TestPyPI rehearsals, and protected PyPI publication are
8179
documented in [`docs/releasing.md`](https://basefoundry.github.io/base-cli/releasing/). The package exposes

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.0
1+
0.5.1

‎docs/adoption-evidence.md‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,12 @@ tests. Base, base-demo, `base-cli-demo`, and other adjacent repositories are
3131
explicitly excluded from the independent-adopter count.
3232

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

4341
## Adopter program
4442

‎docs/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,6 @@ Continue with the framework guides:
9696
- [`base-cli-demo`](https://github.com/basefoundry/base-cli-demo) for the
9797
maintained end-to-end reference consumer, optional integration scenarios,
9898
and runnable documentation examples. It is versioned independently and its
99-
current released dependency line is `base-cli>=0.4.3,<0.5`.
99+
current released dependency line is `base-cli>=0.5.0,<0.6`.
100100
- [Installation and packaging](adopter-readiness.md) for dependency extras,
101101
distribution checks, and release guidance.

‎docs/releasing.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ the downloaded artifact's digest with `SHA256SUMS`, confirm the SBOM namespace
125125
contains the expected tag commit, and inspect the attestation's workflow and
126126
repository identity before installation.
127127

128+
## Release-facing documentation guardrails
129+
130+
The package README is part of the published PyPI metadata. Before any tagged
131+
build, the Package workflow runs `scripts/validate_release_docs.py` to require a
132+
single release row whose version and GitHub release link match `VERSION` and to
133+
reject pre-publication wording. `scripts/validate_package_artifact.py` then
134+
compares the wheel metadata and sdist `PKG-INFO` description with the tagged
135+
README, and the release BOM records the README SHA-256.
136+
137+
After a production PyPI upload, the workflow runs
138+
`scripts/verify_pypi_metadata.py` before creating the GitHub Release. It reads
139+
the published PyPI JSON metadata, retries propagation delays, and requires the
140+
published version and long description to match the reviewed source. A mismatch
141+
blocks release completion; published PyPI bytes remain immutable and require a
142+
new patch version for correction.
143+
128144
## Changelog and release notes
129145

130146
`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

‎scripts/generate_release_metadata.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def generate(dist: Path, root: Path) -> None:
136136
"version": version,
137137
"tag": f"v{version}",
138138
"commit": revision,
139+
"readme_sha256": sha256_file(root / "README.md"),
139140
"source_mode": "release",
140141
"api_schema_version": f"base-cli-api@{version}",
141142
"platforms": ["macos", "ubuntu", "windows"],

‎scripts/validate_package_artifact.py‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,31 @@ def expected_package_files() -> set[str]:
6767
return {path.relative_to(package_root).as_posix() for path in package_root.rglob("*") if path.is_file()}
6868

6969

70-
def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -> None:
70+
def read_expected_readme() -> str:
71+
readme_path = Path(__file__).resolve().parents[1] / "README.md"
72+
try:
73+
return readme_path.read_text(encoding="utf-8")
74+
except (OSError, UnicodeDecodeError) as exc:
75+
fail(f"could not read the source README.md: {exc}")
76+
77+
78+
def normalized_text(value: str) -> str:
79+
return value.replace("\r\n", "\n").strip()
80+
81+
82+
def validate_metadata_description(metadata: email.message.Message, expected_readme: str, artifact_name: str) -> None:
83+
payload = metadata.get_payload(decode=True)
84+
if not isinstance(payload, bytes):
85+
fail(f"{artifact_name} has no text long description")
86+
try:
87+
description = payload.decode("utf-8")
88+
except UnicodeDecodeError as exc:
89+
fail(f"{artifact_name} long description is not UTF-8: {exc}")
90+
if normalized_text(description) != normalized_text(expected_readme):
91+
fail(f"{artifact_name} long description does not match the source README.md")
92+
93+
94+
def validate_wheel(path: Path, expected_version: str, package_files: set[str], expected_readme: str) -> None:
7195
with zipfile.ZipFile(path) as archive:
7296
names = archive.namelist()
7397
metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")]
@@ -84,6 +108,7 @@ def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -
84108
for header, expected in expected_headers.items():
85109
if metadata.get(header) != expected:
86110
fail(f"{path.name} has {header}={metadata.get(header)!r}; expected {expected!r}")
111+
validate_metadata_description(metadata, expected_readme, path.name)
87112

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

133158

134-
def validate_sdist(path: Path, expected_version: str) -> None:
159+
def validate_sdist(path: Path, expected_version: str, expected_readme: str) -> None:
135160
with tarfile.open(path, "r:gz") as archive:
136161
members = archive.getmembers()
137162
names = [member.name for member in members]
@@ -160,6 +185,22 @@ def validate_sdist(path: Path, expected_version: str) -> None:
160185
version_text = archive.extractfile(version_members[0])
161186
if version_text is None or version_text.read().decode().splitlines()[0].strip() != expected_version:
162187
fail(f"{path.name} VERSION does not match {expected_version}")
188+
readme_members = [member for member in members if member.name == f"{root_prefix}README.md"]
189+
if len(readme_members) != 1:
190+
fail(f"{path.name} must contain exactly one README.md")
191+
readme_text = archive.extractfile(readme_members[0])
192+
if readme_text is None or normalized_text(readme_text.read().decode("utf-8")) != normalized_text(
193+
expected_readme
194+
):
195+
fail(f"{path.name} README.md does not match the source README.md")
196+
pkg_info_members = [member for member in members if member.name == f"{root_prefix}PKG-INFO"]
197+
if len(pkg_info_members) != 1:
198+
fail(f"{path.name} must contain exactly one PKG-INFO")
199+
pkg_info = archive.extractfile(pkg_info_members[0])
200+
if pkg_info is None:
201+
fail(f"{path.name} PKG-INFO could not be read")
202+
metadata = email.message_from_bytes(pkg_info.read())
203+
validate_metadata_description(metadata, expected_readme, path.name)
163204

164205

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

181-
validate_wheel(wheels[0], expected_version, expected_package_files())
182-
validate_sdist(sdists[0], expected_version)
222+
expected_readme = read_expected_readme()
223+
validate_wheel(wheels[0], expected_version, expected_package_files(), expected_readme)
224+
validate_sdist(sdists[0], expected_version, expected_readme)
183225
print(f"Validated {PACKAGE_NAME} {expected_version}: wheel, sdist, metadata, package data, and test boundary.")
184226

185227

‎scripts/validate_release_docs.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
"""Validate release-facing documentation before building a tagged package."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import re
9+
from pathlib import Path
10+
11+
VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+)+(?:[-+][0-9A-Za-z.-]+)?$")
12+
RELEASE_ROW_PATTERN = re.compile(
13+
r"^\| `(?P<version>[^`]+)` \| \[Apache-2\.0\]\(LICENSE\) \| "
14+
r"`python -m pip install base-cli` \| \[v(?P<link_version>[^]]+)\]"
15+
r"\(https://github\.com/basefoundry/base-cli/releases/tag/v(?P<tag_version>[^)]+)\) \|$"
16+
)
17+
18+
19+
def validate_release_docs(version_path: Path, readme_path: Path, tag: str = "") -> list[str]:
20+
"""Return release documentation violations for *version_path* and README."""
21+
22+
errors: list[str] = []
23+
version = version_path.read_text(encoding="utf-8").strip()
24+
if not VERSION_PATTERN.fullmatch(version):
25+
errors.append(f"VERSION is not a valid release version: {version!r}")
26+
return errors
27+
expected_tag = f"v{version}"
28+
if tag and tag != expected_tag:
29+
errors.append(f"release tag {tag!r} does not match VERSION {version!r}")
30+
31+
readme = readme_path.read_text(encoding="utf-8")
32+
rows = [RELEASE_ROW_PATTERN.match(line) for line in readme.splitlines()]
33+
matching_rows = [match for match in rows if match is not None]
34+
if len(matching_rows) != 1:
35+
errors.append("README.md must contain exactly one canonical release row")
36+
else:
37+
row = matching_rows[0]
38+
assert row is not None
39+
values = (row.group("version"), row.group("link_version"), row.group("tag_version"))
40+
if values != (version, version, version):
41+
errors.append(f"README.md release row versions {values!r} do not match {version!r}")
42+
43+
lowered = readme.casefold()
44+
for forbidden in ("source release candidate", "publication is pending"):
45+
if forbidden in lowered:
46+
errors.append(f"README.md still contains pre-publication wording: {forbidden!r}")
47+
return errors
48+
49+
50+
def main() -> None:
51+
parser = argparse.ArgumentParser(description=__doc__)
52+
parser.add_argument("--version-file", type=Path, default=Path("VERSION"))
53+
parser.add_argument("--readme", type=Path, default=Path("README.md"))
54+
parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG", ""))
55+
args = parser.parse_args()
56+
errors = validate_release_docs(args.version_file, args.readme, args.tag)
57+
if errors:
58+
for error in errors:
59+
print(f"release documentation validation failed: {error}")
60+
raise SystemExit(1)
61+
version = args.version_file.read_text(encoding="utf-8").strip()
62+
print(f"Validated release-facing documentation for {args.tag or version}.")
63+
64+
65+
if __name__ == "__main__":
66+
main()

0 commit comments

Comments
 (0)