Files
OmniRoute/.github/workflows/ci.yml
Diego Rodrigues de Sa e Souza 4d21044ba5 fix(release): post-merge quality gates to main for v3.8.26 (#3964)
Cherry-picks #3961 + #3962 from release/v3.8.26 to main (parity before tagging).
2026-06-16 02:33:19 -03:00

989 lines
40 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CI_NODE_VERSION: "24"
CI_NODE_24_VERSION: "24"
CI_NODE_26_VERSION: "26"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
env:
# tsx gates below (known-symbols, route-guard-membership) import modules that
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run audit:deps
- run: npm run lint
- run: npm run check:cycles
- run: npm run check:route-validation:t06
- run: npm run check:any-budget:t11
- run: npm run check:provider-consistency
- run: npm run check:fetch-targets
- run: npm run check:deps
- run: npm run check:file-size
- run: npm run check:error-helper
- run: npm run check:migration-numbering
- run: npm run check:public-creds
- run: npm run check:db-rules
- run: npm run check:known-symbols
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:tracked-artifacts
- run: npm run check:lockfile
- run: npm run check:licenses
- run: npm run check:docs-sync
- run: npm run typecheck:core
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
# Run informationally for now — many pre-existing call sites still need
# explicit annotations; track in a dedicated follow-up.
- run: npm run typecheck:noimplicit:core
continue-on-error: true
quality-gate:
name: Quality Ratchet
runs-on: ubuntu-latest
needs: test-coverage
if: ${{ always() && needs.test-coverage.result == 'success' }}
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
permissions:
contents: read
security-events: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: coverage/
- run: npm run quality:collect
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
- name: Ratchet check
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
# Fase 6A.5: require-tighten — ADVISORY por enquanto (continue-on-error). Reporta
# quando uma métrica MELHOROU sem o baseline ter sido apertado no mesmo PR (força
# capturar ganhos permanentes). As métricas coverage.* carregam tightenSlack para
# o gap anti-flake (CI mergeado > baseline) não disparar falso-positivo. Promover a
# BLOQUEANTE no fim do ciclo removendo o `continue-on-error` (ver a nota
# _require_tighten_advisory em config/quality/quality-baseline.json).
- name: Require-tighten (advisory — flip to blocking at cycle-end)
continue-on-error: true
run: node scripts/quality/check-quality-ratchet.mjs --require-tighten
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
# para não pesar no caminho crítico do lint.
- name: Duplication ratchet
run: npm run check:duplication
- name: Complexity ratchet
run: npm run check:complexity
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
# de quality-baseline.json e saem 1 em regressão.
- name: Dead-code ratchet (knip)
run: npm run check:dead-code
- name: Cognitive complexity ratchet (sonarjs)
run: npm run check:cognitive-complexity
- name: Type coverage ratchet
run: npm run check:type-coverage
# CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26).
# Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa
# regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api)
# é skip gracioso com exit 0 — security-events:read no job-level permissions.
- name: CodeQL alerts ratchet (blocking)
run: npm run check:codeql-ratchet
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Append summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload ratchet report
if: always()
uses: actions/upload-artifact@v7
with:
name: quality-ratchet
path: .artifacts/quality-ratchet.md
if-no-files-found: warn
# Phase 7/8 extended quality gates — MIXED (Etapa 2, v3.8.26). The job no longer
# carries a job-level continue-on-error: the three ratchet-blocking steps below
# (Secret scan / Workflow lint / Bundle size, all passing --ratchet) FAIL the job
# on a measured regression vs config/quality/quality-baseline.json. The remaining
# steps stay ADVISORY via step-level continue-on-error (scanner install,
# vuln/osv ratchet, OpenAPI/oasdiff breaking-change, circular-deps/dpdm): they
# depend on external binaries/state that may legitimately self-skip, so they must
# never block. The blocking gates themselves SKIP (exit 0) when their binary/plugin
# is absent — only a measured regression on the SAME metric the baseline froze
# blocks. The CodeQL ratchet was PROMOTED to the quality-gate job (v3.8.26).
# SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets.
quality-extended:
name: Quality Gates (Extended)
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
# would lack the base ref and the gate would self-skip (base-unresolved).
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
- name: Circular deps (dpdm; advisory)
continue-on-error: true
run: npm run check:circular-deps
# BLOCKING ratchet (Etapa 2): bundleSize must not regress vs the baseline
# (gzip via @size-limit/file, installed by `npm ci`). --ratchet exits 1 on a
# measured regression; it SKIPs (exit 0) when the size-limit plugin/build is
# absent (a non-comparable measurement never blocks).
- name: Bundle size (ratchet, blocking)
run: npm run check:bundle-size -- --ratchet
# CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) —
# não roda aqui para evitar duplo run/duplo report.
# Install the advisory security scanners so the gates below actually run
# (they self-skip when the binaries are absent). Robustness lessons baked in:
# • `go install …/gitleaks/v8@latest` produces a binary WITHOUT the version
# ldflags gitleaks needs (and often fails) — avoided.
# • `curl …api.github.com/…/releases/latest` is UNAUTHENTICATED and
# rate-limited to 60 req/hr/IP; when throttled it returns an empty body,
# so the asset URL resolves to nothing and the install silently no-ops —
# every gate then self-skips and the metric is never produced. We instead
# use `gh release download`, which is preinstalled on GitHub runners and
# authenticated via GITHUB_TOKEN (5000 req/hr) — robust under load.
# • actionlint keeps its official download script; zizmor stays on pipx.
# We `set +e` (no single failure aborts the step), ALWAYS export $GITHUB_PATH
# at the end, and print diagnostics so the next CI run proves exactly what
# installed. The job is continue-on-error too, so an install hiccup never
# blocks the build.
- name: Install advisory security scanners (gitleaks/osv/actionlint/zizmor)
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set +e
mkdir -p "$HOME/.local/bin"
# gitleaks — download latest linux x64 tarball via gh (authed), extract binary
rm -rf /tmp/gl && mkdir -p /tmp/gl
gh release download --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
# osv-scanner — download latest linux amd64 bare binary via gh (authed)
rm -rf /tmp/osv && mkdir -p /tmp/osv
gh release download --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
# actionlint — official download script
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
pipx install zizmor || pip install --user zizmor
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
# ALWAYS export the bin dir (even if any step above failed)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# diagnostics — prove what installed on the next CI run
ls -la "$HOME/.local/bin"
"$HOME/.local/bin/gitleaks" version || true
"$HOME/.local/bin/actionlint" -version || true
"$HOME/.local/bin/osv-scanner" --version || true
"$HOME/.local/bin/oasdiff" --version || true
zizmor --version || true
# BLOCKING ratchet (Etapa 2): secretFindings must not regress vs the baseline.
# --ratchet exits 1 on a measured regression; it SKIPs (exit 0) when gitleaks
# is absent (a missing binary never blocks).
- name: Secret scan (gitleaks, ratchet, blocking)
run: npm run check:secrets -- --ratchet
- name: Vulnerability ratchet (osv-scanner; advisory, skips if absent)
continue-on-error: true
run: npm run check:vuln-ratchet
# BLOCKING ratchet (Etapa 2): zizmorFindings must not regress vs the baseline.
# ONLY zizmor is ratcheted — actionlint findings are reported, not blocking.
# --ratchet exits 1 on a measured zizmor regression; it SKIPs (exit 0) when
# zizmor is absent (a missing binary never blocks).
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
run: npm run check:workflows -- --ratchet
# OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API
# contract (docs/reference/openapi.yaml) against the base branch's spec.
# ADVISORY: reports `openapiBreaking=N` and self-skips when oasdiff is absent
# or the base spec can't be resolved. BASE_REF is read by the script from the
# env (never interpolated into a shell body) — workflow-injection-safe.
- name: OpenAPI breaking-change (oasdiff; advisory)
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
run: npm run check:openapi-breaking
docs-sync-strict:
name: Docs Sync (Strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:docs-all
# Previously-orphaned contract gates (existed as files, never wired anywhere).
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
# (floor ~36), openapi-security-tiers is advisory (Hard Rules #15/#17).
- name: CLI i18n consistency
run: npm run check:cli-i18n
- name: OpenAPI route coverage (ratchet)
run: npm run check:openapi-coverage
- name: OpenAPI security-tier consistency (advisory)
run: npm run check:openapi-security-tiers
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
run: npm run check:openapi-routes
- name: Doc /api refs resolve to real routes (anti-hallucination)
run: npm run check:docs-symbols
- name: i18n translation drift (warn)
run: node scripts/i18n/check-translation-drift.mjs --warn
docs-lint:
name: Docs Lint (prose — advisory)
runs-on: ubuntu-latest
# Advisory (warning-first): prose/markdown style must not block merges while the
# existing doc corpus is brought up to style. Promote to blocking once it converges.
continue-on-error: true
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- name: markdownlint (docs + root, advisory)
run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true
- name: Vale prose lint (Microsoft style, advisory)
# Non-fatal: a Vale/reviewdog setup error must not turn this advisory job red.
continue-on-error: true
uses: errata-ai/vale-action@reviewdog
with:
files: docs
fail_on_error: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
i18n-ui-coverage:
name: i18n UI Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
i18n-matrix:
name: Build language matrix
runs-on: ubuntu-latest
outputs:
langs: ${{ steps.langs.outputs.langs }}
steps:
- uses: actions/checkout@v6
- id: langs
run: |
LANG_DIR="src/i18n/messages"
LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .)
echo "langs=${LANGS}" >> "$GITHUB_OUTPUT"
i18n:
name: i18n Validation
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
needs: i18n-matrix
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate ${{ matrix.lang }}
env:
# Pass the matrix value via env (never interpolate ${{ ... }} straight
# into the run: script body) so the shell receives a variable, not
# inlined text — zizmor template-injection mitigation. Named MATRIX_LANG
# to avoid clobbering the POSIX `LANG` locale variable.
MATRIX_LANG: ${{ matrix.lang }}
run: |
python3 scripts/i18n/validate_translation.py quick -l "$MATRIX_LANG" > result.txt
- name: Upload result
if: always()
uses: actions/upload-artifact@v7
with:
name: i18n-${{ matrix.lang }}
path: result.txt
pr-test-policy:
name: PR Test Policy
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
- name: Fetch base branch
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
- name: Validate source changes include tests
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
- name: Detect test-masking (weakened assertions)
run: npm run check:test-masking
# Evidence-in-PR-body (Hard Rule #18 mechanized): claims of "tests pass" must carry output.
- name: Require evidence in PR body
run: npm run check:pr-evidence
env:
PR_BODY: ${{ github.event.pull_request.body }}
- name: Publish PR test policy summary
if: always()
run: |
if [ -f .artifacts/pr-test-policy.md ]; then
cat .artifacts/pr-test-policy.md >> "$GITHUB_STEP_SUMMARY"
fi
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- name: Archive Next.js build for E2E shards
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
run: |
tar -czf /tmp/e2e-build.tar.gz \
--exclude='.build/next/standalone/node_modules' \
--exclude='.build/next/cache' \
.build/next
- name: Upload Next.js build for E2E shards
uses: actions/upload-artifact@v7
with:
name: e2e-next-build
path: /tmp/e2e-build.tar.gz
retention-days: 1
package-artifact:
name: Package Artifact
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
# build:cli runs a clean build into .build/next and assembles dist/
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
- run: npm run build:cli
- name: Assert dist/server.js exists
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
- run: npm run check:pack-artifact
electron-package-smoke:
name: Electron Package Smoke
runs-on: ubuntu-latest
timeout-minutes: 25
needs: build
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- name: Install Electron dependencies
working-directory: electron
run: npm install --no-audit --no-fund
- name: Pack Electron app
working-directory: electron
run: npm run pack
- name: Smoke packaged Electron app
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
run: xvfb-run -a npm run electron:smoke:packaged
test-unit:
name: Unit Tests (${{ matrix.shard }}/8)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
test-vitest:
name: Vitest (MCP / autoCombo / UI components)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
- run: npm run test:vitest
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
# fixes the components/tests; then drop continue-on-error to make it blocking.
- run: npm run test:vitest:ui
continue-on-error: true
node-24-compat:
name: Node 24 Compatibility (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_24_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
node-26-compat:
name: Node 26 Compatibility (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_26_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
test-coverage-shard:
name: Coverage Shard (${{ matrix.shard }}/8)
runs-on: ubuntu-latest
timeout-minutes: 25
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- name: Run c8 over shard ${{ matrix.shard }}/8
run: |
rm -rf coverage-shard coverage-shard-report
# `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge
# job reads with `c8 report --temp-directory ...`. Using `--output-dir`
# only produces the final json *report* and leaves the raw v8 files in
# `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp
# dir so the raw coverage files live there and the artifact upload picks
# them up regardless of `--test-force-exit` timing.
npx c8 \
--temp-directory=coverage-shard \
--reports-dir=coverage-shard-report \
--reporter=json \
--exclude=tests/** \
--exclude=**/*.test.* \
node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 \
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
- name: Upload raw shard coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-shard-${{ matrix.shard }}
path: coverage-shard/*.json
if-no-files-found: error
test-coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 10
needs: test-coverage-shard
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Download all shard coverage
uses: actions/download-artifact@v8
with:
pattern: coverage-shard-*
path: coverage-shards/
merge-multiple: true
- name: Merge + report + gate
# Merging 8 shards of raw v8 coverage is memory-heavy; the 6 GB heap can
# still OOM on large PR runs. Keep this job focused on the gate and
# JSON summary that downstream ratchets consume.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: |
mkdir -p coverage
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
echo "::error::No raw coverage shard data was downloaded."
find . -maxdepth 3 -type f | sort
exit 1
fi
# Gate aligned to the project's local coverage bar: `npm run test:coverage`
# gates at 60/60/60/60, so CI must match it (the previous CI floor of 40
# silently undershot the local bar — a real drift). Real merged coverage is
# ~79/79/82/75, so 60 is a conservative floor with headroom; the Fase-4
# coverage ratchet (quality-baseline.json) layers "must not drop vs baseline"
# on top of this floor.
npx c8 report \
--temp-directory coverage-shards \
--reports-dir coverage \
--reporter=text-summary \
--reporter=json-summary \
--exclude=tests/** \
--exclude=**/*.test.* \
--check-coverage \
--statements 60 --lines 60 --functions 60 --branches 60
- name: Build coverage summary
if: always()
run: |
mkdir -p coverage
if [ -f coverage/coverage-summary.json ]; then
node scripts/check/test-report-summary.mjs \
--input coverage/coverage-summary.json \
--output coverage/coverage-report.md \
--threshold 60
else
printf '%s\n' \
'# Coverage Report' \
'' \
'Coverage summary JSON was not generated. Inspect the Coverage job logs.' \
> coverage/coverage-report.md
fi
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: |
coverage/coverage-summary.json
coverage/coverage-report.md
if-no-files-found: warn
sonarqube:
name: SonarQube
runs-on: ubuntu-latest
needs: test-coverage
if: ${{ always() && needs.test-coverage.result == 'success' }}
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: .
- name: Explain SonarQube skip
if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }}
run: |
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "SonarQube scan skipped on non-PR events to keep main pushes governed by repository CI gates." >> "$GITHUB_STEP_SUMMARY"
else
echo "SonarQube scan skipped because SONAR_TOKEN or SONAR_HOST_URL is not configured." >> "$GITHUB_STEP_SUMMARY"
fi
- name: SonarQube Scan
if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ env.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
coverage-pr-comment:
name: PR Coverage Comment
runs-on: ubuntu-latest
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
needs:
- pr-test-policy
- test-coverage
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Download coverage artifact
if: ${{ needs.test-coverage.result != 'cancelled' }}
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: coverage-report
path: .
- name: Prepare PR coverage comment
env:
COVERAGE_RESULT: ${{ needs.test-coverage.result }}
POLICY_RESULT: ${{ needs.pr-test-policy.result }}
run: |
mkdir -p .artifacts
{
echo "<!-- omniroute-coverage-report -->"
echo "## CI Coverage Report"
echo ""
echo "- Coverage job: \`${COVERAGE_RESULT}\`"
echo "- PR test policy: \`${POLICY_RESULT}\`"
echo ""
if [ -f coverage/coverage-report.md ]; then
cat coverage/coverage-report.md
else
echo "Coverage artifact was not available for this run."
fi
if [ "${POLICY_RESULT}" = "failure" ]; then
echo ""
echo "## PR Test Policy"
echo ""
echo "This PR changes production code in \`src/\`, \`open-sse/\`, \`electron/\`, or \`bin/\` without accompanying automated tests."
fi
} > .artifacts/pr-coverage-comment.md
- uses: actions/github-script@v9
with:
script: |
const fs = require("fs");
const marker = "<!-- omniroute-coverage-report -->";
const body = fs.readFileSync(".artifacts/pr-coverage-comment.md", "utf8");
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((comment) => comment.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
test-e2e:
name: E2E Tests (${{ matrix.shard }}/9)
runs-on: ubuntu-latest
# Build artifact from the `build` job is downloaded instead of rebuilding
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
timeout-minutes: 45
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: playwright-chromium-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: Download Next.js build artifact
uses: actions/download-artifact@v8
with:
name: e2e-next-build
path: /tmp/
- name: Extract Next.js build and restore standalone node_modules
run: |
tar -xzf /tmp/e2e-build.tar.gz
cp -r node_modules .build/next/standalone/node_modules
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
test-integration:
name: Integration Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
INITIAL_PASSWORD: ci-test-password-for-integration
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: node --import tsx --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
test-security:
name: Security Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run test:security
ci-summary:
name: CI Dashboard
runs-on: ubuntu-latest
if: always()
needs:
- lint
- docs-sync-strict
- i18n-ui-coverage
- i18n
- pr-test-policy
- build
- package-artifact
- electron-package-smoke
- test-unit
- node-24-compat
- node-26-compat
- test-coverage
- sonarqube
- coverage-pr-comment
- test-e2e
- test-integration
- test-security
steps:
- name: Download i18n results
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: i18n-*
path: results
merge-multiple: true
- name: Generate dashboard
env:
EVENT_NAME: ${{ github.event_name }}
run: |
status() {
case "$1" in
success) echo "🟢 PASS" ;;
failure) echo "🔴 FAIL" ;;
cancelled) echo "⚫ CANCELLED" ;;
skipped) echo "⚪ SKIPPED" ;;
*) echo "🟡 UNKNOWN" ;;
esac
}
echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
total=0
langs=0
if [ -d results ]; then
for file in results/*.txt; do
[ -f "$file" ] || continue
val=$(sed -r 's/\x1B\[[0-9;]*[mK]//g' "$file" | grep "Untranslated:" | awk '{print $2}')
val=${val:-0}
total=$((total + val))
langs=$((langs + 1))
done
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Languages checked | $langs |" >> "$GITHUB_STEP_SUMMARY"
echo "| Total untranslated | $total |" >> "$GITHUB_STEP_SUMMARY"
if [ "$total" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "⚠️ **Translations need attention**" >> "$GITHUB_STEP_SUMMARY"
else
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "✅ **All translations complete**" >> "$GITHUB_STEP_SUMMARY"
fi