diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..c7d2940 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,76 @@ +name: Compatibility + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + inputs: + upcoming_base_cli: + description: "Optional pip requirement suffix for an upcoming build, for example ==0.4.4rc1" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + supported: + name: ${{ matrix.base_cli.name }} / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + base_cli: + - name: minimum released + spec: "==0.4.3" + - name: latest supported + spec: ">=0.4.3,<0.5" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Build the demo wheel + run: | + python -m pip install --upgrade pip + python -m pip wheel --no-deps --wheel-dir dist . + - name: Install the released framework and test runner + run: python -m pip install "base-cli${{ matrix.base_cli.spec }}" "pytest>=8,<9" + - name: Install the demo wheel without source dependencies + run: python -m pip install --no-deps dist/*.whl + - name: Confirm the wheel is the imported application + run: python -c 'import base_cli_demo, pathlib; root = pathlib.Path.cwd().resolve(); assert root not in pathlib.Path(base_cli_demo.__file__).resolve().parents' + - name: Validate the repository baseline + run: ./tests/validate.sh + - name: Run installed-wheel compatibility tests + run: python -m pytest -q + + upcoming: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.upcoming_base_cli != '' }} + continue-on-error: true + name: upcoming Base-CLI (${{ inputs.upcoming_base_cli }}) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Build the demo wheel + run: | + python -m pip install --upgrade pip + python -m pip wheel --no-deps --wheel-dir dist . + - name: Install the upcoming framework build and test runner + run: python -m pip install "base-cli${{ inputs.upcoming_base_cli }}" "pytest>=8,<9" + - name: Install the demo wheel without source dependencies + run: python -m pip install --no-deps dist/*.whl + - name: Run the non-blocking upcoming compatibility check + run: python -m pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md index d7df141..7810425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,4 @@ and versions are tracked in the repo-root `VERSION` file. - Added a five-minute scenario-driven learning path with CI-checked command examples. - Added lifecycle safety examples for dry-run, structured output and errors, redacted diagnostics, temporary paths, and cleanup. +- Added released-package compatibility CI and installed-wheel README command checks. diff --git a/README.md b/README.md index 3cfe482..9d48559 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ boundary explained beside each scenario, see the error envelope. - The [lifecycle safety guide](docs/lifecycle-safety.md) shows dry-run safety, structured errors, redacted diagnostics, temporary paths, and cleanup. +- The [released-package compatibility guide](docs/compatibility.md) explains + the supported Base-CLI range and the installed-wheel CI gate. +- The [released-package compatibility guide](docs/compatibility.md) explains + the supported Base-CLI range and the installed-wheel CI gate. ## Framework boundary diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..2cd2ddf --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,35 @@ +# Released-package compatibility + +The demo is a consumer of the published `base-cli` package, not a source +checkout consumer. Its declared support range is visible in `pyproject.toml`: + +```text +base-cli>=0.4.3,<0.5 +``` + +The [Compatibility workflow](../.github/workflows/compatibility.yml) builds a +wheel from this repository, installs the minimum released Base-CLI (`0.4.3`) +and the latest release in the supported `<0.5` line, then installs the demo +wheel without dependencies before running the tests. It covers Python 3.10 +and 3.13, the ends of the supported interpreter range used by this repository. + +The same tests execute every `northstar` command in the README through the +installed console script. They also parse the documented record output and +the optional JSON lifecycle envelope, so a source-tree import cannot make the +quickstart appear healthy. + +The update policy is intentionally explicit: + +- `0.4.3` is the minimum compatibility floor and changes only with a support + decision. +- `<0.5` keeps the demo on the released 0.4 API line until a future issue + evaluates the next minor API boundary. +- A pull request or release should update the range, tests, and this document + together when the supported Base-CLI line changes. + +## Upcoming builds + +Run the workflow manually and provide a pip requirement suffix such as +`==0.4.4rc1` in the `upcoming_base_cli` input. The `upcoming` job is explicitly +non-blocking, so it provides early compatibility evidence without turning an +unreleased framework build into the supported release gate. diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..195db05 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +def readme_commands() -> list[list[str]]: + commands: list[list[str]] = [] + in_shell_block = False + for line in Path("README.md").read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("```"): + in_shell_block = not in_shell_block + continue + if in_shell_block and line.strip().startswith("northstar"): + commands.append(shlex.split(line.strip())) + return commands + + +def run_installed_command( + args: list[str], home: Path +) -> subprocess.CompletedProcess[str]: + venv_executable = Path(sys.executable).with_name(args[0]) + executable = ( + str(venv_executable) if venv_executable.is_file() else shutil.which(args[0]) + ) + assert executable is not None, "the installed northstar console script is required" + environment = os.environ.copy() + environment.update( + { + "HOME": str(home / "home"), + "BASE_CLI_CACHE_DIR": str(home / "cache"), + "USERPROFILE": str(home / "home"), + "LOCALAPPDATA": str(home / "home" / "AppData" / "Local"), + } + ) + return subprocess.run( + [executable, *args[1:]], + capture_output=True, + cwd=home, + env=environment, + text=True, + check=False, + ) + + +@pytest.mark.parametrize("args", readme_commands(), ids=lambda args: " ".join(args)) +def test_readme_northstar_commands_run_from_the_installed_wheel( + args: list[str], tmp_path: Path +) -> None: + result = run_installed_command(args, tmp_path) + + assert result.returncode == 0, ( + f"{args!r}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +def test_readme_json_output_is_machine_readable(tmp_path: Path) -> None: + result = run_installed_command( + ["northstar", "--quiet", "--environment", "dev", "status", "--format", "json"], + tmp_path, + ) + + assert result.returncode == 0, result.stderr + records = json.loads(result.stdout) + assert records[0]["service"] == "orders-api" + assert records[-1]["status"] == "degraded" + + +def test_readme_json_envelope_is_machine_readable(tmp_path: Path) -> None: + result = run_installed_command( + [ + "northstar", + "--quiet", + "--environment", + "dev", + "--json", + "status", + "--format", + "json", + ], + tmp_path, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["schema"] == "base-cli.output" + assert payload["code"] == "ok"