name: CI on: push: branches: [main] pull_request: branches: [main] schedule: # Weekly Sunday midnight UTC — catches newly-published CVEs without a push - cron: "0 0 * * 0" workflow_dispatch: # Global minimum permissions; individual jobs override where needed permissions: contents: read concurrency: group: ci-${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true env: UV_FROZEN: "true" MPLBACKEND: Agg # ───────────────────────────────────────────────────────────────────────────── # Job 1 — Lint & Type Check # ───────────────────────────────────────────────────────────────────────────── jobs: # Presence-detection for OPTIONAL/rotating projects. `hashFiles()` is NOT # available in a job-level `if:` (only in step contexts) — using it there is # a workflow-validation error that makes GitHub reject the ENTIRE workflow # at startup (zero jobs run, surfaced as "workflow file issue"). This tiny # job computes the existence flags in a step (valid) and exposes them as # outputs that the optional jobs gate on via `needs.detect.outputs.*`. detect: name: Detect optional projects runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read outputs: setup_hook: ${{ steps.d.outputs.setup_hook }} fep_lean: ${{ steps.d.outputs.fep_lean }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: d shell: bash run: | shopt -s globstar nullglob hooks=(projects/**/scripts/setup_hook.py) if [ ${#hooks[@]} -gt 0 ]; then echo "setup_hook=true" >> "$GITHUB_OUTPUT" else echo "setup_hook=false" >> "$GITHUB_OUTPUT" fi if [ -f projects/fep_lean/lean/lean-toolchain ]; then echo "fep_lean=true" >> "$GITHUB_OUTPUT" else echo "fep_lean=false" >> "$GITHUB_OUTPUT" fi # Derive the complete project/Python matrix from the validated, versioned # public-capability manifest. One source now owns the exact public roster and # supported CI Python versions; malformed metadata, incompatible Python # floors, or declaration drift fails before any matrix cell starts # (PUBLIC-CAPABILITY-PARITY-1). No project runtime is executed here. detect-projects: name: Detect public capability matrix runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read outputs: matrix: ${{ steps.matrix.outputs.matrix }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Validate capabilities and emit CI matrix id: matrix run: | set -euo pipefail matrix="$(uv run python scripts/gates/public_capabilities.py --ci-matrix-json)" echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" # ─────────────────────────────────────────────────────────────────────────── # Job 0b — Actionlint (workflow syntax gate; independent of project setup) # # GH-ACTIONLINT-1: catch workflow-expression and `uses:` errors early, before # a small mistake silently disables CI coverage or fails every PR. Read-only, # no `uv sync`, no project dependencies, no `needs:` — it runs in parallel with # everything else. The actionlint binary is fetched with its official installer # script pinned to a release commit SHA (immutable); the runner's preinstalled # shellcheck is picked up automatically to also lint embedded `run:` scripts. # ─────────────────────────────────────────────────────────────────────────── actionlint: name: Actionlint runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run actionlint env: # rhysd/actionlint @ v1.7.12 — bump REF and VERSION together on upgrade ACTIONLINT_REF: 914e7df21a07ef503a81201c76d2b11c789d3fca ACTIONLINT_VERSION: "1.7.12" run: | set -euo pipefail curl -fsSL \ "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_REF}/scripts/download-actionlint.bash" \ -o "${RUNNER_TEMP}/download-actionlint.bash" echo "72fa3e45ac20f3c3a512d6747b4fcf719e21f890e8c43e78d48a41fdfb900c4e ${RUNNER_TEMP}/download-actionlint.bash" \ | sha256sum --check --status bash "${RUNNER_TEMP}/download-actionlint.bash" "${ACTIONLINT_VERSION}" "${RUNNER_TEMP}" "${RUNNER_TEMP}/actionlint" -color lint: name: Lint & Type Check runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Resolve public CI paths id: public-scope run: | echo "lint_paths=$(uv run python -m infrastructure.project.public_scope lint-paths)" >> "$GITHUB_OUTPUT" echo "source_paths=$(uv run python -m infrastructure.project.public_scope source-paths)" >> "$GITHUB_OUTPUT" - name: Ruff lint run: uv run ruff check ${{ steps.public-scope.outputs.lint_paths }} - name: Ruff format check run: uv run ruff format --check ${{ steps.public-scope.outputs.lint_paths }} - name: Type checking run: uv run python scripts/gates/mypy_ratchet.py ${{ steps.public-scope.outputs.source_paths }} # MED5 gate: every re-exporting module under infrastructure/ must # declare __all__ — see docs/rules/api_design.md. Prevents regression # of the [attr-defined] mypy class of bug. - name: Audit __all__ on re-exporting modules run: uv run python -m infrastructure.skills check-all-exports - name: Verify operations manifest freshness run: uv run python -m infrastructure.skills operations-check - name: Reject tracked generated artifacts run: uv run python scripts/audit/check_tracked_generated_artifacts.py - name: Reject tracked high-confidence secrets run: uv run python scripts/audit/check_tracked_secrets.py - name: Confidentiality guard — only public template resources tracked run: uv run python scripts/audit/check_tracked_all.py # Publication audit is source-only in CI because project output trees are # disposable/ignored. Release jobs and local sign-off add --rendered to # require artifact manifests, evidence registries, and figure bindings. - name: Publication audit — all public exemplars run: uv run python -m infrastructure.validation.cli publication-audit --all-public --strict --format json # XML parser policy (DEP-DEFUSEDXML-1): enforced by Bandit B313-B320 in # the security job, but this AST-level guard is explicit and independent. - name: XML parser policy — defusedxml only run: >- uv run python -c " from pathlib import Path; from infrastructure.validation.xml_parser_policy import validate_xml_parser_policy; import sys; violations = validate_xml_parser_policy(Path('.') / 'infrastructure', Path('.')); if violations: print('XML parser policy violations:', *violations, sep='\\n'); sys.exit(1) " # Enforce the module-size composability budget as a hard gate (fails at # >=950 infra / >=250 project-script lines). It also runs inside the # blocking health job below; keeping the focused step here gives an # earlier, named failure in the lint job. Advisory warnings (>=800) still # only warn. - name: Module line-count gate — composability budget run: uv run python scripts/gates/module_line_count_check.py # Strict template-drift gate: exemplar docs/scripts must match the # canonical Layer-1 contracts (thin orchestrators, pipeline wording, # publication metadata). Previously pre-commit-only; elevated to CI so # drift cannot land through a branch that skips local hooks. - name: Template drift — strict run: uv run python scripts/audit/check_template_drift.py --strict # ───────────────────────────────────────────────────────────────────────────── # Job 1b — Blocking static health report # # MED2: ``infrastructure.core.health`` aggregates every quality gate into a # single typed ``HealthReport``. Behavioral tests and platform matrices remain # separate jobs; every gate represented in this static report is blocking. # ───────────────────────────────────────────────────────────────────────────── health: name: Static Health Report runs-on: ubuntu-latest timeout-minutes: 20 needs: [lint] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - uses: ./.github/actions/setup-docs-lint - name: Sync dependencies run: uv sync - name: Run blocking static health checks run: | uv run python -m infrastructure.core.health --json --quiet \ > health-report.json - name: Upload health report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: health-report path: health-report.json if-no-files-found: warn # ───────────────────────────────────────────────────────────────────────────── # Job 2 — No-Mocks Policy Verification # ───────────────────────────────────────────────────────────────────────────── verify-no-mocks: name: Verify No Mocks Policy runs-on: ubuntu-latest timeout-minutes: 5 # Runs in parallel with `lint` — the no-mocks script only needs the # checkout and `uv sync`, not the lint/type results. This shaves ~5 min # off the critical path to the test subtree (test-infra, test-regression, # test-project all gate on verify-no-mocks, which previously sat behind # lint unnecessarily). permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync # Branch-protection check name stays stable. This command is the lexical # prohibited-framework gate; the following inventory gate separately # enforces that semantic dependency replacements remain at zero. - name: Verify prohibited mock-framework syntax run: uv run python scripts/audit/verify_no_mocks.py - name: Enforce zero semantic dependency replacements run: >- uv run python scripts/audit/verify_no_mocks.py --inventory --max-dependency-replacements 0 # ───────────────────────────────────────────────────────────────────────────── # Job 2b — Setup hook on Windows (conditional smoke) # # When any active project ships ``projects/**/scripts/setup_hook.py``, verify # hook discovery and subprocess paths on ``windows-latest`` (``.sh`` hooks are # intentionally skipped there — see TO-DO.md / infrastructure.project.setup_hook). # ───────────────────────────────────────────────────────────────────────────── setup-hook-windows-smoke: name: Setup hook (Windows smoke) runs-on: windows-latest timeout-minutes: 15 needs: [verify-no-mocks, detect] if: needs.detect.outputs.setup_hook == 'true' permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Run setup_hook tests (Windows) env: PYTHONUTF8: "1" run: >- uv run pytest tests/infra_tests/project/test_setup_hook.py -v --timeout=120 # ───────────────────────────────────────────────────────────────────────────── # Job 3 — Infrastructure Tests (matrix: ubuntu + macos × Python 3.10–3.13) # ───────────────────────────────────────────────────────────────────────────── test-infra: name: "Infra Tests (${{ matrix.os }}, Python ${{ matrix.python-version }})" runs-on: ${{ matrix.os }} timeout-minutes: 30 needs: [verify-no-mocks] permissions: contents: read env: # Override the repository's .python-version so uv uses the interpreter # selected by this matrix cell, including its moving patch release. UV_PYTHON: ${{ matrix.python-version }} strategy: fail-fast: false matrix: # ubuntu covers all four Python versions; macOS runs only the 3.12 # smoke (macOS legs are ~10x cost and rarely surface OS-specific project # breakage beyond what the 3.12 cell catches). os: [ubuntu-latest] python-version: ["3.10", "3.11", "3.12", "3.13"] include: - os: macos-latest python-version: "3.12" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env with: python-version: ${{ matrix.python-version }} - name: Sync dependencies (dev + optional test groups) run: uv sync --group public-exemplars - name: Verify selected Python minor run: | uv run python -c \ 'import sys; expected=tuple(map(int, "${{ matrix.python-version }}".split("."))); assert sys.version_info[:2] == expected, (sys.version, expected)' - name: Fix macOS socket.getfqdn timeout if: runner.os == 'macOS' run: sudo scutil --set HostName "$(hostname)" - name: Install pandoc uses: pandoc/actions/setup@86321b6dd4675f5014c611e05088e10d4939e09e # v1.1.1 # TeX Live so the xelatex/bibtex-gated rendering tests run instead of # skipping (they assert a real PDF lands on disk). Linux lanes only: # MacTeX is a multi-GB install and macOS breadth is a 3.12 smoke lane. - name: Cache TeX Live packages if: runner.os == 'Linux' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | /var/cache/apt /usr/share/texlive /usr/share/texmf key: texlive-${{ runner.os }}-xetex-extra-bibtex-fonts-v1 id: texlive-cache - name: Install TeX Live (xelatex + bibtex) if: runner.os == 'Linux' && steps.texlive-cache.outputs.cache-hit != 'true' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ texlive-xetex texlive-latex-extra texlive-bibtex-extra \ texlive-fonts-recommended lmodern - name: Run infrastructure tests env: COVERAGE_FILE: .coverage.infra # Coverage instrumentation is memory-intensive on macOS; keep that # runner deterministic while retaining wider auto-parallelism on Linux. TEMPLATE_CI_XDIST_WORKERS: ${{ matrix.os == 'macos-latest' && '2' || 'auto' }} # -n auto parallelizes across runner cores (pytest-xdist). Scope-based # distribution keeps subprocess-heavy test modules together, avoiding # intermittent worker replacement under work-stealing contention. # pytest-cov combines per-worker data before the gate. run: >- uv run pytest tests/infra_tests/ -n "$TEMPLATE_CI_XDIST_WORKERS" --dist loadscope --benchmark-disable --cov=infrastructure --cov-report=term-missing --cov-report=xml:coverage-infra.xml --cov-fail-under=60 --durations=10 -m "not requires_ollama and not requires_docker and not network and not slow and not bench and not benchmark and not performance" --timeout=120 - name: Upload infrastructure coverage to Codecov if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: coverage-infra.xml flags: infrastructure name: infra-coverage fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # ───────────────────────────────────────────────────────────────────────────── # Job — Regression tier (claim-binding pins across all public exemplars) # ───────────────────────────────────────────────────────────────────────────── test-regression: name: "Regression Tier (claim-binding pins)" runs-on: ubuntu-latest timeout-minutes: 20 needs: [verify-no-mocks] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env with: python-version: "3.12" - name: Sync dependencies (dev + optional test groups) run: uv sync --group public-exemplars # The claim-binding regression tier re-derives every pinned manuscript # number from source and fails if a value drifts. Run serial (no -n auto): # exemplars ship colliding top-level ``src`` packages resolved via # per-project aliases + temporary sys.meta_path finders whose isolation is # collection-order-sensitive (see docs/maintenance/regression-testing.md). - name: Run claim-binding regression tier run: >- uv run pytest tests/regression/ -q --no-cov --timeout=120 --collect-only -q | tee /tmp/regression-collect.txt && uv run pytest tests/regression/ -q --no-cov --timeout=120 # Fail closed against a silently empty regression tier: the claim-binding # pins are load-bearing release evidence, so zero collected tests must # surface as a violation, not vacuous success. - name: Assert regression tier is not empty run: |- count=$(grep -c "::" /tmp/regression-collect.txt || true) echo "regression collection lines: $count" if [ "$count" -lt 3 ]; then echo "FAIL: regression tier collected fewer than 3 tests; claim-binding pins are missing." >&2 exit 1 fi # ───────────────────────────────────────────────────────────────────────────── # Job 4 — Project Tests (one parallel job per public exemplar) # ───────────────────────────────────────────────────────────────────────────── test-project: name: "Project Tests (${{ matrix.project }}, py${{ matrix.python-version }})" runs-on: ubuntu-latest # Each public exemplar runs in its OWN parallel job, so wall-clock is the # slowest single project (~active_inference) instead of the ~45 min sequential # sum of all public exemplars. Each job enforces that project's own 90% floor via # ``scripts/pipeline/stage_01_test.py --project`` (authoritative per CLAUDE.md), which # also removes the old code_project/fep_lean conftest plugin-name collision # (every project is already isolated in its own job). py3.10 (floor) + py3.12 # (ceiling) give cross-version coverage; macOS breadth is handled by test-infra. # 60 min backstop — active_inference on py3.10 has exceeded 45 min on loaded runners. timeout-minutes: 60 needs: [verify-no-mocks, detect-projects] permissions: contents: read env: # .python-version requests 3.12 by default; bind uv to this exact matrix # minor so the capability contract covers the interpreter actually used. UV_PYTHON: ${{ matrix.python-version }} strategy: fail-fast: false # The include list is the exact canonical project × Python product from # `public_capabilities.py`; no project or Python literal is duplicated in # workflow YAML. matrix: ${{ fromJSON(needs.detect-projects.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env with: python-version: ${{ matrix.python-version }} - name: Sync dependencies (dev + optional test groups) # `public-exemplars` is the single deterministic dependency contract # for every canonical public project, including scientific, literature, # PPTX, and monitoring dependencies. run: uv sync --group public-exemplars - name: Verify selected Python minor run: | uv run python -c \ 'import sys; expected=tuple(map(int, "${{ matrix.python-version }}".split("."))); assert sys.version_info[:2] == expected, (sys.version, expected)' # scripts/pipeline/stage_01_test.py --project runs pytest with cwd= and enforces that # project's own --cov-fail-under=90 internally, writing coverage into the # project dir (projects//.coverage.project + coverage_project.json). # The gate is therefore authoritative here; no repo-root coverage step. - name: Run project tests env: PYTHONPATH: . run: | set -euo pipefail uv run python scripts/pipeline/stage_01_test.py \ --project "${{ matrix.project }}" --project-only --include-slow - name: Upload project coverage to Codecov if: matrix.python-version == '3.12' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: projects/${{ matrix.project }}/coverage_project.json flags: projects name: project-coverage fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # ───────────────────────────────────────────────────────────────────────────── # Job 4b — fep_lean (real math-inc gauss + elan lake/lean; excluded from matrix above) # This job uses projects/fep_lean/ paths. When fep_lean is not checked out # (it lives in the private repo's working/ pool), the detect job emits # fep_lean=false and this job is skipped. Check it out flat under projects/ to # activate CI: # cp -r /working/fep_lean projects/fep_lean # ───────────────────────────────────────────────────────────────────────────── fep-lean: name: fep_lean (gauss + lake) if: needs.detect.outputs.fep_lean == 'true' runs-on: ubuntu-latest timeout-minutes: 60 needs: [verify-no-mocks, detect] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies (dev + optional test groups) run: uv sync --group rendering --group monitoring - name: Sync fep_lean project (explicit venv + dev extras) run: uv sync --directory projects/fep_lean --extra dev - name: Install elan (lean + lake) run: | set -euxo pipefail curl -sSf \ https://raw.githubusercontent.com/leanprover/elan/58e8d545e33641f66dbcbd22c4283109e71757be/elan-init.sh \ -o "$RUNNER_TEMP/elan-init.sh" echo "4bacca9502cb89736fe63d2685abc2947cfbf34dc87673504f1bb4c43eda9264 $RUNNER_TEMP/elan-init.sh" \ | sha256sum --check --status sh "$RUNNER_TEMP/elan-init.sh" -y echo "$HOME/.elan/bin" >> "$GITHUB_PATH" - name: Pin Lean toolchain and warm Lake build working-directory: projects/fep_lean/lean run: | set -euxo pipefail TOOLCHAIN=$(tr -d '[:space:]' < lean-toolchain) elan override set "$TOOLCHAIN" lean --version lake --version lake build - name: Reject sorry in Lean sketches working-directory: projects/fep_lean/lean/FepSketches run: | set -euo pipefail SORRY_LINES=$(grep -n 'sorry' fep_all.lean Basic.lean \ | grep -Ev ':[[:space:]]*--' || true) if [ -n "$SORRY_LINES" ]; then echo "ERROR: 'sorry' found in non-comment Lean lines:" echo "$SORRY_LINES" exit 1 fi echo "No sorry in Lean sketches." - name: Install Open Gauss CLI timeout-minutes: 25 env: OPEN_GAUSS_AUTO_ATTACH: "0" run: | set -euxo pipefail export PATH="$HOME/.local/bin:$PATH" git clone --depth 1 https://github.com/math-inc/OpenGauss.git "$RUNNER_TEMP/OpenGauss" cd "$RUNNER_TEMP/OpenGauss" # Pin to the reviewed HEAD SHA; update this value intentionally when upgrading. EXPECTED_SHA="f87633900ae185b8037bf451a914fe7eeae1eb08" ACTUAL_SHA="$(git rev-parse HEAD)" if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then echo "ERROR: OpenGauss HEAD SHA mismatch." echo " Expected: $EXPECTED_SHA" echo " Got: $ACTUAL_SHA" echo "Update EXPECTED_SHA in .github/workflows/ci.yml after reviewing the diff." exit 1 fi ./scripts/install.sh --plain --noninteractive --skip-system-packages echo "$HOME/.local/bin" >> "$GITHUB_PATH" command -v gauss gauss doctor - name: Run fep_lean tests (real gauss doctor + lake build) working-directory: projects/fep_lean env: COVERAGE_FILE: ../../.coverage.fep_lean run: >- uv run pytest tests/ --timeout=1200 --cov=src --cov-report=term-missing --cov-fail-under=89 --durations=10 -m "not requires_ollama" # ───────────────────────────────────────────────────────────────────────────── # Job 5 — Validate Manuscripts # ───────────────────────────────────────────────────────────────────────────── validate: name: Validate Manuscripts runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Resolve public project names id: project-scope run: echo "names=$(uv run python -m infrastructure.project.public_scope project-names)" >> "$GITHUB_OUTPUT" - name: Validate manuscript markdown # The `markdown` subcommand takes ONE directory; project globs # shell-globs to multiple dirs (CLI then errors "unrecognized # arguments"). Validate each public project's manuscript in turn. run: | set -euo pipefail for project in ${{ steps.project-scope.outputs.names }}; do d="projects/$project/manuscript" [ -d "$d" ] || continue echo "::group::validate $d" uv run python -m infrastructure.validation.cli markdown "$d" echo "::endgroup::" done - name: Verify api-reference.md is in sync with __all__ # Auto-generated from each `infrastructure//__init__.py` `__all__` # by `scripts/docgen/api_reference.py`. Drift fails the build; # regenerate locally with `--write` and commit the result. run: uv run python scripts/docgen/api_reference.py --check - name: Verify exemplar_roster.md is in sync with the live roster # Auto-generated from the public exemplar roster by # `scripts/docgen/exemplar_roster.py`. Adding a test file to an # exemplar changes its row; without this gate the committed roster # drifted silently and was only caught by the full infra pytest run. # Drift fails the build; regenerate locally and commit the result. run: uv run python scripts/docgen/exemplar_roster.py --check - name: Verify COUNTS.md is in sync with the live tree # Auto-generated from live repo state by `scripts/docgen/counts.py` # (tracked infrastructure .py count, project/publishing test-collection # totals, exemplar roster, module list). Replaces the formerly # hand-maintained COUNTS.md whose drift was chased across ~40 # commits. Drift fails the build; regenerate locally with `--write` and # commit the result. run: uv run python scripts/docgen/counts.py --check - name: Verify publication_records.md is in sync with public metadata run: uv run python scripts/docgen/publication_records.py --check - name: Verify project imports env: PUBLIC_PROJECTS: ${{ steps.project-scope.outputs.names }} # Public project names are now QUALIFIED (``templates/``) and the # projects/ tree is not a Python package chain (no projects/__init__.py), # so a dotted ``projects..src`` import cannot resolve. Import each # project's ``src`` package in an ISOLATED subprocess with the project's # own sys.path (repo root for ``infrastructure``, project dir, and its # ``src``) — the same way pytest's pythonpath is configured — which also # avoids the top-level ``src`` name collision across projects. run: | uv run python -c " import os import subprocess import sys repo = os.getcwd() projects = os.environ['PUBLIC_PROJECTS'].split() if not projects: print('No projects found — skipping import check') sys.exit(0) failed = False for project in projects: proj_dir = os.path.join(repo, 'projects', *project.split('/')) env = dict(os.environ) env['PYTHONPATH'] = os.pathsep.join([repo, proj_dir, os.path.join(proj_dir, 'src')]) result = subprocess.run([sys.executable, '-c', 'import src'], env=env, capture_output=True, text=True) if result.returncode == 0: print(f' OK: {project} (src)') else: tail = (result.stderr.strip().splitlines() or [str(result.returncode)])[-1] print(f' FAIL: {project}: {tail}') failed = True if failed: sys.exit(1) print(f'All {len(projects)} project(s) imported successfully') " # ───────────────────────────────────────────────────────────────────────────── # Job 6 — Security Scan # ───────────────────────────────────────────────────────────────────────────── security: name: Security Scan runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Resolve public project names id: security-scope run: echo "names=$(uv run python -m infrastructure.project.public_scope project-names)" >> "$GITHUB_OUTPUT" - name: Dependency audit (pip-audit) run: | set -euo pipefail IGNORE_FILE=".github/pip-audit-ignore.txt" LOCK_REQUIREMENTS="${RUNNER_TEMP}/template-all-requirements.txt" uv run python scripts/gates/pip_audit_ignore_policy.py "$IGNORE_FILE" uv export --all-groups --all-extras --frozen --no-hashes \ --no-emit-project --output-file "$LOCK_REQUIREMENTS" PIP_AUDIT_ARGS=() while IFS= read -r raw || [ -n "$raw" ]; do [[ "$raw" =~ ^[[:space:]]*# ]] && continue line="${raw%%#*}" line="$(echo "$line" | xargs)" [ -z "$line" ] && continue PIP_AUDIT_ARGS+=(--ignore-vuln "$line") done < "$IGNORE_FILE" for attempt in 1 2 3; do if uv run pip-audit --requirement "$LOCK_REQUIREMENTS" \ --no-deps --disable-pip "${PIP_AUDIT_ARGS[@]}"; then exit 0 fi echo "pip-audit attempt ${attempt} failed; retrying in 15s..." >&2 sleep 15 done exit 1 - name: Code security scan (Bandit MEDIUM+ severity) run: | set -euo pipefail targets=(infrastructure scripts) for project in ${{ steps.security-scope.outputs.names }}; do targets+=("projects/$project") done uv run bandit -c bandit.yaml -r -ll "${targets[@]}" # Targeted shell-injection sweep at LOW severity: the MEDIUM+ gate above # rates a constant-string ``shell=True`` (B602/B604/B605/B609) as LOW and # so lets it pass. This always-on pass closes that gap across all three # trees; keep it green by never introducing ``shell=True`` / partial-path # ``os.system`` calls (use list-form argv with ``shell=False``). - name: Shell-injection sweep (Bandit B602/B604/B605/B609, LOW+) run: | set -euo pipefail targets=(infrastructure scripts) for project in ${{ steps.security-scope.outputs.names }}; do targets+=("projects/$project") done uv run bandit -c bandit.yaml -r \ -t B602,B604,B605,B609 --severity-level low \ "${targets[@]}" # ───────────────────────────────────────────────────────────────────────────── # Job 6b — Documentation Lint (mermaid + cross-links + consistency + doc pairs) # # Rules enforced: # 1. Every fenced ```mermaid block in long-lived docs must render with the # real `mmdc` (mermaid-cli) binary backed by chrome-headless-shell. # 2. Every relative Markdown link must resolve on disk (skipping fenced and # inline-code spans). # 3. ``N Python (sub)packages`` claims in long-lived docs must match the # live count under ``infrastructure/``; rotating project names must not # appear unconditionally hard-coded outside ``docs/_generated/``. # 4. Permanent-template content folders must carry paired ``AGENTS.md`` and # ``README.md`` files. # # This job intentionally fails LOUDLY when mmdc / chrome-headless-shell is # missing instead of skipping — see ``infrastructure/validation/docs/mermaid_lint.py``. # ───────────────────────────────────────────────────────────────────────────── docs-lint: name: Documentation Lint runs-on: ubuntu-latest timeout-minutes: 15 needs: [lint] permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - uses: ./.github/actions/setup-docs-lint - name: Sync Python dependencies run: uv sync - name: Run docs linters (mermaid + cross-links + consistency + doc pairs) run: uv run python scripts/audit/lint_docs.py --quiet - name: Check template exemplar drift (strict — dead links gate) run: uv run python scripts/audit/check_template_drift.py --strict - name: Skill-reachability gate (front-door links + index completeness) run: uv run python scripts/gates/skill_reachability_check.py # ───────────────────────────────────────────────────────────────────────────── # Job 7 — Performance Check # ───────────────────────────────────────────────────────────────────────────── performance: name: Performance Check runs-on: ubuntu-latest timeout-minutes: 5 needs: [test-infra, test-project] permissions: actions: read contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env - name: Sync dependencies run: uv sync - name: Run import benchmarks run: | uv run python -c " import time import sys from pathlib import Path from infrastructure.project.public_scope import public_project_names MAX_IMPORT_SECONDS = 5.0 results = {} # Infrastructure core t0 = time.perf_counter() try: import infrastructure.core results['infrastructure.core'] = time.perf_counter() - t0 except ImportError as e: print(f'SKIP infrastructure.core: {e}') import subprocess import os projects = public_project_names(Path('.')) for project in projects: project_path = Path('.') / 'projects' / project project_dir = str(project_path) project_src_dir = str(project_path / 'src') label = f'projects/{project}/src' pythonpath = os.pathsep.join( part for part in [project_dir, project_src_dir, os.environ.get('PYTHONPATH', '')] if part ) env = {**os.environ, 'PYTHONPATH': pythonpath} t0 = time.perf_counter() proc = subprocess.run( [sys.executable, '-c', 'import src'], env=env, capture_output=True, ) elapsed = time.perf_counter() - t0 if proc.returncode == 0: results[label] = elapsed else: print(f'SKIP {label}: {proc.stderr.decode().strip()}') total = sum(results.values()) print('Import timing:') for name, elapsed in results.items(): print(f' {elapsed:.3f}s {name}') print(f'Total: {total:.3f}s (informational; public roster size dependent)') print(f'Per-import threshold: {MAX_IMPORT_SECONDS}s') slow = {name: elapsed for name, elapsed in results.items() if elapsed > MAX_IMPORT_SECONDS} if slow: print('ERROR: One or more imports exceed threshold') for name, elapsed in slow.items(): print(f' {elapsed:.3f}s {name}') sys.exit(1) print('Performance check passed') " # Informational microbench harness for setup_hook + analysis_pipeline (MED6). # `|| true` keeps this strictly informational — a slow bench will never # fail the build. Results are uploaded as a CI artifact for trend analysis. - name: Run setup_hook + analysis_pipeline microbenches (informational) run: | uv run pytest tests/infra_tests/benchmark/ -m bench --benchmark-only \ --benchmark-min-rounds=3 --benchmark-json=bench-results.json \ --timeout=180 || true - name: Upload microbench results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bench-results path: bench-results.json if-no-files-found: warn # MED3 — coverage trend dashboard. Pulls last 30 days of CI artefacts # via `gh run download` and renders ``docs/_generated/coverage_history.md``. # Strictly informational (`|| true`); the result is uploaded as the # ``coverage-history`` artefact for trend review. - name: Generate coverage history dashboard (informational) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | uv run python scripts/docgen/coverage_history.py --from-gh --days=30 || true - name: Upload coverage history dashboard uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-history path: docs/_generated/coverage_history.md if-no-files-found: warn # ───────────────────────────────────────────────────────────────────────────── # Job — Public-matrix receipt (weekly/manual only) # # The per-project test-projects jobs run one exemplar per matrix cell, so no # single job can produce the aggregated public-matrix receipt. This scheduled # lane runs the full receipt-bearing matrix in one process and uploads the # deterministic receipt (roster revision, per-project floor/exit/coverage/ # output-isolation) for operator verification. Deliberately NOT run on push/PR # — a full 24-exemplar matrix takes ~45 min and would triple every PR's cost. # ───────────────────────────────────────────────────────────────────────────── public-matrix-receipt: name: Public Matrix Receipt if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 120 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-python-env with: python-version: "3.12" - name: Sync dependencies (dev + optional test groups) run: uv sync --group public-exemplars - name: Run receipt-bearing public matrix run: >- uv run python scripts/pipeline/stage_01_test.py --project-only --all-projects --public-scope --workers 2 --receipt public_matrix_receipt.json - name: Upload public-matrix receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: public-matrix-receipt path: public_matrix_receipt.json if-no-files-found: error