| name: Tests |
|
|
| on: |
| push: |
| branches: [main] |
| pull_request: |
| branches: [main] |
|
|
| env: |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" |
|
|
| permissions: |
| contents: read |
|
|
| concurrency: |
| group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} |
| cancel-in-progress: true |
|
|
| jobs: |
| classify: |
| name: "Change classifier" |
| runs-on: ubuntu-latest |
| outputs: |
| browser_changed: ${{ steps.classify.outputs.browser_changed }} |
| ci_changed: ${{ steps.classify.outputs.ci_changed }} |
| docs_changed: ${{ steps.classify.outputs.docs_changed }} |
| docs_only: ${{ steps.classify.outputs.docs_only }} |
| graph_artifact_changed: ${{ steps.classify.outputs.graph_artifact_changed }} |
| graph_changed: ${{ steps.classify.outputs.graph_changed }} |
| graph_only: ${{ steps.classify.outputs.graph_only }} |
| package_changed: ${{ steps.classify.outputs.package_changed }} |
| similarity_changed: ${{ steps.classify.outputs.similarity_changed }} |
| source_changed: ${{ steps.classify.outputs.source_changed }} |
| steps: |
| - name: Checkout with full history |
| uses: actions/checkout@v5 |
| with: |
| fetch-depth: 0 |
| lfs: false |
|
|
| - name: Classify changed paths |
| id: classify |
| shell: bash |
| env: |
| BEFORE_SHA: ${{ github.event.before }} |
| EVENT_NAME: ${{ github.event_name }} |
| HEAD_SHA: ${{ github.sha }} |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| run: | |
| set -euo pipefail |
| BASE="$BEFORE_SHA" |
| if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then |
| BASE="$PR_BASE_SHA" |
| fi |
| if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then |
| BASE="$(git rev-list --max-parents=0 "$HEAD_SHA")" |
| fi |
| if git cat-file -e "$BASE^{commit}" 2>/dev/null; then |
| git diff --name-only "$BASE" "$HEAD_SHA" > changed-files.txt |
| elif git cat-file -e "$HEAD_SHA^" 2>/dev/null; then |
| git diff --name-only "$HEAD_SHA^" "$HEAD_SHA" > changed-files.txt |
| else |
| git diff-tree --no-commit-id --name-only -r "$HEAD_SHA" > changed-files.txt |
| fi |
| |
| python scripts/ci_classifier.py changed-files.txt |
|
|
| static: |
| name: "Static quality gates" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.11 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Install dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
| |
| - name: Run static quality gates |
| run: python -m ruff check src hooks scripts && python -m mypy src && python -m pip check |
|
|
| unit-linux: |
| name: "unit-linux" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.11 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Install dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
| |
| - name: Run tests with coverage gate |
| |
| run: pytest -q -m "not browser and not integration" --cov=src --cov-report=term-missing --cov-fail-under=40 |
|
|
| - name: Upload coverage artifact |
| uses: actions/upload-artifact@v7 |
| with: |
| name: coverage |
| path: .coverage |
| include-hidden-files: true |
| retention-days: 7 |
|
|
| similarity-integration: |
| name: "Similarity precision/recall" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.similarity_changed == 'true') }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.11 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Install embedding dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev,embeddings]" |
| |
| - name: Cache MiniLM model |
| uses: actions/cache@v5 |
| with: |
| path: ~/.cache/huggingface |
| key: hf-sentence-transformers-all-MiniLM-L6-v2-v1 |
|
|
| - name: Pre-download MiniLM model |
| env: |
| HF_TOKEN: ${{ secrets.HF_TOKEN }} |
| run: | |
| python - <<'PY' |
| import time |
| from sentence_transformers import SentenceTransformer |
| |
| model = "sentence-transformers/all-MiniLM-L6-v2" |
| for attempt in range(1, 5): |
| try: |
| SentenceTransformer(model) |
| print(f"ready: {model}") |
| break |
| except Exception as exc: |
| print(f"attempt {attempt} failed: {exc}", flush=True) |
| if attempt == 4: |
| raise |
| time.sleep(15 * attempt) |
| PY |
| |
| - name: Run similarity precision/recall gate |
| env: |
| CTX_REQUIRE_SIMILARITY_EVAL: "1" |
| run: pytest -q --no-cov -m integration src/tests/test_similarity_precision_recall.py |
| |
| test: |
| name: pytest (${{ matrix.os }} / py${{ matrix.python-version }}) |
| if: ${{ github.event_name != 'pull_request' }} |
| runs-on: ${{ matrix.os }} |
| strategy: |
| fail-fast: false |
| matrix: |
| include: |
| - os: ubuntu-latest |
| python-version: "3.12" |
| - os: windows-latest |
| python-version: "3.11" |
| - os: windows-latest |
| python-version: "3.12" |
| - os: macos-latest |
| python-version: "3.11" |
| - os: macos-latest |
| python-version: "3.12" |
| |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
| |
| - name: Set up Python ${{ matrix.python-version }} |
| uses: actions/setup-python@v6 |
| with: |
| python-version: ${{ matrix.python-version }} |
| cache: pip |
| cache-dependency-path: pyproject.toml |
| |
| - name: Install dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
|
|
| - name: Run tests without coverage |
| run: pytest -q -m "not browser and not integration" --no-cov |
|
|
| contract-compat: |
| name: "Contract compatibility (${{ matrix.os }})" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ${{ matrix.os }} |
| strategy: |
| fail-fast: false |
| matrix: |
| os: [windows-latest, macos-latest] |
|
|
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.12 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
|
|
| - name: Install dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
| |
| - name: Run cross-OS contract tests |
| run: python -m pytest -q --no-cov src/tests/test_clean_host_contract.py src/tests/test_package_scaffold.py |
|
|
| e2e-canary: |
| name: "A-Z alive-loop E2E canary" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.11 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Install dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
| |
| - name: Run E2E + fuzz canary suite |
| |
| |
| |
| |
| |
| |
| |
| |
| run: | |
| pytest -q --no-cov \ |
| src/tests/test_alive_loop_e2e.py \ |
| src/tests/test_fuzz_yaml_rendering.py |
| |
| docs-check: |
| name: "Docs strict build" |
| needs: classify |
| if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_changed == 'true' }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.11 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
| cache: pip |
| cache-dependency-path: requirements-docs.txt |
|
|
| - name: Build docs strictly |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install -r requirements-docs.txt |
| python -m mkdocs build --strict |
| |
| graph-check: |
| name: "Graph artifact check" |
| needs: classify |
| if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.graph_artifact_changed == 'true' }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout graph artifacts |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
|
|
| - name: Install graph check dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" |
| |
| - name: Resolve graph artifacts from release assets |
| shell: bash |
| env: |
| GH_TOKEN: ${{ github.token }} |
| run: | |
| set -euo pipefail |
| echo "Resolving graph artifacts from matching release assets to avoid Git LFS bandwidth." |
| python - <<'PY' |
| import hashlib |
| import json |
| import os |
| from pathlib import Path |
| import subprocess |
| import time |
| import urllib.request |
| |
| repo = os.environ["GITHUB_REPOSITORY"] |
| release_asset_wait_seconds = 300 |
| release_asset_poll_seconds = 10 |
| expected_graph_assets = { |
| "graph/wiki-graph.tar.gz": { |
| "sha256": "e487ec2109803e3c05cb2ca6906e8a0bae681f32a4fe79f3fb2f168fbea2c947", |
| "size": 329276669, |
| }, |
| "graph/wiki-graph-runtime.tar.gz": { |
| "sha256": "993fc08377fdb09edcff4414c59b10fc121189b4a161bf796e3f8f6600907bb1", |
| "size": 122141091, |
| }, |
| } |
|
|
| def load_releases() -> list[dict]: |
| return json.loads(subprocess.check_output( |
| ["gh", "api", f"repos/{repo}/releases?per_page=50"], |
| text=True, |
| )) |
|
|
| def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None: |
| graph_tar = Path(path_name) |
| fallback = expected_graph_assets[path_name] |
| expected_oid = fallback["sha256"] |
| expected_size = int(fallback["size"]) |
| if graph_tar.exists(): |
| pointer = graph_tar.read_text(encoding="utf-8", errors="replace") |
| for line in pointer.splitlines(): |
| if line.startswith("oid sha256:"): |
| expected_oid = line.split(":", 1)[1].strip() |
| elif line.startswith("size "): |
| expected_size = int(line.split(" ", 1)[1].strip()) |
| if not pointer.startswith("version https://git-lfs.github.com/spec/v1") and graph_tar.stat().st_size > hydrated_min_size: |
| print(f"{graph_tar} is already hydrated") |
| return |
|
|
| deadline = time.monotonic() + release_asset_wait_seconds |
| while True: |
| candidates = [] |
| for release in load_releases(): |
| tag_name = str(release.get("tag_name") or "") |
| is_graph_cache = tag_name.startswith("graph-artifacts-") |
| if release.get("draft") or ( |
| release.get("prerelease") and not is_graph_cache |
| ): |
| continue |
| for asset in release.get("assets", []): |
| if asset.get("name") != graph_tar.name: |
| continue |
| digest = str(asset.get("digest") or "") |
| size = int(asset.get("size") or 0) |
| if size != expected_size: |
| continue |
| if digest and digest != f"sha256:{expected_oid}": |
| continue |
| candidates.append((tag_name, asset)) |
|
|
| if candidates: |
| break |
| if time.monotonic() >= deadline: |
| raise SystemExit( |
| f"No previous release asset matches {path_name} " |
| f"sha256:{expected_oid} size:{expected_size}" |
| ) |
| print( |
| f"Waiting for matching release asset {graph_tar.name} " |
| f"sha256:{expected_oid} size:{expected_size}" |
| ) |
| time.sleep(release_asset_poll_seconds) |
|
|
| source_tag, asset = candidates[0] |
| tmp = graph_tar.with_name(f"{graph_tar.name}.download") |
| sha = hashlib.sha256() |
| total = 0 |
| with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: |
| with tmp.open("wb") as fh: |
| while True: |
| chunk = resp.read(1024 * 1024) |
| if not chunk: |
| break |
| sha.update(chunk) |
| total += len(chunk) |
| fh.write(chunk) |
| actual_oid = sha.hexdigest() |
| if actual_oid != expected_oid or total != expected_size: |
| tmp.unlink(missing_ok=True) |
| raise SystemExit( |
| f"Downloaded {path_name} does not match LFS pointer: " |
| f"sha256:{actual_oid} size:{total}" |
| ) |
| tmp.replace(graph_tar) |
| print( |
| f"Hydrated {path_name} from {source_tag} release asset " |
| f"sha256:{actual_oid} size:{total}" |
| ) |
|
|
| hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000) |
| hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000) |
| PY |
|
|
| - name: Validate shipped graph artifacts |
| run: | |
| python src/validate_graph_artifacts.py \ |
| --graph-dir graph \ |
| --deep \ |
| --min-nodes 79000 \ |
| --min-edges 1700000 \ |
| --min-skills-sh-nodes 67000 \ |
| --min-semantic-edges 1000000 \ |
| --expected-nodes 79958 \ |
| --expected-edges 1778069 \ |
| --expected-semantic-edges 1088763 \ |
| --expected-harness-nodes 207 \ |
| --expected-skills-sh-nodes 67028 \ |
| --expected-skills-sh-catalog-entries 67024 \ |
| --expected-skills-sh-converted 67024 \ |
| --expected-skill-pages 68494 \ |
| --expected-agent-pages 467 \ |
| --expected-mcp-pages 10790 \ |
| --expected-harness-pages 207 \ |
| --line-threshold 180 \ |
| --max-stage-lines 40 |
| |
| - name: Validate README and docs stats |
| run: python src/update_repo_stats.py --check |
|
|
| browser-security: |
| name: "Browser monitor security" |
| needs: classify |
| if: ${{ github.event_name == 'push' || needs.classify.outputs.browser_changed == 'true' }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.12 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Install browser Python dependencies |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev,browser]" |
| |
| - name: Detect Playwright version |
| id: playwright |
| run: | |
| python - <<'PY' >> "$GITHUB_OUTPUT" |
| from importlib.metadata import version |
| |
| print(f"version={version('playwright')}") |
| PY |
|
|
| - name: Cache Playwright browsers |
| uses: actions/cache@v5 |
| with: |
| path: ~/.cache/ms-playwright |
| key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} |
|
|
| - name: Install browser runtime |
| run: | |
| python -m playwright install-deps chromium |
| python -m playwright install chromium |
| |
| - name: Run browser security tests |
| run: pytest -q --no-cov -m browser src/tests/test_ctx_monitor_browser.py |
|
|
| package-build: |
| name: "Build wheel package" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ubuntu-latest |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.12 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Build and inspect wheel |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install build twine |
| python -m build |
| python -m twine check dist/* |
| python - <<'PY' |
| from pathlib import Path |
| |
| wheels = sorted(Path("dist").glob("*.whl")) |
| if len(wheels) != 1: |
| raise SystemExit(f"expected exactly one wheel, found {len(wheels)}") |
| wheel = wheels[0] |
| if not wheel.name.endswith("-py3-none-any.whl"): |
| message = f"expected a universal pure-Python wheel, got {wheel.name}" |
| raise SystemExit(message) |
| print(f"built package-smoke wheel: {wheel.name}") |
| PY |
| |
| - name: Upload wheel artifact |
| uses: actions/upload-artifact@v7 |
| with: |
| name: package-smoke-wheel |
| path: dist/*.whl |
| retention-days: 7 |
| |
| package-smoke: |
| name: "Wheel package smoke (${{ matrix.os }})" |
| needs: |
| - classify |
| - package-build |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ${{ matrix.os }} |
| strategy: |
| fail-fast: false |
| matrix: |
| os: [ubuntu-latest, windows-latest, macos-latest] |
| defaults: |
| run: |
| shell: bash |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.12 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Download wheel artifact |
| uses: actions/download-artifact@v7 |
| with: |
| name: package-smoke-wheel |
| path: dist |
|
|
| - name: Install wheel in clean venv |
| run: | |
| python -m venv .venv-smoke |
| if [[ "$RUNNER_OS" == "Windows" ]]; then |
| . .venv-smoke/Scripts/activate |
| else |
| . .venv-smoke/bin/activate |
| fi |
| python -m pip install --upgrade pip |
| python -m pip install dist/*.whl |
| python -m pip check |
| python - <<'PY' |
| import tomllib |
| from importlib.metadata import entry_points, version |
| |
| import ctx |
|
|
| with open("pyproject.toml", "rb") as fh: |
| expected_scripts = set(tomllib.load(fh)["project"]["scripts"]) |
|
|
| dist_version = version("claude-ctx") |
| if ctx.__version__ != dist_version: |
| raise SystemExit( |
| f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}" |
| ) |
| |
| script_eps = { |
| ep.name: ep |
| for ep in entry_points(group="console_scripts") |
| if ep.name == "ctx" or ep.name.startswith("ctx-") |
| } |
| missing = sorted(expected_scripts - set(script_eps)) |
| extra = sorted(set(script_eps) - expected_scripts) |
| if missing or extra: |
| raise SystemExit( |
| "wheel console-script surface mismatch\n" |
| f"missing: {missing}\n" |
| f"extra: {extra}" |
| ) |
| failures = [] |
| for ep in script_eps.values(): |
| try: |
| ep.load() |
| except Exception as exc: |
| failures.append(f"{ep.name}: {exc!r}") |
| if failures: |
| raise SystemExit("console script load failures:\n" + "\n".join(failures)) |
|
|
| unsafe_help = {"ctx-mcp-server"} |
| safe_help = sorted(expected_scripts - unsafe_help) |
| with open( |
| "ctx-console-help.txt", "w", encoding="utf-8", newline="\n" |
| ) as fh: |
| fh.write("\n".join(safe_help) + "\n") |
| print( |
| f"loaded {len(script_eps)} ctx console scripts from wheel " |
| f"{dist_version}; help-smoke={len(safe_help)}" |
| ) |
| PY |
| while IFS= read -r cmd; do |
| cmd="${cmd%$'\r'}" |
| [[ -z "$cmd" ]] && continue |
| echo "help smoke: $cmd" |
| "$cmd" --help >/dev/null |
| done < ctx-console-help.txt |
|
|
| clean-host-contract: |
| name: "Clean host contract" |
| needs: classify |
| if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }} |
| runs-on: ubuntu-latest |
| timeout-minutes: 25 |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python 3.12 |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.12" |
| cache: pip |
| cache-dependency-path: pyproject.toml |
|
|
| - name: Upgrade pip |
| run: python -m pip install --upgrade pip |
|
|
| - name: Run clean-host contract |
| run: python scripts/clean_host_contract.py --fast |
|
|
| no-test-no-merge: |
| name: "Contract changes must touch tests" |
| needs: classify |
| runs-on: ubuntu-latest |
| if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true' }} |
| steps: |
| - name: Checkout with full history |
| uses: actions/checkout@v5 |
| with: |
| fetch-depth: 0 |
| lfs: false |
|
|
| - name: Enforce test-coverage-per-PR policy |
| |
| |
| |
| |
| |
| |
| |
| run: | |
| set -euo pipefail |
| BASE="${{ github.event.pull_request.base.sha }}" |
| HEAD="${{ github.event.pull_request.head.sha }}" |
| LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' |
| python scripts/ci_no_test_policy.py \ |
| --base "$BASE" \ |
| --head "$HEAD" \ |
| --labels-json "$LABELS" |
| |
| ci-required: |
| name: "CI required" |
| runs-on: ubuntu-latest |
| needs: |
| - classify |
| - static |
| - unit-linux |
| - similarity-integration |
| - test |
| - contract-compat |
| - e2e-canary |
| - docs-check |
| - graph-check |
| - browser-security |
| - package-smoke |
| - package-build |
| - clean-host-contract |
| - no-test-no-merge |
| if: ${{ always() }} |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Check required job results |
| env: |
| EVENT_NAME: ${{ github.event_name }} |
| NEEDS_JSON: ${{ toJson(needs) }} |
| run: python scripts/ci_required.py |
|
|