From cbc3690731d25b24ca856e299a9727b3b1124def Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Thu, 9 Jul 2026 13:30:47 -0600 Subject: [PATCH] ci: add CI pipeline & merge gates (SEC-36) Add .github/workflows/ci.yml with four jobs matching the contract gates: - lint ruff check framework/ (lint + import order) and mypy framework/ (core framework; TUI suppressed via a pyproject override) - test pytest across the Python 3.10-3.13 matrix - smoke paramify list/catalog/ksi + describe over every fetcher - secrets gitleaks OSS binary (v8.30.1), full-history scan via `gitleaks git` Add tests/test_contracts.py: parametrized JSON-Schema validation of every fetcher.yaml and _categories/*.yaml, plus discovery-doesn't-raise and unique-name invariants (+122 tests). Configure tooling in pyproject.toml: [tool.ruff] gates framework/ only (ruff format not gated yet); [tool.mypy] runs on the core framework with a framework.tui.* ignore_errors override; ruff + mypy added to dev/all extras. Clear the new gates with behavior-preserving fixes: 3 ruff autofixes (unused imports in the TUI, import sort in executor.py) and core-framework mypy fixes (config_loader signature, two Optional handlings in api.py, and renaming reused `except ... as e` loop variables to `err` in cli.py). Branch protection (CI-green + review before main) is a repo setting, best applied on the public repo (SEC-30); not included here. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 109 +++++++++++++++++++++++++++++ framework/api.py | 8 ++- framework/cli.py | 12 ++-- framework/config_loader.py | 4 +- framework/runner/executor.py | 3 +- framework/tui/modals.py | 2 +- framework/tui/screens/workspace.py | 2 - pyproject.toml | 29 +++++++- tests/test_contracts.py | 108 ++++++++++++++++++++++++++++ 9 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_contracts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40be8a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +# The public API surface — schemas + manifest + envelope + the `paramify` CLI — +# is the contract (CLAUDE.md). These jobs gate it: +# test — every fetcher.yaml / category file validates against its JSON Schema +# smoke — the CLI discovery surface works across all fetchers +# lint — the framework's own code passes ruff + mypy +# secrets — no credentials in the tree or its full git history +# Fetchers can't be integration-tested here (no live tenants); that signal comes +# from recorded fixtures + the scheduled dogfood run (SEC-38). + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - run: python -m pip install -e ".[dev]" + # Framework's own code only. Ported v0.x fetchers under fetchers/ are held + # to the schema contract (the test job), not lint rules. `ruff format` is + # intentionally not gated yet. + - name: ruff (lint + import order) + run: ruff check framework/ + # Core framework. The TUI is suppressed via a pyproject override until it + # gets its own typing pass (see [tool.mypy] in pyproject.toml). + - name: mypy (core framework) + run: mypy framework/ + + test: + name: test (py ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + # tui extra: the CLI/TUI parity test reads the TUI source, and importing + # the CLI must stay clean with textual installed. + - run: python -m pip install -e ".[dev,tui]" + # Includes tests/test_contracts.py — the schema contract gate over every + # fetcher.yaml and _categories/*.yaml. + - name: pytest + run: pytest -q + + smoke: + name: smoke (CLI discovery) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - run: python -m pip install -e . + - name: paramify list / catalog / ksi + describe every fetcher + run: | + set -euo pipefail + paramify list --json > /dev/null + paramify catalog --json > /dev/null + paramify ksi --json > /dev/null + for f in $(paramify list --json | python -c "import sys, json; [print(x['name']) for x in json.load(sys.stdin)]"); do + paramify describe "$f" --json > /dev/null + done + echo "smoke ok: described all discovered fetchers" + + secrets: + name: secrets (gitleaks full history) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history — scanning it is the whole point + # The OSS binary, not the marketplace action (which requires an org + # license). `gitleaks git` scans the full commit history and exits 1 on + # any finding. + - name: Install gitleaks + run: | + set -euo pipefail + VERSION=8.30.1 + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz gitleaks + sudo install gitleaks /usr/local/bin/gitleaks + gitleaks version + - name: gitleaks (full history) + run: gitleaks git --verbose --redact diff --git a/framework/api.py b/framework/api.py index 42f5c58..656ce2c 100644 --- a/framework/api.py +++ b/framework/api.py @@ -515,8 +515,8 @@ def on_line(line: str, _use=entry.use) -> None: fetcher, entry, run_dir, - platforms.get(fetcher.category), - parsed.platforms.get(fetcher.category), + platforms.get(fetcher.category or ""), + parsed.platforms.get(fetcher.category or ""), on_line=on_line, ) except (RuntimeError, ValueError) as e: @@ -815,11 +815,13 @@ def list_manifests(root) -> List[dict]: return [] # Discover the fetcher tree once and reuse it across every manifest's # validate() — otherwise the welcome screen re-scans all fetchers per file. + fetchers: Optional[dict] = None + platforms: Optional[dict] = None try: fetchers = discover_fetchers(root) platforms = discover_platforms(root) except Exception: - fetchers = platforms = None + pass summaries = [_manifest_summary(p, root, fetchers, platforms) for p in paths] return [s for s in summaries if s is not None] diff --git a/framework/cli.py b/framework/cli.py index 6e1b08c..2627382 100644 --- a/framework/cli.py +++ b/framework/cli.py @@ -442,8 +442,8 @@ def validate_cmd( typer.echo(json.dumps({"ok": not errors, "errors": errors}, indent=2)) raise typer.Exit(0 if not errors else 1) if errors: - for e in errors: - _err(f" ERROR {e}") + for err in errors: + _err(f" ERROR {err}") raise typer.Exit(1) n = len(m.get("run", {}).get("fetchers", [])) typer.echo(f"OK manifest valid; {n} fetcher entries") @@ -525,8 +525,8 @@ def upload_cmd( if json_out: typer.echo(json.dumps(preflight, indent=2, default=str)) else: - for e in preflight["errors"]: - _err(f" ERROR {e}") + for err in preflight["errors"]: + _err(f" ERROR {err}") raise typer.Exit(1) try: @@ -582,8 +582,8 @@ def _save_and_report(manifest: dict, path: Path, root: Path, json_out: bool, *, typer.echo(f"{verb} {path}") if errors: _err(" (manifest saved but not yet runnable):") - for e in errors: - _err(f" {e}") + for err in errors: + _err(f" {err}") @manifest_app.command("init") diff --git a/framework/config_loader.py b/framework/config_loader.py index 4e821eb..8957297 100644 --- a/framework/config_loader.py +++ b/framework/config_loader.py @@ -6,7 +6,7 @@ import json from pathlib import Path -from typing import Dict +from typing import Dict, Optional import yaml from jsonschema import Draft202012Validator @@ -25,7 +25,7 @@ def _load_schema(repo_root: Path, name: str = "fetcher_schema.json") -> dict: return json.loads((repo_root / "framework" / "schemas" / name).read_text()) -def _parse_config_schema(raw: dict) -> dict: +def _parse_config_schema(raw: Optional[dict]) -> dict: """Parse a config_schema mapping (shared by fetcher + category files).""" out = {} for field_name, spec in (raw or {}).items(): diff --git a/framework/runner/executor.py b/framework/runner/executor.py index 2115faa..b20f618 100644 --- a/framework/runner/executor.py +++ b/framework/runner/executor.py @@ -27,8 +27,7 @@ PlatformSpec, TargetInstance, ) -from framework.secret_resolver import resolve, SecretResolutionError - +from framework.secret_resolver import SecretResolutionError, resolve _INHERITED_ENV_VARS = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "USER", "TZ") diff --git a/framework/tui/modals.py b/framework/tui/modals.py index d90237c..ee37fe7 100644 --- a/framework/tui/modals.py +++ b/framework/tui/modals.py @@ -13,7 +13,7 @@ from __future__ import annotations -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple from rich.text import Text from textual import events, on diff --git a/framework/tui/screens/workspace.py b/framework/tui/screens/workspace.py index 95af120..626abc4 100644 --- a/framework/tui/screens/workspace.py +++ b/framework/tui/screens/workspace.py @@ -8,8 +8,6 @@ from __future__ import annotations -from pathlib import Path - from textual.app import ComposeResult from textual.binding import Binding from textual.screen import Screen diff --git a/pyproject.toml b/pyproject.toml index ce9aa47..cc6bc80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,9 +26,9 @@ dependencies = [ [project.optional-dependencies] tui = ["textual>=1.0,<2.0"] checkov = ["checkov"] -dev = ["pytest"] +dev = ["pytest", "ruff", "mypy"] # Convenience: every front-end + dev tooling in one install. -all = ["textual>=1.0,<2.0", "checkov", "pytest"] +all = ["textual>=1.0,<2.0", "checkov", "pytest", "ruff", "mypy"] # The single entry point. `paramify` steers every front-end: the headless # commands, plus `paramify tui`. (Renaming later is a one-line change here; add @@ -57,3 +57,28 @@ include = ["framework*"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--import-mode=importlib" + +# Lint + import order for the framework's OWN code. Ported v0.x fetchers under +# fetchers/ are held to the schema contract (tests/test_contracts.py), not lint +# rules (CLAUDE.md); CI runs `ruff check framework/` so only that tree is gated. +[tool.ruff] +target-version = "py310" +extend-exclude = ["fetchers", ".venv"] + +[tool.ruff.lint] +# Default lint set (pyflakes F + the E4/E7/E9 pycodestyle slice) plus import +# sorting (I). `ruff format` is intentionally NOT gated yet (would churn ~25 +# files) — adopt as a separate pass. +select = ["E4", "E7", "E9", "F", "I"] + +# Static typing gate for the core framework. The TUI subclasses textual's App and +# reads attributes mypy can't see on the base class — it needs its own typing +# pass, so it's suppressed here rather than gating CI. Third-party libs ship no +# stubs, hence ignore_missing_imports. +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "framework.tui.*" +ignore_errors = true diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..de9c0b9 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,108 @@ +"""Contract gate: every fetcher.yaml and category file must satisfy its schema. + +This is the merge gate the CI ``test`` job protects (SEC-36). Discovery +(``framework.config_loader``) validates every ``fetcher.yaml`` against +``fetcher_schema.json`` and every ``fetchers/_categories/*.yaml`` against +``category_schema.json``, and raises on any schema violation or duplicate +fetcher name. The public API surface *is* the contract (CLAUDE.md), so a change +that breaks a fetcher's declared shape must turn this suite red before it can +merge. + +The per-file parametrized tests validate directly against the JSON Schema so a +failure names the offending file and field; the discovery tests exercise the +same path in aggregate (must not raise) and assert the duplicate-name and +on-disk/discovered-count invariants. + +Run: ``pytest tests/test_contracts.py`` (needs an editable install: +``pip install -e .``). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from jsonschema import Draft202012Validator + +from framework.config_loader import discover_fetchers, discover_platforms + +REPO_ROOT = Path(__file__).resolve().parent.parent +FETCHERS_ROOT = REPO_ROOT / "fetchers" +SCHEMAS_ROOT = REPO_ROOT / "framework" / "schemas" + + +def _fetcher_yamls() -> list[Path]: + """Every real fetcher.yaml, mirroring discover_fetchers' walk. + + category//fetcher.yaml, skipping any ``_``-prefixed dir (_shared, + _categories, _template) at either level. + """ + out: list[Path] = [] + for category_dir in sorted(FETCHERS_ROOT.iterdir()): + if not category_dir.is_dir() or category_dir.name.startswith("_"): + continue + for fetcher_dir in sorted(category_dir.iterdir()): + if not fetcher_dir.is_dir() or fetcher_dir.name.startswith("_"): + continue + yaml_path = fetcher_dir / "fetcher.yaml" + if yaml_path.exists(): + out.append(yaml_path) + return out + + +FETCHER_YAMLS = _fetcher_yamls() +CATEGORY_YAMLS = sorted((FETCHERS_ROOT / "_categories").glob("*.yaml")) + + +def _load_schema(name: str) -> dict: + return json.loads((SCHEMAS_ROOT / name).read_text()) + + +def _rel_ids(paths: list[Path]) -> list[str]: + return [str(p.relative_to(REPO_ROOT)) for p in paths] + + +def _format_errors(errors) -> str: + return "\n".join( + f" {'/'.join(map(str, e.path)) or ''}: {e.message}" for e in errors + ) + + +def test_fetcher_yamls_discovered() -> None: + """Sanity: the walk actually finds fetchers (guards an empty parametrize).""" + assert FETCHER_YAMLS, "no fetcher.yaml files discovered under fetchers/" + + +@pytest.mark.parametrize("yaml_path", FETCHER_YAMLS, ids=_rel_ids(FETCHER_YAMLS)) +def test_fetcher_yaml_matches_schema(yaml_path: Path) -> None: + validator = Draft202012Validator(_load_schema("fetcher_schema.json")) + data = yaml.safe_load(yaml_path.read_text()) + errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path)) + assert not errors, f"{yaml_path.name} violates fetcher_schema.json:\n{_format_errors(errors)}" + + +@pytest.mark.parametrize("yaml_path", CATEGORY_YAMLS, ids=_rel_ids(CATEGORY_YAMLS)) +def test_category_yaml_matches_schema(yaml_path: Path) -> None: + validator = Draft202012Validator(_load_schema("category_schema.json")) + data = yaml.safe_load(yaml_path.read_text()) or {} + errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path)) + assert not errors, f"{yaml_path.name} violates category_schema.json:\n{_format_errors(errors)}" + + +def test_discovery_does_not_raise() -> None: + """The aggregate gate: discovery validates + dedupes, raising on violations.""" + fetchers = discover_fetchers(REPO_ROOT) + assert fetchers, "discover_fetchers returned nothing" + discover_platforms(REPO_ROOT) # must not raise on any category file + + +def test_fetcher_names_unique_and_complete() -> None: + """Every fetcher.yaml on disk maps to exactly one discovered, uniquely named + fetcher (discover_fetchers raises on a duplicate name before returning).""" + fetchers = discover_fetchers(REPO_ROOT) + assert len(fetchers) == len(FETCHER_YAMLS), ( + f"{len(FETCHER_YAMLS)} fetcher.yaml files on disk but " + f"{len(fetchers)} discovered" + )