diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..361ad45 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,168 @@ +name: Package + +on: + pull_request: + paths: + - "lib/python/base_cli/**" + - "pyproject.toml" + - "VERSION" + - "README.md" + - "LICENSE" + - "scripts/validate_package_artifact.py" + - "docs/releasing.md" + - ".github/workflows/package.yml" + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + inputs: + publish_target: + description: "Protected publication target" + required: true + type: choice + options: + - testpypi + - pypi + default: testpypi + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PACKAGE_NAME: base-cli + +jobs: + build: + name: Build and validate distributions + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.metadata.outputs.version }} + steps: + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + + - name: Read package version + id: metadata + run: echo "version=$(tr -d '\\r\\n' < VERSION)" >> "$GITHUB_OUTPUT" + + - name: Validate release ref + env: + PUBLISH_TARGET: ${{ inputs.publish_target || '' }} + run: | + if [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" != "v${{ steps.metadata.outputs.version }}" ]]; then + echo "Release tag must be v${{ steps.metadata.outputs.version }}; got $GITHUB_REF_NAME" >&2 + exit 1 + fi + if [[ "$PUBLISH_TARGET" == "pypi" && "$GITHUB_REF_TYPE" != "tag" ]]; then + echo "PyPI publication requires dispatching this workflow from the matching version tag." >&2 + exit 1 + fi + + - name: Validate repository baseline + run: ./tests/validate.sh + + - name: Install build and validation tools + run: python -m pip install --upgrade build twine + + - name: Build sdist and wheel + run: python -m build --sdist --wheel --outdir dist + + - name: Validate artifact contents and metadata + run: python scripts/validate_package_artifact.py dist + + - name: Validate package indexes + run: python -m twine check dist/* + + - name: Upload reviewed distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + smoke: + name: Install smoke test (Python ${{ matrix.python-version }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: ${{ matrix.python-version }} + + - name: Download reviewed distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist + + - name: Install wheel and runtime dependencies + run: python -m pip install dist/base_cli-*.whl + + - name: Verify installed package + env: + EXPECTED_VERSION: ${{ needs.build.outputs.version }} + run: | + python - <<'PY' + import base_cli + import importlib.metadata + + expected = __import__("os").environ["EXPECTED_VERSION"] + assert base_cli.__version__ == expected, (base_cli.__version__, expected) + assert importlib.metadata.version("base-cli") == expected + assert hasattr(base_cli, "App") + print(f"base-cli {base_cli.__version__} installed successfully") + PY + + publish: + name: Publish reviewed distribution + needs: [build, smoke] + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: ${{ github.event_name == 'push' && 'pypi' || inputs.publish_target }} + url: ${{ github.event_name == 'push' && 'https://pypi.org/p/base-cli' || 'https://test.pypi.org/p/base-cli' }} + permissions: + contents: read + id-token: write + steps: + - name: Download reviewed distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist + + - name: Publish to TestPyPI + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_target == 'testpypi' }} + uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74 + with: + packages-dir: dist + repository-url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi') }} + uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74 + with: + packages-dir: dist diff --git a/CHANGELOG.md b/CHANGELOG.md index b30c435..4c75410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,7 @@ and versions are tracked in the repo-root `VERSION` file. ### Added - 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. diff --git a/README.md b/README.md index 78cc54e..4004f49 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Install it with: python -m pip install base-cli ``` +Release builds, TestPyPI rehearsals, and protected PyPI publication are +documented in [`docs/releasing.md`](docs/releasing.md). The package exposes +`base_cli.__version__`, which matches the distribution version. + The package is distributed under the Apache License 2.0. Base itself remains licensed separately under AGPL-3.0-or-later. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..2ddaf6f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,69 @@ +# Releasing `base-cli` + +The `base-cli` distribution is built and published from the standalone +`basefoundry/base-cli` repository. The package name on PyPI is `base-cli`; the +Python import name is `base_cli`. + +## Version and tag contract + +`VERSION` is the release version source of truth. The build backend reads it for +the wheel and sdist metadata, and `base_cli.__version__` reports the same value +from a source checkout or from installed distribution metadata. + +Production releases use a matching annotated-style tag such as `v0.1.0`. +The Package workflow rejects a tag that does not exactly match `v${VERSION}`. + +## Validation workflow + +Pull requests and pushes to `main` build one sdist and one wheel, run `twine +check`, inspect metadata and package data, and install the reviewed wheel across +Python 3.10 through 3.14. The publish job downloads that same artifact; it does +not rebuild during publication. + +## TestPyPI rehearsal + +1. Dispatch **Package** from the branch or tag to be rehearsed and choose + `testpypi`. +2. Approve the protected `testpypi` environment when prompted. +3. Verify the published artifact from a clean environment: + + ```bash + python -m venv /tmp/base-cli-smoke + /tmp/base-cli-smoke/bin/python -m pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + base-cli + /tmp/base-cli-smoke/bin/python -c \ + 'import base_cli; print(base_cli.__version__)' + ``` + +The `testpypi` GitHub environment must be configured with PyPI trusted +publishing for this repository and workflow before the dispatch can upload. + +## Production release + +1. Update `VERSION` and the changelog in a reviewed pull request. +2. Merge to `main` and create the matching `v${VERSION}` tag. +3. Approve the protected `pypi` environment. The workflow verifies the tag, + builds and tests the artifact, then publishes the exact artifact to PyPI via + trusted publishing. +4. Verify installation from PyPI: + + ```bash + python -m venv /tmp/base-cli-smoke + /tmp/base-cli-smoke/bin/python -m pip install --upgrade base-cli + /tmp/base-cli-smoke/bin/python -c \ + 'import base_cli; import importlib.metadata as m; assert base_cli.__version__ == m.version("base-cli"); print(base_cli.__version__)' + ``` + +The `pypi` GitHub environment must require approval and be configured with the +PyPI trusted publisher for `.github/workflows/package.yml`. No long-lived PyPI +token is stored in the repository. + +## Recovery + +PyPI versions cannot be overwritten. If validation fails, fix the branch and +rerun the workflow before creating a tag. If TestPyPI succeeds but a production +publish fails, inspect the workflow logs and rerun the same approved tag only +after confirming that neither artifact nor metadata needs correction. A version +that was published successfully must be incremented for the next release. diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index e92e540..d6440fc 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -1,5 +1,27 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError, version as distribution_version +from pathlib import Path + + +def _resolve_version() -> str: + """Return the checkout version or the installed distribution version.""" + + for parent in Path(__file__).resolve().parents: + version_file = parent / "VERSION" + if version_file.is_file(): + value = version_file.read_text(encoding="utf-8").splitlines()[0].strip() + if value: + return value + + try: + return distribution_version("base-cli") + except PackageNotFoundError: + return "0.0.0" + + +__version__ = _resolve_version() + from . import command_filters, command_protocol, history, testing from .app import App, argument, command, delegated_display_command, option, run_app from .command_filters import command_matches, normalize_command_filter, normalize_command_filters @@ -21,6 +43,7 @@ __all__ = [ "App", + "__version__", "CommandProtocolError", "Context", "ExitCode", diff --git a/pyproject.toml b/pyproject.toml index 5ff0d9e..68e9783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "base-cli" -version = "0.1.0" +dynamic = ["version"] description = "A small, consistent Python CLI framework for Base and Base-supported projects" readme = "README.md" requires-python = ">=3.10" @@ -47,6 +47,9 @@ package-dir = {"" = "lib/python"} [tool.setuptools.packages.find] where = ["lib/python"] +[tool.setuptools.dynamic] +version = {file = "VERSION"} + [tool.setuptools.package-data] base_cli = ["py.typed"] diff --git a/scripts/validate_package_artifact.py b/scripts/validate_package_artifact.py new file mode 100644 index 0000000..5e3371b --- /dev/null +++ b/scripts/validate_package_artifact.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Validate the distributions produced for a base-cli release.""" + +from __future__ import annotations + +import argparse +import email +import re +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import NoReturn + + +PACKAGE_NAME = "base-cli" +IMPORT_NAME = "base_cli" +MINIMUM_PYTHON = ">=3.10" +REQUIRED_DEPENDENCIES = ("click>=8.1", "PyYAML>=6.0") + + +def fail(message: str) -> NoReturn: + print(f"artifact validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def read_expected_version() -> str: + version_path = Path(__file__).resolve().parents[1] / "VERSION" + lines = version_path.read_text(encoding="utf-8").splitlines() + if not lines or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)+(?:[-+][0-9A-Za-z.-]+)?", lines[0].strip()): + fail(f"invalid VERSION file: {version_path}") + return lines[0].strip() + + +def validate_wheel(path: Path, expected_version: str) -> None: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + fail(f"{path.name} must contain exactly one dist-info METADATA file") + metadata = email.message_from_bytes(archive.read(metadata_names[0])) + + expected_headers = { + "Name": PACKAGE_NAME, + "Version": expected_version, + "License": "Apache-2.0", + "Requires-Python": MINIMUM_PYTHON, + } + 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}") + + dependencies = set(metadata.get_all("Requires-Dist", [])) + for dependency in REQUIRED_DEPENDENCIES: + if dependency not in dependencies: + fail(f"{path.name} is missing runtime dependency {dependency!r}") + + if f"{IMPORT_NAME}/py.typed" not in names: + fail(f"{path.name} does not contain {IMPORT_NAME}/py.typed") + if not any(name.endswith(".dist-info/licenses/LICENSE") for name in names): + fail(f"{path.name} does not contain the packaged LICENSE file") + if any(name.startswith("tests/") or f"/{IMPORT_NAME}/tests/" in name for name in names): + fail(f"{path.name} contains repository test files") + + +def validate_sdist(path: Path, expected_version: str) -> None: + with tarfile.open(path, "r:gz") as archive: + names = [member.name for member in archive.getmembers()] + required_suffixes = {"pyproject.toml", "README.md", "LICENSE", "VERSION"} + present_suffixes = {name.rsplit("/", 1)[-1] for name in names} + missing = required_suffixes - present_suffixes + if missing: + fail(f"{path.name} is missing sdist files: {', '.join(sorted(missing))}") + version_members = [member for member in archive.getmembers() if member.name.endswith("/VERSION")] + if len(version_members) != 1: + fail(f"{path.name} must contain exactly one VERSION file") + 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}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dist", type=Path, help="directory containing the built distributions") + args = parser.parse_args() + if not args.dist.is_dir(): + fail(f"distribution directory does not exist: {args.dist}") + + expected_version = read_expected_version() + wheels = sorted(args.dist.glob("*.whl")) + sdists = sorted(args.dist.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + fail(f"expected one wheel and one sdist, found {len(wheels)} wheel(s) and {len(sdists)} sdist(s)") + expected_stem = f"base_cli-{expected_version}" + 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) + validate_sdist(sdists[0], expected_version) + print(f"Validated {PACKAGE_NAME} {expected_version}: wheel, sdist, metadata, package data, and test boundary.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 750ca41..acdca96 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -8,6 +8,12 @@ class PublicApiTests(unittest.TestCase): + def test_version_matches_repository_contract(self) -> None: + from pathlib import Path + + version_file = Path(__file__).resolve().parents[1] / "VERSION" + self.assertEqual(base_cli.__version__, version_file.read_text(encoding="utf-8").splitlines()[0].strip()) + def test_facade_exports_supported_modules_functions_and_types(self) -> None: expected = { "CommandProtocolError", diff --git a/tests/validate.sh b/tests/validate.sh index 3b06005..102f9af 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -12,6 +12,9 @@ required_files=( .github/workflows/issue-branch-policy.yml .github/workflows/project-intake.yml .github/workflows/tests.yml + .github/workflows/package.yml + docs/releasing.md + scripts/validate_package_artifact.py ) for file in "${required_files[@]}"; do