mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Runner-cost pass grounded in the #8084 review of the current pipeline: - dast-smoke.yml: add concurrency cancel-in-progress (25-min advisory builds were stacking on force-push storms) and paths-ignore for docs/**+**/*.md — a docs-only PR cannot change DAST behavior but was paying the 6-11min CLI-bundle build. - semgrep.yml: add concurrency cancel-in-progress. No paths filter on purpose: p/secrets must keep scanning docs-only diffs (credentials leak in .md too). - quality.yml (TIA step): persist the per-PR impacted-test selection to a tia-selection artifact + GITHUB_STEP_SUMMARY line. This is the shadow-evidence phase: TIA false negatives become measurable against fast-unit's full-suite verdict across releases BEFORE any gate authority moves off ordinary PRs. Refs #8084
374 lines
19 KiB
YAML
374 lines
19 KiB
YAML
name: Quality Gates
|
|
|
|
on:
|
|
pull_request:
|
|
branches: ["release/**"]
|
|
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"
|
|
|
|
jobs:
|
|
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
|
|
# path filters share existence reasons: code / docs / i18n / workflow.
|
|
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@v7
|
|
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: |
|
|
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"
|
|
|
|
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
|
|
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
|
|
docs-gates:
|
|
name: Docs Gates (fast-path)
|
|
needs: changes
|
|
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
|
|
- run: npm run check:api-docs-refs
|
|
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
|
run: npm run check:docs-all
|
|
|
|
fast-gates:
|
|
name: Fast Quality Gates
|
|
needs: changes
|
|
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
|
|
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
|
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
|
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
|
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
|
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
|
|
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' }}
|
|
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
|
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
|
env:
|
|
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:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- 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 }}-
|
|
- run: npm run check:provider-consistency
|
|
- run: npm run check:fetch-targets
|
|
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
|
- 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:test-runner-api
|
|
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
|
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
|
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
|
- run: npm run check:mutation-test-coverage
|
|
- run: npm run check:any-budget:t11
|
|
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
|
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
|
- run: npm run check:build-scope
|
|
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
|
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
|
# the release PR's heavy Package Artifact job.
|
|
- run: npm run check:pack-policy
|
|
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
|
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
|
- run: npm run check:complexity-ratchets
|
|
- name: Typecheck (core)
|
|
run: npm run typecheck:core
|
|
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
|
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
|
- name: Typecheck (dashboard)
|
|
run: npm run check:dashboard-typecheck
|
|
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
|
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
|
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
|
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
|
|
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
|
|
# Promote to the blocking gate after ~1 week of parity with the step above.
|
|
- name: Typecheck (core) — TS7 native shadow (advisory)
|
|
continue-on-error: true
|
|
run: |
|
|
RC=0
|
|
START=$(date +%s)
|
|
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
|
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
|
exit $RC
|
|
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
|
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
|
|
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
|
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
|
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
|
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
|
#
|
|
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
|
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
|
- name: Impacted unit tests (TIA subset; blocking)
|
|
env:
|
|
GITHUB_BASE_REF: ${{ github.base_ref }}
|
|
run: |
|
|
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
|
node scripts/quality/build-test-impact-map.mjs
|
|
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
|
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
|
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
|
# any gate authority moves off ordinary PRs. Artifact uploaded below.
|
|
printf '%s\n' "$SEL" > tia-selection.txt
|
|
if [ -z "$SEL" ]; then
|
|
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "No source/test changes — skipping unit tests"; exit 0
|
|
fi
|
|
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
|
|
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
|
|
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
|
|
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
|
# run race-free regardless of concurrency.
|
|
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
|
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
|
|
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
|
|
exit 0
|
|
fi
|
|
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "Running impacted tests:"; echo "$SEL"
|
|
mapfile -t FILES <<< "$SEL"
|
|
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
|
|
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
|
|
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
|
|
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
|
# module-shape test the impact map selects ("Unexpected token 'export'").
|
|
DASH=(); REST=()
|
|
for f in "${FILES[@]}"; do
|
|
case "$f" in
|
|
tests/unit/dashboard/*) DASH+=("$f") ;;
|
|
*) REST+=("$f") ;;
|
|
esac
|
|
done
|
|
RC=0
|
|
if [ ${#REST[@]} -gt 0 ]; then
|
|
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
|
|
fi
|
|
if [ ${#DASH[@]} -gt 0 ]; then
|
|
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
|
|
fi
|
|
exit $RC
|
|
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
|
|
# audited against fast-unit failures on the same run (gate moves need this data).
|
|
- name: Upload TIA selection (shadow evidence)
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: tia-selection
|
|
path: tia-selection.txt
|
|
if-no-files-found: ignore
|
|
retention-days: 30
|
|
|
|
fast-vitest:
|
|
name: Vitest (fast-path)
|
|
needs: changes
|
|
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
|
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → 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' }}
|
|
env:
|
|
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
|
|
- run: npm ci
|
|
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
|
# which is where flaky-detection volume actually comes from (ci.yml's heavy
|
|
# jobs only run on the release PR). Advisory upload, own-origin only.
|
|
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
|
- 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 }}
|
|
|
|
fast-unit:
|
|
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
|
needs: changes
|
|
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
|
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
|
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
|
|
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
|
|
# runner box). Node's native --test-shard=N/total takes any denominator — only
|
|
# this matrix and the TEST_SHARD env below encode the shard count.
|
|
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' }}
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
env:
|
|
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
|
|
- run: npm ci
|
|
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
|
|
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
|
|
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
|
|
- run: npm run test:unit:ci:shard
|
|
env:
|
|
TEST_SHARD: ${{ matrix.shard }}/4
|
|
|
|
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
|
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
|
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
|
|
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
|
|
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
|
|
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
|
|
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
|
|
#
|
|
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
|
|
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
|
|
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
|
|
# contribuidor NUNCA é bloqueado nem cobrado).
|
|
lint-guard:
|
|
name: No new ESLint warnings
|
|
needs: changes
|
|
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- 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 }}-
|
|
- name: ESLint (baseline congelado — warning novo = vermelho)
|
|
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
|
run: npm run lint:json -- --max-warnings 0
|
|
|
|
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
|
|
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
|
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
|
|
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
|
|
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
|
|
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
|
|
# integration invisíveis até a release).
|
|
#
|
|
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
|
|
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
|
|
# nunca é bloqueado.
|
|
merge-integrity:
|
|
name: Merge integrity (changelog + generated skills)
|
|
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
|
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
|
env:
|
|
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
|
|
run: npm run check:changelog-integrity
|
|
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)
|
|
run: npm run check:agent-skills-sync
|