Files
OmniRoute/.github/workflows/ci.yml
dependabot[bot] 94125e09b1 chore(deps): bump actions/setup-python from 6 to 7 (#8456)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 02:52:37 -03:00

1292 lines
60 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }}
testsOnly: ${{ steps.classify.outputs.testsOnly }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
- 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: |
# Single source of truth: scripts/quality/classify-pr-changes.mjs
# (unit-tested). Push/dispatch always enable every lane.
if [ "$EVENT_NAME" != "pull_request" ]; then
{
echo "code=true"
echo "docs=true"
echo "i18n=true"
echo "workflow=true"
} >> "$GITHUB_OUTPUT"
exit 0
fi
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
lint:
name: Lint
runs-on: ubuntu-latest
needs: changes
# 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).
# Path filter: pure docs / pure message-catalog PRs skip the code lint bag + typecheck.
# Existence reason of this job is code regression; docs/i18n have dedicated jobs.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
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@v7
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
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
# a second cold full-tree pass for eslintWarnings ratchet counts.
- name: ESLint (JSON report)
run: npm run lint:json
- name: Upload ESLint results
if: always()
uses: actions/upload-artifact@v7
with:
name: eslint-results
path: .artifacts/eslint-results.json
if-no-files-found: warn
retention-days: 7
- 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
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
- name: hadolint (Dockerfile)
run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile
- 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
# #7033: typecheck:core's curated file allowlist does not cover
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
# true, so `next build` never type-checks it either) — orphaned
# identifiers there (see #6625/#6909) were invisible to CI. This gate
# runs tsc scoped to the dashboard tree against a frozen baseline of
# pre-existing errors; only NEW errors fail it.
- run: npm run check:dashboard-typecheck
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
# it was advisory (continue-on-error) and largely subsumed by the blocking
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
quality-gate:
name: Quality Ratchet
runs-on: ubuntu-latest
# needs lint so eslint-results artifact is available (same inventory as the
# blocking lint step). Allow lint failure so other ratchets still run.
needs: [changes, test-coverage, lint]
# 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.
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
# 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@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
# 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/
# Prefer lint job's ESLint JSON (one inventory, two consumers).
- name: Download ESLint results
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: eslint-results
path: .artifacts/
- 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
# Complexity + cognitive: one ESLint walk, two independent baselines (by ruleId).
- name: Complexity + cognitive ratchets
run: npm run check:complexity-ratchets
# Fase 7 INT: dead-code, type-coverage promovidos de advisory (quality-extended)
# para BLOQUEANTES aqui. cognitive-complexity is folded into the step above.
- name: Dead-code ratchet (knip)
run: npm run check:dead-code
- 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
needs: changes
# 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).
# Path filter: code-only (scanners/ratchets target production surface).
if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }}
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@v7
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
needs: changes
# 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).
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
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
# One FS inventory of src/app/api for both anti-hallucination directions.
- name: API docs refs (openapi + prose → routes)
run: npm run check:api-docs-refs
- 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
needs: changes
# 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).
# Prose/markdown only — skip when the PR has no doc surface.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.docs == 'true') }}
# 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@v7
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
needs: changes
# 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).
# UI keys move with dashboard code OR message catalogs.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
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
# #8038: cheap single-locale glossary/protected-terms consistency gate —
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
i18n-glossary-zhcn:
name: i18n Glossary (zh-CN)
runs-on: ubuntu-latest
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
# 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
needs: changes
# 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).
# Message-catalog / i18n-tooling only — pure code without i18n surface skips this lane.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
continue-on-error: true
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
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@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
- name: Fetch base branch
run: git fetch --no-tags origin "${GITHUB_BASE_REF}"
- 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@v7
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@v7
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
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
- name: Boot-smoke the packed tarball
run: npm run check:pack-boot
electron-package-smoke:
name: Electron Package Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
needs: build
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
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@v7
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
if: runner.os == 'Linux'
working-directory: electron
run: npm run pack
# ADVISORY while the new Windows leg matures (repo convention, dast-smoke
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
# continue-on-error keeps the heavy gate green while we harden it (#7336).
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
if: runner.os == 'Windows'
working-directory: electron
continue-on-error: true
shell: bash
run: npm run prepare:bundle 2>&1
- name: Smoke packaged Electron app
if: runner.os == 'Linux'
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@v7
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-bun-sqlite:
name: Bun SQLite Compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
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@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run test:bun:db
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@v7
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).
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration,
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
# 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
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
timeout-minutes: 20
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
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@v7
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 \
--reporter=lcov \
--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"
# WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov —
# informational during calibration (codecov.yml sets informational: true);
# promote to blocking only after ~2 weeks without false blocks. The lcov
# reporter above also fixes coverage/lcov.info being silently absent
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- 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
# The upload strips the common `coverage/` prefix, so the artifact root holds
# lcov.info directly — download into coverage/ so it lands at coverage/lcov.info,
# where sonar.javascript.lcov.reportPaths expects it (path: . left the Sonar
# new-code coverage at 0% every scan).
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: coverage/
- 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, changes]
# WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min).
# It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md:
# production-broken only, full-suite evidence from the previous green run linked in the
# PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app).
if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
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@v7
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
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
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
# critical path. The balancer self-verifies completeness and exits non-zero on
# any inconsistency, falling back to plain --shard (never fewer specs).
- name: Run E2E tests (duration-balanced shard)
env:
SHARD: ${{ matrix.shard }}
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
run: |
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
else
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
fi
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: junit-e2e-results.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
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@v7
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@v7
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-glossary-zhcn
- 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 "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.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