release: v2.2.1 #579
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main, release/**] | |
| pull_request: | |
| branches: [main, release/**] | |
| # Cancel superseded runs on the same ref (e.g. a force-push or rapid PR | |
| # updates). `main` keeps each push separate (group includes SHA) so we | |
| # never lose a post-merge run; PR refs and release/** branch pushes | |
| # collapse to one in-flight run. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| jobs: | |
| backend: | |
| name: Backend (Python) | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| python-version: "3.13" | |
| - name: Install dependencies | |
| run: uv sync | |
| - name: Lint (ruff) | |
| run: uv run ruff check . | |
| - name: Format check (ruff) | |
| run: uv run ruff format --check . | |
| - name: Type check (mypy, filtered through mypy-baseline) | |
| # Pre-existing errors accepted via mypy-baseline.txt; the filter | |
| # exits non-zero only on NET-NEW errors. Refresh the baseline after | |
| # a burndown PR with | |
| # uv run mypy backend/ 2>&1 | uv run mypy-baseline sync | |
| # and commit mypy-baseline.txt. | |
| run: uv run mypy backend/ 2>&1 | uv run mypy-baseline filter | |
| - name: Architectural contracts (import-linter, R-9) | |
| # Enforces: | |
| # - Routers are independent (no router imports another router, | |
| # transitively). Pre-existing cross-router edges are | |
| # baselined in pyproject.toml [tool.importlinter]; new | |
| # edges fail the gate. | |
| # - Core does not depend on routers (no inversion of | |
| # web ↔ analytics layering). | |
| run: uv run lint-imports | |
| - name: Install falco | |
| # Pinned to match backend/Dockerfile's FALCO_VERSION so CI lints VCL | |
| # with the SAME falco the prod backend uses. An unpinned `latest` is a | |
| # moving supply-chain target AND can accept/reject a recv snippet | |
| # differently than production (silent CI-vs-prod VCL-lint drift on a | |
| # security-relevant validation path). Bump deliberately alongside the | |
| # Dockerfile ARG. | |
| run: | | |
| FALCO_VERSION=2.3.0 | |
| # Resolve the release arch from the runner so this works on both | |
| # x86_64 and arm64 runners (e.g. ubuntu-22.04 vs ubuntu-22.04-arm). | |
| case "$(uname -m)" in | |
| x86_64) ARCH=amd64 ;; | |
| aarch64) ARCH=arm64 ;; | |
| *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| # Extract the whole archive (no member arg) — it contains only the | |
| # binary, stored as "./falco". GNU tar (the runner's tar) won't match | |
| # a bare "falco" against the "./"-prefixed name, so naming the member | |
| # fails with "falco: Not found in archive". This mirrors how | |
| # backend/Dockerfile installs falco, keeping CI and prod identical. | |
| sudo curl -sSfL "https://github.com/ysugimoto/falco/releases/download/v${FALCO_VERSION}/falco-linux-${ARCH}.tar.gz" \ | |
| | sudo tar -xz -C /usr/local/bin | |
| sudo chmod +x /usr/local/bin/falco | |
| falco --version | |
| - name: Install gitleaks | |
| # Same curl-binary-to-PATH pattern as falco above. Version pinned so | |
| # a detector-rule change doesn't suddenly fail an unrelated PR; bump | |
| # deliberately when wanted. Mirrors `.pre-commit-config.yaml`. | |
| run: | | |
| GITLEAKS_VERSION=8.30.1 | |
| # gitleaks names x86_64 assets "x64" (not "amd64"); arm64 matches. | |
| case "$(uname -m)" in | |
| x86_64) ARCH=x64 ;; | |
| aarch64) ARCH=arm64 ;; | |
| *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| sudo curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${ARCH}.tar.gz" \ | |
| | sudo tar -xz -C /usr/local/bin gitleaks | |
| sudo chmod +x /usr/local/bin/gitleaks | |
| gitleaks version | |
| - name: Secret scan (gitleaks) | |
| # Scans full git history against the .gitleaks.toml allowlist. | |
| # `--exit-code 1` is the default; explicit for clarity. Anything | |
| # the allowlist doesn't cover fails the build with a redacted | |
| # diagnostic — see CONTRIBUTING.md / AGENTS.md for the | |
| # suppression playbook. | |
| run: gitleaks detect --no-banner --redact --config .gitleaks.toml --exit-code 1 | |
| - name: Install osv-scanner | |
| # Same curl-binary-to-PATH pattern as falco and gitleaks above. | |
| # Version pinned so a CVE-database refresh doesn't suddenly fail | |
| # an unrelated PR; bump deliberately when wanted. | |
| run: | | |
| OSV_VERSION=2.2.4 | |
| case "$(uname -m)" in | |
| x86_64) ARCH=amd64 ;; | |
| aarch64) ARCH=arm64 ;; | |
| *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| sudo curl -sSfL "https://github.com/google/osv-scanner/releases/download/v${OSV_VERSION}/osv-scanner_linux_${ARCH}" \ | |
| -o /usr/local/bin/osv-scanner | |
| sudo chmod +x /usr/local/bin/osv-scanner | |
| osv-scanner --version | |
| - name: Dependency vulnerability scan (osv-scanner, CRITICAL gate) | |
| # scripts/check_osv.py runs osv-scanner once and exits non-zero | |
| # only on CRITICAL vulnerabilities. Lower severities print as a | |
| # warning table but don't block — they get triaged via Dependabot. | |
| # Lives in scripts/ so it's also runnable locally via `make osv`. | |
| run: uv run python scripts/check_osv.py | |
| - name: Install terraform | |
| # Required by tests/utils/test_terraform_gen.py — runs `terraform fmt` | |
| # against generator output and `validate` when TERRAFORM_VALIDATE=1. | |
| uses: hashicorp/setup-terraform@v4 | |
| with: | |
| terraform_version: "~1.9" | |
| terraform_wrapper: false | |
| - name: Cache Terraform provider plugins | |
| uses: actions/cache@v4 | |
| with: | |
| path: cache/tf_provider_cache | |
| key: ${{ runner.os }}-terraform-providers-${{ hashFiles('backend/utils/terraform_gen.py', 'tests/utils/test_terraform_gen.py', 'tests/utils/test_terraform_resource_graph.py') }} | |
| restore-keys: | | |
| ${{ runner.os }}-terraform-providers- | |
| - name: Verify falco is required and runnable | |
| env: | |
| FALCO_REQUIRED: "1" | |
| run: | | |
| falco --version | |
| # Collect (don't run) the falco-tagged tests; assert at least one was found. | |
| # pytest 9 condenses `-q` output, so we use the verbose form and grep for the node ID. | |
| count=$(uv run pytest tests/core/test_vcl_semantics.py --collect-only 2>&1 | grep -c 'test_falco_' || true) | |
| echo "Discovered $count falco semantic test(s)" | |
| if [ "$count" -lt 1 ]; then | |
| echo "ERROR: expected at least one test_falco_* test to be collected" >&2 | |
| exit 1 | |
| fi | |
| - name: Tests (pytest with coverage) | |
| env: | |
| FALCO_REQUIRED: "1" | |
| TERRAFORM_VALIDATE: "1" | |
| # Coverage gate convention: ratchet --cov-fail-under to current actual − 2pp. | |
| # The 2pp buffer absorbs CI-vs-local jitter so it can't force-fail a build; | |
| # raise it as backend coverage clears the next floor. `make ratchet` prints actual. | |
| # | |
| # `-n auto` parallelizes via pytest-xdist (TESTING_PLAN_3 item 21). | |
| # Verified safe: per-service SQLite (`{id}.metadata.db`) + per-test | |
| # tmp_path give file isolation; autouse `_reset_module_caches` resets | |
| # the 8 module-level caches between tests; moto fixtures are per-test. | |
| # Local run: 2268 passed in 58s under `-n auto` vs ~3min serial. | |
| # | |
| # Two runs (mirrors Makefile `test-ci`; the only divergence is `nice`, | |
| # which is local-only ergonomics). The `terraform_cli`-marked tests | |
| # shell out to the real `terraform` binary — isolated subprocess work | |
| # that gains nothing from xdist and whose worker HARD-CRASHED under the | |
| # `-n auto` pool. Run them SERIALLY (`-n 0`) in a second step. | |
| # Run 1: fast suite, parallel, `-m "not terraform_cli"`, fresh | |
| # coverage (no --cov-append), report suppressed, no gate yet. | |
| # Run 2: terraform tests serial, --cov-append so the single | |
| # --cov-fail-under=86 gate sees the COMBINED coverage. | |
| run: | | |
| uv run pytest -n auto -m "not terraform_cli" --cov=backend --cov-report= | |
| uv run pytest -n 0 -m terraform_cli --cov=backend --cov-append --cov-report=term --cov-fail-under=86 | |
| - name: Observability guard (no OTEL_EXPORTER=console in deploy files) | |
| # SRE-10 / ADR-08 §5: the console exporter floods prod stdout with | |
| # ~1 MB/min of JSON (the 2026-06-10 incident). The default is `none` | |
| # in code; this catches a hardcoded `console` slipping into a tracked | |
| # compose/Dockerfile/env. Also runnable locally via `make ci`. | |
| run: bash scripts/check_no_console_otel.sh | |
| - name: Security-regression count gate | |
| # v2.0 cleanup Phase 0.8: asserts the | |
| # @pytest.mark.security_regression count never drops below the | |
| # baseline floor (24 — from the since-removed audit-findings/ | |
| # verified fixes). A refactor cannot silently delete coverage of a | |
| # verified fix without surfacing the change. | |
| run: bash scripts/check_security_regression_count.sh | |
| - name: Emit perf samples (CI-scale synthetic load) | |
| # Produces tests/perf/latest.json from a 100K-row in-memory | |
| # DuckDB dataset (~2 s wall). The gate below compares to | |
| # tests/perf/baseline.json and fails on >regression_pct_threshold% | |
| # over baseline (50 % default; tuned for GH Actions runner | |
| # variance at CI scale). | |
| run: uv run python scripts/emit_perf_latest.py | |
| - name: Perf gate (load-harness baseline) | |
| # Compares the just-emitted latest.json against baseline.json. | |
| # Production targets (≤2800 / ≤1900 ms) are documented in | |
| # baseline.json's production_targets_comment for traceability | |
| # but enforced by the manual loadtest probe, not this CI gate. | |
| run: bash scripts/perf_gate.sh | |
| frontend: | |
| name: Frontend (Node) | |
| runs-on: ubuntu-24.04 | |
| defaults: | |
| run: | |
| working-directory: frontend | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Node | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| python-version: "3.13" | |
| - name: Install backend dependencies | |
| run: uv sync | |
| working-directory: . | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Generate API types | |
| # `frontend/types/api.generated.ts` is regenerated fresh on every | |
| # CI run. The drift guard below catches the case where a contributor | |
| # bypassed the pre-commit `regen-openapi` hook (or where the backend | |
| # OpenAPI surface changed without a corresponding type regen). The | |
| # backend-side guard is `tests/test_openapi_snapshot.py`; this is | |
| # the consumer-side mirror. | |
| run: npm run gen:types | |
| - name: Detect drift in generated OpenAPI types | |
| # Pre-commit runs the same generator, so the only way this fires | |
| # is (a) someone bypassed --no-verify or (b) the openapi-typescript | |
| # tool version drifted between local and CI. Either way, the right | |
| # response is to regenerate locally and commit. | |
| run: | | |
| if ! git diff --exit-code types/api.generated.ts openapi.json; then | |
| echo "::error::Generated OpenAPI types are out of sync. Run 'npm run gen:types' locally and commit the result." >&2 | |
| exit 1 | |
| fi | |
| - name: Type check (tsc) | |
| run: npx tsc --noEmit | |
| - name: ESLint count-ceiling gate | |
| # ESLint was previously gated nowhere (this job runs gen:types + tsc + | |
| # vitest; the backend job runs the Python import-linter). The gate | |
| # fails if the source eslint error count rises above the committed | |
| # ceiling, catching new `as any` / rules-of-hooks before runtime. | |
| # Ratchet the ceiling down as violations are removed. | |
| # The script resolves the repo root itself, so call it with ../. | |
| run: bash ../scripts/check_eslint_count.sh | |
| - name: Tests (vitest with coverage) | |
| # Coverage gate convention: ratchet each threshold to current actual − 2pp | |
| # (the 2pp buffer absorbs CI-vs-local jitter). GATE-03 (2026-06-19): enforce | |
| # statements/functions/branches floors too, not just lines — else an uncovered | |
| # error-path branch (no new lines) can't drop the gate, and the branch floor is | |
| # what catches a happy-path-only test. `make ratchet` prints all four. | |
| run: >- | |
| npx vitest run --coverage | |
| --coverage.thresholds.lines=66 | |
| --coverage.thresholds.statements=65 | |
| --coverage.thresholds.functions=54 | |
| --coverage.thresholds.branches=52 | |
| scorer: | |
| name: Scorer (Rust) | |
| runs-on: ubuntu-24.04 | |
| defaults: | |
| run: | |
| working-directory: compute/scorer | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| # compute/scorer/rust-toolchain.toml pins the channel (1.90). We install | |
| # that toolchain explicitly so `rustc`/`cargo` exist for the cache step | |
| # below; rustup then honours the toml override when cargo runs here. | |
| # Install rustup itself only if the runner image doesn't ship it. | |
| run: | | |
| if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then | |
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | |
| | sh -s -- -y --default-toolchain 1.90 --profile minimal | |
| fi | |
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | |
| - name: Cache cargo registry + build | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: compute/scorer | |
| - name: Run scorer unit tests | |
| # Native (host-target) tests: Python↔Rust normalize/cookie/matrix | |
| # parity, session-expiry boundaries, and the scoring math. These are the | |
| # 80+ `#[test]`s that no other CI job runs — a Rust-side normalizer, | |
| # wire-format, or expiry regression ships green without this. `--locked` | |
| # also fails if Cargo.lock drifted. No Fastly CLI needed: the dev | |
| # profile builds for the host; Wasm is only built for deploy | |
| # (`make scorer-package`). | |
| run: cargo test --locked | |
| - name: Audit dependencies for RustSec advisories | |
| # The scorer verifies AES-GCM cookie integrity at the edge, so a future | |
| # advisory in a crypto/RNG crate (aes-gcm/ghash/polyval/getrandom/time) | |
| # must fail CI rather than ship green — `cargo test --locked` above does | |
| # not check advisories. `cargo audit` exits non-zero on any known | |
| # vulnerability in the locked tree. cargo-audit is cached by rust-cache | |
| # (cache-bin) after the first install. | |
| run: | | |
| command -v cargo-audit >/dev/null || cargo install cargo-audit --locked | |
| cargo audit |