1137 lines
50 KiB
YAML
1137 lines
50 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 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 <base_ref>: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 "<!-- 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@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
|