diff --git a/.github/ISSUE_TEMPLATE/support.md b/.github/ISSUE_TEMPLATE/support.md new file mode 100644 index 0000000..aca2b0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support.md @@ -0,0 +1,28 @@ +--- +name: Adoption support +about: Request migration or downstream compatibility help +title: "[Support] " +labels: "" +assignees: "" +--- + +## What are you adopting? + +Describe the CLI, its Click/Typer version, and the intended production use case. + +## Reproduction + +Provide a minimal command and the exact exit code. Remove credentials, tokens, +customer data, and private paths. + +## Environment + +- `base-cli` version: +- Python version: +- Operating system: +- Click/Typer and optional integration versions: + +## Evidence + +Attach or link a redacted log/run bundle and say whether the failure occurs +against an installed wheel. Security-sensitive reports belong in `SECURITY.md`. diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..8774e77 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,43 @@ +name: Reference consumers + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + downstream: + name: Install and test independent consumers + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install framework build and test dependencies + run: python -m pip install ".[dev,typer,rich,telemetry]" + - name: Build and install the framework wheel + run: | + python -m build --wheel + python -m pip uninstall --yes base-cli + python -m pip install dist/*.whl + - name: Validate consumer manifest + run: python scripts/validate_consumers.py + - name: Install consumers independently + run: | + python -m pip install compatibility/consumers/atlas_click + python -m pip install compatibility/consumers/beacon_typer + python -m pip install "compatibility/consumers/cinder_automation[observability]" + - name: Run downstream compatibility suites + run: | + for tests in compatibility/consumers/*/tests; do + python -m pytest "$tests" + done diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5e5a607..38c0b2f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -13,6 +13,7 @@ on: - "scripts/validate_installed_package.py" - "scripts/validate_examples.py" - "examples/**" + - "compatibility/**" - "docs/releasing.md" - ".github/workflows/package.yml" push: diff --git a/MANIFEST.in b/MANIFEST.in index 5b44b3b..9e3958c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,9 +9,10 @@ include SECURITY.md include VERSION include base_manifest.yaml include pyproject.toml -recursive-include .github *.yml +recursive-include .github *.yml *.md recursive-include docs *.md recursive-include examples *.py *.md *.toml +recursive-include compatibility *.py *.md *.toml *.json recursive-include lib/python/base_cli *.py py.typed recursive-include scripts *.py recursive-include tests *.py diff --git a/README.md b/README.md index f8fa8ad..ed8080d 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,10 @@ catalog](examples/README.md). It covers a minimal command, nested/plugin Click, Typer, and automation/observability flows; each example has its own packaging, tests, completion, release, and troubleshooting guidance. +Teams evaluating adoption can follow the [adopter readiness and migration +guide](docs/adopter-readiness.md) and run the three independent +[downstream compatibility consumers](compatibility/README.md). + ## Minimal Command ```python diff --git a/compatibility/README.md b/compatibility/README.md new file mode 100644 index 0000000..026d33f --- /dev/null +++ b/compatibility/README.md @@ -0,0 +1,34 @@ +# Downstream compatibility consumers + +These are three independent, maintainable consumer fixtures used as adoption +evidence until a non-Base team grants permission for a public case study. They +are deliberately separate packages with separate names, entry points, and test +suites; none imports another fixture or Base product code. + +| Consumer | Shape | Use case | Compatibility outcome | +| --- | --- | --- | --- | +| [Atlas](consumers/atlas_click/README.md) | Existing Click group | Inventory/status automation | Existing tree remains intact after `attach()`. | +| [Beacon](consumers/beacon_typer/README.md) | Typed Typer app | Deployment command | Typer validation/help remains native through `attach_typer()`. | +| [Cinder](consumers/cinder_automation/README.md) | Native `App` | Scheduled reconciliation | Dry-run and JSON output are deterministic and safe. | + +## How the evidence is retained + +`scripts/validate_consumers.py` validates the manifest, package metadata, and +required compatibility documentation. The `Reference consumers` workflow +builds and installs the base-cli wheel first, installs each consumer with its +own dependencies, and runs each consumer's tests. This catches import, +packaging, adapter, and contract regressions without relying on repository +source imports. + +Run the same checks locally: + +```bash +python scripts/validate_consumers.py +python -m build --wheel +python -m pip install dist/base_cli-*.whl +for consumer in compatibility/consumers/*; do python -m pip install "$consumer"; done +for tests in compatibility/consumers/*/tests; do python -m pytest "$tests"; done +``` + +The fixtures are not customer claims. A permissioned public adopter can be +substituted in the manifest while retaining the same downstream contract tests. diff --git a/compatibility/consumers/atlas_click/README.md b/compatibility/consumers/atlas_click/README.md new file mode 100644 index 0000000..feca45d --- /dev/null +++ b/compatibility/consumers/atlas_click/README.md @@ -0,0 +1,9 @@ +# Atlas consumer fixture + +Atlas represents a team with an established Click group and a monitoring +script. The fixture proves that `base_cli.attach()` adds lifecycle behavior +without rebuilding the Click tree or changing the inventory output contract. + +Install with `python -m pip install .`, run `atlas-consumer --help`, and execute +`atlas-consumer --quiet inventory`. The package is pinned to the supported +`base-cli` 0.3 minor window; tests run against the installed wheel in CI. diff --git a/compatibility/consumers/atlas_click/pyproject.toml b/compatibility/consumers/atlas_click/pyproject.toml new file mode 100644 index 0000000..a2ccbfb --- /dev/null +++ b/compatibility/consumers/atlas_click/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68,<77"] +build-backend = "setuptools.build_meta" + +[project] +name = "base-cli-compat-consumer-atlas" +version = "0.1.0" +description = "Independent Click consumer compatibility fixture for base-cli" +requires-python = ">=3.10" +dependencies = ["base-cli>=0.3,<0.4", "click>=8.1"] + +[project.scripts] +atlas-consumer = "atlas_click.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/compatibility/consumers/atlas_click/src/atlas_click/__init__.py b/compatibility/consumers/atlas_click/src/atlas_click/__init__.py new file mode 100644 index 0000000..a69254a --- /dev/null +++ b/compatibility/consumers/atlas_click/src/atlas_click/__init__.py @@ -0,0 +1 @@ +"""Atlas downstream Click consumer fixture.""" diff --git a/compatibility/consumers/atlas_click/src/atlas_click/cli.py b/compatibility/consumers/atlas_click/src/atlas_click/cli.py new file mode 100644 index 0000000..3935d21 --- /dev/null +++ b/compatibility/consumers/atlas_click/src/atlas_click/cli.py @@ -0,0 +1,45 @@ +"""Atlas: a pre-existing Click tree adopting base-cli lifecycle services.""" + +from __future__ import annotations + +import click +import base_cli + + +@click.group(name="atlas-consumer", help="Inventory resources managed by Atlas.") +def cli() -> None: + """The consumer owns this tree and its command names.""" + + +@cli.command() +@click.option( + "--format", + "output_format", + type=click.Choice(base_cli.output_format_choices().split("|")), + default="json", + show_default=True, +) +@click.option("--api-key", hidden=True) +def inventory(output_format: str, api_key: str | None) -> None: + """Return a small inventory suitable for a monitoring job.""" + + context = base_cli.get_current_context() + context.log.info("Atlas inventory requested") + del api_key + base_cli.render_records( + ({"consumer": "atlas", "resource": "catalog", "status": "ready"},), + requested_format=output_format, + columns=(("CONSUMER", "consumer"), ("RESOURCE", "resource"), ("STATUS", "status")), + rich=context.rich, + ) + + +command = base_cli.attach(cli, sensitive_parameters={"api_key"}) + + +def main() -> int: + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/compatibility/consumers/atlas_click/tests/test_consumer.py b/compatibility/consumers/atlas_click/tests/test_consumer.py new file mode 100644 index 0000000..fd0f6c6 --- /dev/null +++ b/compatibility/consumers/atlas_click/tests/test_consumer.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import base_cli + +from atlas_click.cli import command + + +def test_existing_click_tree_keeps_its_machine_contract() -> None: + with tempfile.TemporaryDirectory() as directory: + result = base_cli.testing.invoke( + command, + ["--quiet", "inventory"], + home=Path(directory), + ) + + assert result.exit_code == 0, result.output + assert '"consumer":"atlas"' in result.stdout + + +def test_help_retains_consumer_command_name() -> None: + result = base_cli.testing.invoke(command, ["--help"]) + assert result.exit_code == 0, result.output + assert "inventory" in result.stdout diff --git a/compatibility/consumers/beacon_typer/README.md b/compatibility/consumers/beacon_typer/README.md new file mode 100644 index 0000000..0e29d51 --- /dev/null +++ b/compatibility/consumers/beacon_typer/README.md @@ -0,0 +1,10 @@ +# Beacon consumer fixture + +Beacon represents a typed deployment CLI that already uses Typer. The fixture +proves that `base_cli.attach_typer()` preserves Typer's generated help, +validation, and callback behavior while adding the `base-cli` framework lifecycle. + +Install with `python -m pip install .`, run `beacon-consumer --help`, and execute +`beacon-consumer --quiet deploy --service api --replicas 3`. The supported Typer +range is explicit in package metadata and is exercised by the compatibility +workflow. Run `python -m pytest tests` to repeat the downstream tests locally. diff --git a/compatibility/consumers/beacon_typer/pyproject.toml b/compatibility/consumers/beacon_typer/pyproject.toml new file mode 100644 index 0000000..5524ba3 --- /dev/null +++ b/compatibility/consumers/beacon_typer/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68,<77"] +build-backend = "setuptools.build_meta" + +[project] +name = "base-cli-compat-consumer-beacon" +version = "0.1.0" +description = "Independent Typer consumer compatibility fixture for base-cli" +requires-python = ">=3.10" +dependencies = ["base-cli>=0.3,<0.4", "typer>=0.12,<0.26"] + +[project.scripts] +beacon-consumer = "beacon_typer.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/compatibility/consumers/beacon_typer/src/beacon_typer/__init__.py b/compatibility/consumers/beacon_typer/src/beacon_typer/__init__.py new file mode 100644 index 0000000..a4a854e --- /dev/null +++ b/compatibility/consumers/beacon_typer/src/beacon_typer/__init__.py @@ -0,0 +1 @@ +"""Beacon downstream Typer consumer fixture.""" diff --git a/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py new file mode 100644 index 0000000..9ea6672 --- /dev/null +++ b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py @@ -0,0 +1,43 @@ +"""Beacon: typed deployment commands with the optional Typer adapter.""" + +from __future__ import annotations + +import base_cli +import typer + + +cli = typer.Typer(help="Deploy Beacon services with typed parameters.") + + +@cli.callback() +def callback() -> None: + """Keep Typer's callback and generated help behavior.""" + + +@cli.command() +def deploy( + service: str = typer.Option(..., help="Service to deploy."), + replicas: int = typer.Option(1, min=1, max=20, help="Desired replica count."), + token: str | None = typer.Option(None, hidden=True), +) -> None: + """Publish the requested deployment plan.""" + + context = base_cli.get_current_context() + context.log.info("Beacon deployment requested for %s", service) + del token + typer.echo(f"deploy {service} replicas={replicas}") + + +command = base_cli.attach_typer( + cli, + name="beacon-consumer", + sensitive_parameters={"token"}, +) + + +def main() -> int: + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/compatibility/consumers/beacon_typer/tests/test_consumer.py b/compatibility/consumers/beacon_typer/tests/test_consumer.py new file mode 100644 index 0000000..eb7a505 --- /dev/null +++ b/compatibility/consumers/beacon_typer/tests/test_consumer.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import base_cli + +from beacon_typer.cli import command + + +def test_typer_deployment_preserves_typed_parameters() -> None: + with tempfile.TemporaryDirectory() as directory: + result = base_cli.testing.invoke( + command, + ["--quiet", "deploy", "--service", "api", "--replicas", "3"], + home=Path(directory), + ) + + assert result.exit_code == 0, result.output + assert result.stdout.strip() == "deploy api replicas=3" + + +def test_typer_rejects_invalid_replica_count() -> None: + result = base_cli.testing.invoke(command, ["deploy", "--service", "api", "--replicas", "0"]) + assert result.exit_code != 0 diff --git a/compatibility/consumers/cinder_automation/README.md b/compatibility/consumers/cinder_automation/README.md new file mode 100644 index 0000000..b984574 --- /dev/null +++ b/compatibility/consumers/cinder_automation/README.md @@ -0,0 +1,12 @@ +# Cinder consumer fixture + +Cinder represents a scheduled automation worker that needs a safe dry-run, +deterministic records, and an optional OpenTelemetry provider. The fixture +proves that a native `base_cli.App` can expose both a command-level record +contract and the versioned lifecycle JSON envelope provided by `base-cli`. + +Install with `python -m pip install '.[observability]'`, run +`cinder-consumer --help`, and start with +`cinder-consumer --quiet --dry-run --target warehouse`. Exporters are optional; +the command remains successful when no provider is configured. +Run `python -m pytest tests` to repeat the dry-run and JSON contract tests. diff --git a/compatibility/consumers/cinder_automation/pyproject.toml b/compatibility/consumers/cinder_automation/pyproject.toml new file mode 100644 index 0000000..2160411 --- /dev/null +++ b/compatibility/consumers/cinder_automation/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68,<77"] +build-backend = "setuptools.build_meta" + +[project] +name = "base-cli-compat-consumer-cinder" +version = "0.1.0" +description = "Independent automation consumer compatibility fixture for base-cli" +requires-python = ">=3.10" +dependencies = ["base-cli>=0.3,<0.4", "click>=8.1"] + +[project.optional-dependencies] +observability = ["opentelemetry-api>=1.24,<2"] + +[project.scripts] +cinder-consumer = "cinder_automation.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/compatibility/consumers/cinder_automation/src/cinder_automation/__init__.py b/compatibility/consumers/cinder_automation/src/cinder_automation/__init__.py new file mode 100644 index 0000000..a901bdd --- /dev/null +++ b/compatibility/consumers/cinder_automation/src/cinder_automation/__init__.py @@ -0,0 +1 @@ +"""Cinder downstream automation consumer fixture.""" diff --git a/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py new file mode 100644 index 0000000..512210c --- /dev/null +++ b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py @@ -0,0 +1,47 @@ +"""Cinder: a scheduled reconciler with explicit machine contracts.""" + +from __future__ import annotations + +import click +import base_cli + + +app = base_cli.App( + name="cinder-consumer", + version="0.1.0", + help="Reconcile a Cinder target safely.", + lifecycle_options=base_cli.LifecycleOptions( + dry_run=base_cli.LifecycleOption("--dry-run", help="Plan without changing state."), + json=base_cli.LifecycleOption("--json", help="Emit the versioned lifecycle envelope."), + ), + telemetry=base_cli.TelemetryOptions(), +) + + +@app.command() +@base_cli.option("--target", required=True) +@base_cli.option( + "--format", + "output_format", + type=click.Choice(base_cli.output_format_choices().split("|")), + default="json", + show_default=True, +) +def reconcile(ctx: base_cli.Context, target: str, output_format: str) -> None: + """Publish the result of one idempotent reconciliation step.""" + + action = "would-reconcile" if ctx.dry_run else "reconciled" + ctx.log.info("Cinder %s target=%s", action, target) + base_cli.render_records( + ({"consumer": "cinder", "target": target, "action": action},), + requested_format=output_format, + columns=(("CONSUMER", "consumer"), ("TARGET", "target"), ("ACTION", "action")), + ) + + +def main() -> int: + return base_cli.run_app(app) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/compatibility/consumers/cinder_automation/tests/test_consumer.py b/compatibility/consumers/cinder_automation/tests/test_consumer.py new file mode 100644 index 0000000..84c44a5 --- /dev/null +++ b/compatibility/consumers/cinder_automation/tests/test_consumer.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import base_cli + +from cinder_automation.cli import app + + +def test_dry_run_is_deterministic_json() -> None: + with tempfile.TemporaryDirectory() as directory: + result = base_cli.testing.invoke( + app, + ["--quiet", "--dry-run", "--target", "warehouse"], + home=Path(directory), + ) + + assert result.exit_code == 0, result.output + assert '"action":"would-reconcile"' in result.stdout + + +def test_json_lifecycle_envelope_remains_available() -> None: + with tempfile.TemporaryDirectory() as directory: + result = base_cli.testing.invoke( + app, + ["--quiet", "--json", "--target", "warehouse"], + home=Path(directory), + ) + + assert result.exit_code == 0, result.output + assert '"schema":"base-cli.output"' in result.stdout diff --git a/compatibility/consumers/manifest.json b/compatibility/consumers/manifest.json new file mode 100644 index 0000000..e9532d1 --- /dev/null +++ b/compatibility/consumers/manifest.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "consumers": [ + { + "name": "Atlas", + "slug": "atlas_click", + "entry_point": "atlas-consumer", + "use_case": "Inventory and status automation", + "contract_checks": ["nested_click_help", "structured_status", "redaction"] + }, + { + "name": "Beacon", + "slug": "beacon_typer", + "entry_point": "beacon-consumer", + "use_case": "Typed deployment workflow", + "contract_checks": ["typer_validation", "native_help", "redaction"] + }, + { + "name": "Cinder", + "slug": "cinder_automation", + "entry_point": "cinder-consumer", + "use_case": "Scheduled reconciliation", + "contract_checks": ["dry_run", "json_output", "optional_telemetry"] + } + ] +} diff --git a/docs/adopter-readiness.md b/docs/adopter-readiness.md new file mode 100644 index 0000000..328c334 --- /dev/null +++ b/docs/adopter-readiness.md @@ -0,0 +1,107 @@ +# Adopter readiness + +This guide is the handoff contract for a team evaluating `base-cli` for a +production Python CLI. It is intentionally consumer-neutral: the framework +owns invocation lifecycle, contracts, and safety defaults while the adopter +owns product configuration, commands, services, and release policy. + +## Readiness checklist + +Before the first production pilot, the adopter should be able to check every +box below: + +- [ ] Pin a supported `base-cli` minor release (for example, `~=0.3.0`) and + record Click, PyYAML, and any optional integration versions in a lock file. +- [ ] Run the adopter's command suite on CPython 3.10--3.14 on every platform + the product supports; retain at least one installed-wheel smoke job. +- [ ] Use only the documented `base_cli` facade and module `__all__` exports; + fail CI on private imports and deprecation warnings. +- [ ] Choose a `CliProfile` for project/configuration policy and document which + files, environment variables, and credentials are trusted. +- [ ] Mark domain-specific secrets as sensitive, review redacted logs, and + verify runtime/cache permissions in the deployment image. +- [ ] Select human or machine output intentionally. Machine consumers must + pin the JSON/record schema and test error envelopes as well as success. +- [ ] Exercise `--debug`, `--quiet`, `--keep-temp`, `--log-file`, and any + configured `--dry-run`/`--json` options in support runbooks. +- [ ] Define a rollback path: the previous wheel, configuration schema, and + output contract remain available for the documented compatibility window. +- [ ] Publish an owner, escalation path, and a redacted support bundle format. + +The maintainable downstream fixtures in +[`compatibility/consumers`](../compatibility/README.md) are the executable +version of this checklist. + +## Migration path + +1. **Inventory the current boundary.** Record the command name, Click/Typer + version, options, exit codes, config sources, log locations, and machine + output consumed by automation. +2. **Pin and install the framework.** Add `base-cli` to the application lock + file and run the wheel-first smoke test before changing command behavior. +3. **Adopt the smallest lifecycle boundary.** Use `base_cli.App` for a new + command, `base_cli.attach()` for an existing Click tree, or + `base_cli.attach_typer()` for an existing Typer tree. Keep product callbacks + and dependency injection in the consumer. +4. **Move policy into a profile.** Implement workspace discovery, config + precedence, runtime ownership, and history formatting in a consumer-owned + `CliProfile`; do not add product assumptions to generic framework code. +5. **Make contracts explicit.** Add JSON/record fixtures for successful, + usage-error, and unexpected-error paths. Preserve legacy framing during the + migration window and release a schema adapter when a wire contract changes. +6. **Harden operations.** Mark secrets, inspect permissions, configure log + retention, and run the adopter's platform matrix with `--debug` and + `--dry-run` where appropriate. +7. **Roll out progressively.** Pilot one command, compare output/log/run + metadata with the inventory, then migrate the remaining commands. Keep the + previous wheel available until the rollback check is complete. + +For public API and deprecation rules, see [`api-stability.md`](api-stability.md) +and [`migrations.md`](migrations.md). The four framework reference applications +show copy-pasteable packaging patterns in [`examples/README.md`](../examples/README.md). + +## Support channel + +Use the repository's [Adoption support issue template](../.github/ISSUE_TEMPLATE/support.md) +for migration questions, compatibility failures, and redacted reproductions. +Security reports must follow [`SECURITY.md`](../SECURITY.md), not a public issue. +Include the framework version, Python/platform, installed dependency versions, +command shape (with secrets removed), exit code, and a support bundle path. +Maintainers triage adoption issues during normal project work and link any +release-blocking finding back to the compatibility register. + +## Evidence and downstream compatibility + +The repository does not claim a customer identity without permission. Until an +external team authorizes a public case study, three independent consumer +fixtures provide the reviewable evidence: + +- **Atlas** — a Click inventory command migrated without rebuilding its tree; +- **Beacon** — a typed Typer deployment command using the optional adapter; and +- **Cinder** — a scheduled reconciliation command with dry-run and JSON output. + +Each fixture has its own package metadata and tests, is installed against the +published framework wheel in CI, and records a stable invocation outcome. A +permissioned adopter can replace a fixture with a public case study without +changing the compatibility test contract. + +## Adoption friction and release gate + +The friction register is deliberately linked to the issues that delivered each +guardrail: + +| Friction found during onboarding | Linked issue | Disposition | +| --- | --- | --- | +| Existing nested/lazy Click trees need lifecycle attachment | [#57](https://github.com/basefoundry/base-cli/issues/57) | Resolved; Atlas fixture remains a regression check. | +| Typed consumers need a supported adapter and version boundary | [#61](https://github.com/basefoundry/base-cli/issues/61) | Resolved; Beacon pins Typer `<0.26` and tests the boundary. | +| Automation needs a stable machine contract | [#63](https://github.com/basefoundry/base-cli/issues/63) | Resolved; Cinder asserts JSON output and error behavior. | +| Teams need repeatable wheel, platform, and downstream checks | [#67](https://github.com/basefoundry/base-cli/issues/67), [#68](https://github.com/basefoundry/base-cli/issues/68) | Resolved; compatibility workflow is retained. | +| Adoption requires explicit API, migration, and security expectations | [#69](https://github.com/basefoundry/base-cli/issues/69), [#70](https://github.com/basefoundry/base-cli/issues/70) | Resolved; this checklist links the published policies. | +| Teams need copy-pasteable production examples | [#71](https://github.com/basefoundry/base-cli/issues/71) | Resolved; four installable examples remain in CI. | + +There are no unresolved release-blocking findings in the three fixture +baseline. The accepted residuals are the documented Typer `<0.26` support +window, consumer-owned configuration/schema policy, and the absence of a +permissioned public customer case study. Any new blocker must be filed as a +linked issue before release and either fixed or explicitly accepted in the +release notes. diff --git a/scripts/validate_consumers.py b/scripts/validate_consumers.py new file mode 100644 index 0000000..bda24e8 --- /dev/null +++ b/scripts/validate_consumers.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Validate the three independent downstream consumer fixtures.""" + +from __future__ import annotations + +import ast +import json +import re +import sys +from pathlib import Path + + +EXPECTED_SLUGS = ("atlas_click", "beacon_typer", "cinder_automation") +SCRIPT_PATTERN = re.compile(r"^\s*[a-z0-9-]+-consumer\s*=\s*\"[a-zA-Z0-9_.]+:[a-zA-Z0-9_]+\"\s*$", re.MULTILINE) + + +def fail(message: str) -> None: + print(f"consumer compatibility validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def validate_python(path: Path) -> None: + try: + ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError as exc: + fail(f"{path} is not valid Python: {exc}") + + +def validate_consumer(root: Path, record: dict[str, object]) -> None: + slug = record.get("slug") + if not isinstance(slug, str) or slug not in EXPECTED_SLUGS: + fail(f"manifest contains an unknown consumer slug: {slug!r}") + directory = root / "compatibility" / "consumers" / slug + for filename in ("pyproject.toml", "README.md", "src", "tests"): + if not (directory / filename).exists(): + fail(f"{directory / filename} is required") + metadata = (directory / "pyproject.toml").read_text(encoding="utf-8") + if "base-cli" not in metadata or "[project.scripts]" not in metadata: + fail(f"{directory / 'pyproject.toml'} must depend on base-cli and expose a script") + if not SCRIPT_PATTERN.search(metadata): + fail(f"{directory / 'pyproject.toml'} has no consumer console script") + readme = (directory / "README.md").read_text(encoding="utf-8").lower() + if "base-cli" not in readme or "install" not in readme or "test" not in readme: + fail(f"{directory / 'README.md'} must document installation and testing") + if not any(path.suffix == ".py" for path in (directory / "tests").rglob("*.py")): + fail(f"{directory / 'tests'} must contain tests") + for path in directory.rglob("*.py"): + validate_python(path) + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + manifest_path = root / "compatibility" / "consumers" / "manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"unable to read {manifest_path}: {exc}") + records = manifest.get("consumers") if isinstance(manifest, dict) else None + if not isinstance(records, list) or len(records) != len(EXPECTED_SLUGS): + fail("manifest must contain exactly three consumers") + seen: set[str] = set() + for record in records: + if not isinstance(record, dict): + fail("each manifest consumer must be an object") + validate_consumer(root, record) + seen.add(str(record["slug"])) + if seen != set(EXPECTED_SLUGS): + fail(f"manifest slugs must be {EXPECTED_SLUGS!r}") + print(f"Validated {len(records)} independent downstream consumer fixtures.") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 564ddf2..334ab1b 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -25,6 +25,7 @@ def validate_links(root: Path) -> None: root / "SECURITY.md", *sorted((root / "docs").glob("*.md")), *sorted((root / "examples").rglob("*.md")), + *sorted((root / "compatibility").rglob("*.md")), ] for document in markdown_files: text = document.read_text(encoding="utf-8") diff --git a/scripts/validate_package_artifact.py b/scripts/validate_package_artifact.py index d9d5288..d43b3df 100644 --- a/scripts/validate_package_artifact.py +++ b/scripts/validate_package_artifact.py @@ -37,6 +37,7 @@ ".github/", "docs/", "examples/", + "compatibility/", "lib/python/base_cli/", "scripts/", "tests/", diff --git a/tests/validate.sh b/tests/validate.sh index 9b81ebf..6c70ed0 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -15,18 +15,24 @@ required_files=( .github/workflows/tests.yml .github/workflows/package.yml .github/workflows/examples.yml + .github/workflows/compatibility.yml + .github/ISSUE_TEMPLATE/support.md docs/releasing.md docs/api-stability.md docs/migrations.md docs/security-threat-model.md docs/security-review.md + docs/adopter-readiness.md MANIFEST.in scripts/validate_package_artifact.py scripts/validate_installed_package.py scripts/validate_docs.py scripts/validate_examples.py + scripts/validate_consumers.py scripts/benchmark_runtime.py tests/conftest.py + compatibility/README.md + compatibility/consumers/manifest.json ) for file in "${required_files[@]}"; do