From 71527174ee66e225cff2029b0877eb1cab85e906 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:03:47 +0530 Subject: [PATCH] release: prepare base-cli 0.5.1 --- .github/workflows/package.yml | 34 ++++++++++- CHANGELOG.md | 12 +++- README.md | 12 ++-- VERSION | 2 +- docs/adoption-evidence.md | 8 +-- docs/index.md | 2 +- docs/releasing.md | 16 ++++++ scripts/generate_release_metadata.py | 1 + scripts/validate_package_artifact.py | 50 ++++++++++++++-- scripts/validate_release_docs.py | 66 +++++++++++++++++++++ scripts/validate_release_metadata.py | 3 + scripts/verify_pypi_metadata.py | 76 +++++++++++++++++++++++++ tests/test_package_workflow.py | 12 +++- tests/test_validate_package_artifact.py | 6 +- tests/test_validate_release_docs.py | 49 ++++++++++++++++ tests/test_verify_pypi_metadata.py | 34 +++++++++++ tests/validate.sh | 8 +-- 17 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 scripts/validate_release_docs.py create mode 100644 scripts/verify_pypi_metadata.py create mode 100644 tests/test_validate_release_docs.py create mode 100644 tests/test_verify_pypi_metadata.py diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 30ffb32..d47ddb7 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -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" @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf531e..d3482a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index da2d9bc..617b52c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/VERSION b/VERSION index 8f0916f..4b9fcbe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1 diff --git a/docs/adoption-evidence.md b/docs/adoption-evidence.md index 2307006..46688ba 100644 --- a/docs/adoption-evidence.md +++ b/docs/adoption-evidence.md @@ -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 diff --git a/docs/index.md b/docs/index.md index b5e916b..35ef02d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index c8dfb73..5d4e4d2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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/). diff --git a/scripts/generate_release_metadata.py b/scripts/generate_release_metadata.py index 9c429e5..38e80f0 100644 --- a/scripts/generate_release_metadata.py +++ b/scripts/generate_release_metadata.py @@ -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"], diff --git a/scripts/validate_package_artifact.py b/scripts/validate_package_artifact.py index 1790593..c17b668 100644 --- a/scripts/validate_package_artifact.py +++ b/scripts/validate_package_artifact.py @@ -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")] @@ -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: @@ -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] @@ -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: @@ -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.") diff --git a/scripts/validate_release_docs.py b/scripts/validate_release_docs.py new file mode 100644 index 0000000..3ae7209 --- /dev/null +++ b/scripts/validate_release_docs.py @@ -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[^`]+)` \| \[Apache-2\.0\]\(LICENSE\) \| " + r"`python -m pip install base-cli` \| \[v(?P[^]]+)\]" + r"\(https://github\.com/basefoundry/base-cli/releases/tag/v(?P[^)]+)\) \|$" +) + + +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() diff --git a/scripts/validate_release_metadata.py b/scripts/validate_release_metadata.py index d606c86..568ecb3 100644 --- a/scripts/validate_release_metadata.py +++ b/scripts/validate_release_metadata.py @@ -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") diff --git a/scripts/verify_pypi_metadata.py b/scripts/verify_pypi_metadata.py new file mode 100644 index 0000000..f3005db --- /dev/null +++ b/scripts/verify_pypi_metadata.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Verify that PyPI exposes the README from the reviewed release source.""" + +from __future__ import annotations + +import argparse +import json +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +def _normalized(value: str) -> str: + return value.replace("\r\n", "\n").strip() + + +def validate_metadata(metadata: dict[str, Any], version: str, readme: str) -> list[str]: + """Return violations between PyPI metadata and the reviewed README.""" + + errors: list[str] = [] + info = metadata.get("info") + if not isinstance(info, dict): + return ["PyPI response has no info object"] + if info.get("version") != version: + errors.append(f"PyPI reports version {info.get('version')!r}, expected {version!r}") + description = info.get("description") + if not isinstance(description, str): + errors.append("PyPI response has no Markdown description") + elif _normalized(description) != _normalized(readme): + errors.append("PyPI description does not match the reviewed README.md") + if isinstance(description, str): + if f"| `{version}` |" not in description: + errors.append(f"PyPI description does not contain the {version} release row") + lowered = description.casefold() + for forbidden in ("source release candidate", "publication is pending"): + if forbidden in lowered: + errors.append(f"PyPI description still contains pre-publication wording: {forbidden!r}") + return errors + + +def _fetch(url: str, attempts: int, delay: float) -> dict[str, Any]: + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=20) as response: + value = json.load(response) + if not isinstance(value, dict): + raise ValueError("response is not a JSON object") + return value + except (OSError, ValueError, urllib.error.HTTPError) as exc: + last_error = exc + if attempt < attempts: + time.sleep(delay) + raise SystemExit(f"could not read {url} after {attempts} attempts: {last_error}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--readme", type=Path, default=Path("README.md")) + parser.add_argument("--attempts", type=int, default=12) + parser.add_argument("--delay", type=float, default=5.0) + args = parser.parse_args() + metadata = _fetch(f"https://pypi.org/pypi/base-cli/{args.version}/json", args.attempts, args.delay) + errors = validate_metadata(metadata, args.version, args.readme.read_text(encoding="utf-8")) + if errors: + for error in errors: + print(f"PyPI metadata validation failed: {error}") + raise SystemExit(1) + print(f"Verified PyPI metadata for base-cli {args.version}.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 88d0bee..38dd089 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -56,6 +56,16 @@ def test_package_workflow_uses_explicit_release_notes() -> None: ) +def test_package_workflow_gates_tag_publication_on_release_documentation() -> None: + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") + + assert "Validate release-facing documentation" in workflow + assert "python scripts/validate_release_docs.py" in workflow + assert "scripts/verify_pypi_metadata.py" in workflow + assert "name: Verify published PyPI metadata" in workflow + assert "needs: [build, smoke, provenance, publish, attest, verify_pypi]" in workflow + + def test_package_workflow_gates_writes_on_release_provenance() -> None: workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") jobs = yaml.safe_load(workflow)["jobs"] @@ -65,4 +75,4 @@ def test_package_workflow_gates_writes_on_release_provenance() -> None: assert "python scripts/validate_release_provenance.py" in workflow assert jobs["publish"]["needs"] == ["build", "smoke", "provenance"] assert jobs["attest"]["needs"] == ["build", "smoke", "provenance"] - assert jobs["release"]["needs"] == ["build", "smoke", "provenance", "publish", "attest"] + assert jobs["release"]["needs"] == ["build", "smoke", "provenance", "publish", "attest", "verify_pypi"] diff --git a/tests/test_validate_package_artifact.py b/tests/test_validate_package_artifact.py index d611aa3..004a5d2 100644 --- a/tests/test_validate_package_artifact.py +++ b/tests/test_validate_package_artifact.py @@ -4,8 +4,12 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from scripts.validate_package_artifact import REQUIRED_DEPENDENCIES +from scripts.validate_package_artifact import REQUIRED_DEPENDENCIES, normalized_text def test_artifact_validator_matches_declared_click_window() -> None: assert "click<8.6,>=8.1" in REQUIRED_DEPENDENCIES + + +def test_artifact_validator_normalizes_only_line_endings_and_outer_whitespace() -> None: + assert normalized_text("\r\nREADME\r\n") == "README" diff --git a/tests/test_validate_release_docs.py b/tests/test_validate_release_docs.py new file mode 100644 index 0000000..59150a7 --- /dev/null +++ b/tests/test_validate_release_docs.py @@ -0,0 +1,49 @@ +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.validate_release_docs import validate_release_docs + +VALID_README = """\ +# base-cli + +| Version | License | Install | Release notes | +| --- | --- | --- | --- | +| `1.2.3` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v1.2.3](https://github.com/basefoundry/base-cli/releases/tag/v1.2.3) | + +The 1.2.3 release is bound to the immutable `v1.2.3` tag. +""" + + +class ReleaseDocumentationValidationTests(unittest.TestCase): + def validate(self, readme: str = VALID_README, *, tag: str = "v1.2.3") -> list[str]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + version = root / "VERSION" + path = root / "README.md" + version.write_text("1.2.3\n", encoding="utf-8") + path.write_text(readme, encoding="utf-8") + return validate_release_docs(version, path, tag) + + def test_accepts_matching_published_release_documentation(self) -> None: + self.assertEqual(self.validate(), []) + + def test_rejects_stale_release_row(self) -> None: + errors = self.validate(VALID_README.replace("1.2.3", "1.2.2").replace("v1.2.3", "v1.2.2")) + self.assertTrue(any("do not match" in error for error in errors)) + + def test_rejects_prepublication_wording(self) -> None: + errors = self.validate(VALID_README + "Publication is pending the release checklist.\n") + self.assertTrue(any("pre-publication wording" in error for error in errors)) + + def test_rejects_tag_mismatch(self) -> None: + errors = self.validate(tag="v9.9.9") + self.assertTrue(any("does not match VERSION" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verify_pypi_metadata.py b/tests/test_verify_pypi_metadata.py new file mode 100644 index 0000000..cd4b515 --- /dev/null +++ b/tests/test_verify_pypi_metadata.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts.verify_pypi_metadata import validate_metadata + +README = """\ +# base-cli + +| `1.2.3` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v1.2.3](https://github.com/basefoundry/base-cli/releases/tag/v1.2.3) | +""" + + +class PyPIMetadataValidationTests(unittest.TestCase): + def test_accepts_matching_description(self) -> None: + metadata = {"info": {"version": "1.2.3", "description": README}} + self.assertEqual(validate_metadata(metadata, "1.2.3", README), []) + + def test_rejects_stale_description(self) -> None: + metadata = {"info": {"version": "1.2.3", "description": README.replace("1.2.3", "1.2.2")}} + errors = validate_metadata(metadata, "1.2.3", README) + self.assertTrue(any("does not match" in error for error in errors)) + + def test_rejects_prepublication_description(self) -> None: + metadata = {"info": {"version": "1.2.3", "description": README + "Source release candidate: 1.2.3."}} + errors = validate_metadata(metadata, "1.2.3", README) + self.assertTrue(any("pre-publication wording" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/validate.sh b/tests/validate.sh index 82cf60e..b717c32 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -37,6 +37,8 @@ required_files=( MANIFEST.in scripts/validate_package_artifact.py scripts/validate_installed_package.py + scripts/validate_release_docs.py + scripts/verify_pypi_metadata.py scripts/validate_docs.py scripts/generate_api_reference.py scripts/validate_examples.py @@ -60,8 +62,6 @@ for file in "${required_files[@]}"; do done version="$(head -n 1 VERSION | tr -d '\r')" -published_version="$(git tag --list 'v[0-9]*' --sort=-version:refname | sed -n '1p' | sed 's/^v//')" -published_version="${published_version:-$version}" readme_head="$(sed -n '1,18p' README.md | tr -d '\r')" if ! printf '%s\n' "$readme_head" | grep -F "[![Tests](https://img.shields.io/github/actions/workflow/status/basefoundry/base-cli/tests.yml?branch=main&label=tests)](https://github.com/basefoundry/base-cli/actions/workflows/tests.yml)" >/dev/null; then printf 'README.md is missing the main-branch tests health badge.\n' >&2 @@ -79,8 +79,8 @@ if ! printf '%s\n' "$readme_head" | grep -F "[![Python](https://img.shields.io/p printf 'README.md is missing the supported Python versions badge.\n' >&2 exit 1 fi -if ! printf '%s\n' "$readme_head" | grep -F "| \`$published_version\` | [Apache-2.0](LICENSE) | \`python -m pip install base-cli\` | [v$published_version](https://github.com/basefoundry/base-cli/releases/tag/v$published_version) |" >/dev/null; then - printf 'README.md release strip does not match the latest published tag (%s), license, install command, and release link.\n' "$published_version" >&2 +if ! printf '%s\n' "$readme_head" | grep -F "| \`$version\` | [Apache-2.0](LICENSE) | \`python -m pip install base-cli\` | [v$version](https://github.com/basefoundry/base-cli/releases/tag/v$version) |" >/dev/null; then + printf 'README.md release strip does not match VERSION (%s), license, install command, and release link.\n' "$version" >&2 exit 1 fi