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 must never mutate the runner's OS trust store (2026-07-05: a cert-flow # test installed a fake PEM on a persistent self-hosted runner and broke all # system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts. OMNIROUTE_SKIP_SYSTEM_TRUST: "1" CI_NODE_VERSION: "24" CI_NODE_24_VERSION: "24" CI_NODE_26_VERSION: "26" jobs: changes: name: Change Classification runs-on: ubuntu-latest outputs: code: ${{ steps.classify.outputs.code }} docs: ${{ steps.classify.outputs.docs }} i18n: ${{ steps.classify.outputs.i18n }} workflow: ${{ steps.classify.outputs.workflow }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: persist-credentials: false fetch-depth: 0 - id: classify env: EVENT_NAME: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | if [ "$EVENT_NAME" != "pull_request" ]; then { echo "code=true" echo "docs=true" echo "i18n=true" echo "workflow=true" } >> "$GITHUB_OUTPUT" exit 0 fi code=false docs=false i18n=false workflow=false git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt while IFS= read -r file; do case "$file" in .github/workflows/*|.zizmor.yml) workflow=true code=true ;; docs/*|*.md) docs=true ;; src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json) i18n=true code=true ;; src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*) code=true ;; db/*|config/*) code=true ;; *) code=true ;; esac done < changed-files.txt { echo "code=$code" echo "docs=$docs" echo "i18n=$i18n" echo "workflow=$workflow" } >> "$GITHUB_OUTPUT" lint: name: Lint runs-on: ubuntu-latest # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - 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 # check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the # husky pre-commit hook; the standalone copy here was redundant (ROI dedup). - 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 # Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage # Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets # (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need # the coverage artifact and MUST still run so cycle drift is measured on the # release PR — otherwise a flake silently skips the whole gate (incident: v3.8.36 # release PR #4854, where the drift cascade only surfaced post-merge in #5029). # The coverage.* metrics degrade gracefully: the download is continue-on-error and # the ratchet runs with --allow-missing, so absent coverage is skipped, not failed. if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} # 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry # Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura. # continue-on-error: o artifact pode não existir se a job test-coverage foi # SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem # erro) e o ratchet roda com --allow-missing — as métricas determinísticas # (eslint/complexity/cognitive) seguem BLOQUEANTES. - uses: actions/download-artifact@v8 continue-on-error: true 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. # --allow-missing: pula métricas do baseline ausentes do collect (coverage.* quando # o artifact não veio) em vez de falhar — mantém os gates determinísticos ativos. - name: Ratchet check run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md # Fase 6A.5: require-tighten — BLOQUEANTE (promovido de advisory no fim do ciclo # v3.8.27). Falha 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. Verificado limpo (exit 0) no tip de release/v3.8.27 com a cobertura # mergeada == baseline (ver a nota _require_tighten_flip_blocking em # config/quality/quality-baseline.json). - name: Require-tighten (blocking) run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --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 - name: Compression budget ratchet (F2.4 / N4) run: npm run check:compression-budget # 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 # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} steps: # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base # spec via `git show :docs/openapi.yaml`; a shallow clone # would lack the base ref and the gate would self-skip (base-unresolved). - uses: actions/checkout@v7 with: persist-credentials: false fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry # 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 # BLOCKING ratchet (v3.8.27 cycle-end): vulnCount must not regress vs the # baseline. --ratchet exits 1 on a measured regression (measured > baseline); # it SKIPs (exit 0) when osv-scanner is absent or osv.dev is unreachable (a # missing/failed measurement never blocks). See the CVE-variance note in # docs/security/SUPPLY_CHAIN.md — a newly-disclosed CVE on an unchanged dep can # red this gate; the fix is to bump the dep or re-baseline metrics.vulnCount. - name: Vulnerability ratchet (osv-scanner, ratchet, blocking) run: npm run check:vuln-ratchet -- --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/openapi.yaml) against the base branch's spec. # BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and # exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0) # when oasdiff is absent or the base spec can't be resolved — a missing # measurement never blocks. BASE_REF is read by the script from the env # (never interpolated into a shell body) — workflow-injection-safe. - name: OpenAPI breaking-change (oasdiff, ratchet, blocking) env: BASE_REF: ${{ github.base_ref }} run: npm run check:openapi-breaking -- --ratchet docs-sync-strict: name: Docs Sync (Strict) runs-on: ubuntu-latest # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - 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 # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} # 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@v7 with: persist-credentials: false - 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 # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65 # D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha # a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos) # e pagava spin-up + arredondamento de billing por idioma. Um único job itera os idiomas # (mesmo script), com grupo de log por idioma e artifact único de resultados nomeados por # idioma (a matrix antiga subia 40 artifacts cujo result.txt colidia no merge-multiple). i18n: name: i18n Validation (all languages) runs-on: ubuntu-latest # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} continue-on-error: true steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Validate all languages run: | set -uo pipefail mkdir -p i18n-results FAIL=0 for f in src/i18n/messages/*.json; do lang=$(basename "$f" .json) [ "$lang" = "en" ] && continue echo "::group::i18n $lang" if python3 scripts/i18n/validate_translation.py quick -l "$lang" > "i18n-results/$lang.txt" 2>&1; then echo "OK $lang" else FAIL=1 echo "FAIL $lang" cat "i18n-results/$lang.txt" fi echo "::endgroup::" done exit "$FAIL" - name: Upload results if: always() uses: actions/upload-artifact@v7 with: name: i18n-results path: i18n-results/ pr-test-policy: name: PR Test Policy if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false 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 # Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to # 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is # online), the heavy jobs run on the dedicated 32-core VPS runners (label # omni-release) instead of queueing on the 20-concurrent-job hosted pool. # Safety: fork PRs NEVER reach the self-hosted runner — the expression falls # back to ubuntu-latest unless the PR head repo is this repository (push / # dispatch events are own-origin by definition). Any failure path (VM down, # var unset/false) also falls back to ubuntu-latest. runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime # NOTE: the webpack `.build/next/cache` actions/cache step was removed with the # Turbopack switch below — Turbopack does not read/write the webpack cache dir # (its persistent FS cache is still experimental and intentionally NOT enabled), # so restoring the old ~0.5 GB webpack cache would only waste download time. # Rolling back to webpack = revert this commit (cache step comes back with it). # # Turbopack production build (Next 16, stable): benchmarked 1.9× faster than the # webpack pass (9min0s vs 17min15s on a 32-core box; multi-core Rust vs webpack's # single-threaded compile). Standalone output smoke-validated (server boots, # /api/monitoring/health 200). Downstream jobs (e2e ×9, package-artifact, # electron-package-smoke) consume this artifact, so a green run here validates # the Turbopack artifact end-to-end. - run: npm run build env: OMNIROUTE_USE_TURBOPACK: "1" - name: Archive Next.js build for downstream jobs # 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/...). # Keep standalone/node_modules intact: package/electron jobs consume the # Next-traced standalone tree and must not replace it with root node_modules. run: | tar -czf /tmp/e2e-build.tar.gz \ --exclude='.build/next/cache' \ .build/next - name: Upload Next.js build for downstream jobs uses: actions/upload-artifact@v7 with: name: 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Download Next.js build artifact uses: actions/download-artifact@v8 with: name: next-build path: /tmp/ - name: Extract Next.js build artifact run: | tar -xzf /tmp/e2e-build.tar.gz # build:cli consumes the downloaded .build/next standalone artifact and assembles dist/; # it only rebuilds if the downloaded standalone artifact is missing. - 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Download Next.js build artifact uses: actions/download-artifact@v8 with: name: next-build path: /tmp/ - name: Extract Next.js build artifact run: | tar -xzf /tmp/e2e-build.tar.gz - 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) # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} timeout-minutes: 25 # needs: changes (not build) — this job never downloads the next-build artifact; # gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that # DO consume the artifact (e2e, package-artifact, electron-package-smoke) keep # needs: build. The `if` mirrors Build's own skip condition so docs-only PRs # still skip the suite. needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime # QW-d (plano mestre): fonte única — o MESMO npm script dos runs locais (adiciona o # setupPolyfill que o comando inline omitia; o glob canônico vive só no package.json). # D3 (plano mestre): a coverage é coletada NESTE mesmo run (c8/NODE_V8_COVERAGE propaga # aos filhos através do npm) — elimina a matrix Coverage Shard ×8, que re-executava a # suíte inteira só para medir o gate. Padrão usado pelo CI do próprio nodejs/node. - name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage env: TEST_SHARD: ${{ matrix.shard }}/8 run: | rm -rf coverage-shard coverage-shard-report npx c8 \ --temp-directory=coverage-shard \ --reports-dir=coverage-shard-report \ --reporter=json \ --exclude=tests/** \ --exclude=**/*.test.* \ npm run test:unit:ci:shard - 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-vitest: name: Vitest (MCP / autoCombo / UI components) # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} timeout-minutes: 15 # needs: changes (not build) — no artifact consumed; see test-unit note. needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry # 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/26 compatibility matrices moved to .github/workflows/nightly-compat.yml # (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a # failure class that rarely originates in a PR; nightly catches it within 24h and # the release gate can still exercise them via workflow_dispatch when needed). test-coverage: name: Coverage runs-on: ubuntu-latest timeout-minutes: 10 needs: test-unit if: ${{ !cancelled() && needs.test-unit.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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - 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. `--merge-async` # keeps the V8 coverage merge incremental instead of loading every raw # JSON blob into one in-memory merge, which avoids Node heap OOMs. env: NODE_OPTIONS: --max-old-space-size=8192 run: | mkdir -p coverage first_coverage_file="" if [ -d coverage-shards ]; then first_coverage_file="$(find coverage-shards -maxdepth 1 -type f -name '*.json' -print -quit)" fi if [ -z "$first_coverage_file" ]; 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 \ --merge-async \ --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 coverage/lcov.info if-no-files-found: warn sonarqube: name: SonarQube runs-on: ubuntu-latest needs: test-coverage if: ${{ !cancelled() && needs.test-coverage.result == 'success' }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} steps: - uses: actions/checkout@v7 with: persist-credentials: false 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: Read project version if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }} id: pkg run: | VERSION="$(node -p "require('./package.json').version")" # Harden against output injection: only accept a plain semver-ish string. if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "Invalid package.json version: $VERSION" >&2; exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - 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 }} with: # sonar.projectVersion advances the "previous_version" new-code baseline at # each release; without it the baseline never moves (it stays pinned to the # analysis where the version last changed) and legacy issues pile up as # "new code" in the quality gate. args: -Dsonar.projectVersion=${{ steps.pkg.outputs.version }} coverage-pr-comment: name: PR Coverage Comment runs-on: ubuntu-latest if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }} needs: - changes - 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 "" 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 = ""; 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Cache Playwright browsers uses: actions/cache@v6.1.0 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: next-build path: /tmp/ - name: Extract Next.js build artifact run: | tar -xzf /tmp/e2e-build.tar.gz - 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: changes (not build) — no artifact consumed; see test-unit note. needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime # (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up) - run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --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: changes (not build) — no artifact consumed; see test-unit note. needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} 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@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - run: npm run test:security ci-summary: name: CI Dashboard runs-on: ubuntu-latest if: ${{ !cancelled() }} needs: - changes - lint - docs-sync-strict - i18n-ui-coverage - i18n - pr-test-policy - build - package-artifact - electron-package-smoke - test-unit - 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 "| Change Classification | $(status '${{ needs.changes.result }}') |" >> "$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 "| 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