mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
* chore(release): open v3.8.28 development cycle * fix(ws): warm SSE auth import on LiveWS startup; relocate boot test to integration (#4063) The live dashboard WebSocket sidecar lazily import()-ed the SSE auth module inside the connection handler, only on the API-key path. That cold import pulls in hundreds of transitive modules and takes ~7s under tsx, blocking the single-threaded event loop. The first API-key WebSocket connection therefore stalled the loop long enough that any connection arriving in that window — e.g. a same-origin cookie client — could not complete its handshake and timed out. This was deterministic, not an "env flake": the boot test fires an API-key connection immediately followed by a cookie connection, so the cookie connection always raced the cold import and timed out (reproduced 3/3 locally and red on every CI run; proven via instrumented probes — reversing the order or warming the module first makes both connections open in ~20ms). Fix: - Memoize the auth-module import and warm it once at startup (before listen), so connection handling never pays the cold-import cost. Real improvement: the first API-key client no longer stalls the event loop for concurrent clients. - Relocate the boot test from tests/unit/cli to tests/integration. It spawns a real subprocess + WS server + SQLite (~9-11s); under the unit suite's --test-concurrency=20 it contended for CPU and destabilized the shard. The serial integration runner is its correct home; it still guards #4004's cookie-parse fix on every PR via the integration CI job. - Bump the test's startup/overall timeouts to absorb the eager auth warm. Makes `npm run test:unit` deterministically green (the only remaining unit red). Validated: relocated test 3/3 green via the integration runner (was 3/3 red); typecheck:core + eslint clean; confirmed it no longer matches the test:unit glob and does match tests/integration/*.test.ts. * fix(ws): start LiveWS sidecar with cwd at package root (#4055) (#4064) * chore(deps): bump ossf/scorecard-action from 2.4.0 to 2.4.3 (#4045) Integrado em release/v3.8.28. Patch de SHA do ossf/scorecard-action (2.4.0→2.4.3), mantém SHA-pin. Reds de CI são exclusivamente os shards flaky pré-existentes branch-wide (Unit 7/8, Integration, Coverage 7/8, Node 1/2) — não relacionados ao bump (PR deps-only). * deps: bump electron from 42.4.0 to 42.4.1 in /electron (#4049) Integrado em release/v3.8.28. Patch do electron (42.4.0→42.4.1). Reds de CI: shards flaky pré-existentes + PR Test Policy = falso-positivo (mudança deps-only sob electron/ não comporta teste de código) + Node 26(2/2) sem step (flake/infra). Precedente #3913/#3914 (electron dependabot mergeado nessas condições). * fix(auto): resolve built-in auto catalog combos (#4058) Integrado em release/v3.8.28. Resolve os IDs de catálogo `auto/*` built-in (combos virtuais) — corrige o 400 "No auto combos configured" em auto/best-coding etc. Ajuste de review: os mapas AUTO_TEMPLATE_VARIANTS/VALID_AUTO_VARIANTS duplicados em chat.ts e chatHelpers.ts foram extraídos para open-sse/services/autoCombo/builtinCatalog.ts (DRY), devolvendo chatHelpers.ts <800 LOC; baseline de chat.ts rebaselinado 1432→1458 (lógica nova). Fast QG + semgrep + dast verdes; 22/22 testes. * chore(docs): update Discord invite link to a non-expiring one (#4067) * chore(deps): freeze @huggingface/transformers in dependabot (hard-pin) (#4066) Integrado em release/v3.8.28. Congela @huggingface/transformers no dependabot (pin exato 3.5.2, load-bearing p/ LLMLingua + memory embeddings, VPS-validado #4014). Fast QG + semgrep + dast verdes. * ci(quality): flip TIA impacted-unit-tests gate from advisory to blocking (#4069) The pre-existing release unit test-debt that kept the TIA "Impacted unit tests" step advisory has been cleared: - #4030 restored 16 lossless Zod/registry reds (from the oyi77 modularize refactors). - #4063 fixed the last red — the LiveWS boot test — which was a real deterministic event-loop stall in the WS sidecar (cold ~7s lazy auth import racing a second connection), not an env flake; fixed (warm the import at startup) and relocated to the integration suite. A full workflow_dispatch ci.yml run on release/v3.8.28 then showed all 8 Unit Tests shards green. The remaining Integration Tests / Quality Ratchet reds are pre-existing and unrelated (combo/resilience env-flakes; eslint/i18n baseline drift). Removing continue-on-error makes PR->release block on unit-test regressions in the TIA-selected impacted set (fail-safe still runs the full unit suite on hub/unmapped changes). typecheck:core was already blocking. Closes the fast-gates "no tests on PR->release" hole (Quality Gate v2 / Fase 9, P2). * docs(compression): document LLMLingua optional deps + on-demand install (#4061) Integrado em release/v3.8.28. Docs LLMLingua optional deps + on-demand install (F3.1). * feat(dashboard): Combo Studio connection-cooldown badge (U1b Slice 2) (#4068) Integrado em release/v3.8.28. Combo Studio connection-cooldown badge (U1b Slice 2 / F5.1). * feat(compression): record Context Editing telemetry (engine: context-editing) (#4062) Integrado em release/v3.8.28. Context Editing telemetry (F4.1). * feat(sse): Context Editing relay coverage + 400-fallback (#4065) Integrado em release/v3.8.28. Context Editing relay coverage (cc-*) + 400-fallback (F4.2/F4.3). Conflito de file-size-baseline.json (vs #4062) resolvido por união (ambas justificativas + base.ts 1292 + chatCore.ts 5898). Validado local no tree mergeado: typecheck:core ✓, eslint ✓, check:file-size ✓, 4/4 testes ✓; semgrep + semgrep-cloud verdes. Fast QG enfileirado (saturação de runner) — mergeado nos gates de política verificados (precedente #4034/#4020). * feat(providers): add OrcaRouter (OpenAI-compatible routing gateway) (#4070) Integrado em release/v3.8.28. Adiciona o provider OrcaRouter (OpenAI-compatible, API-key, DefaultExecutor). Ajuste de review: rebaseline de file-size de providers.ts 3147→3159 (+12 da entrada OrcaRouter). Validado local no tree sincronizado: provider-consistency ✓, docs-counts STRICT 227 ✓, typecheck:core ✓, teste 3/3 ✓, eslint ✓; semgrep + semgrep-cloud verdes. Fast QG/dast enfileirados (saturação de runner) — merge nos gates de política verificados (precedente #4034/#4065). * test(infra): isolate DATA_DIR per test process; raise Stryker concurrency 1→4 (#4078) * test(infra): isolate DATA_DIR per test process; raise Stryker concurrency 1→4 Every test process resolved DATA_DIR to the same default (~/.omniroute) when the env var was unset (src/lib/dataPaths.ts::resolveDataDir), so concurrent test files opened the SAME on-disk storage.sqlite. node:test spawns a process per file and Stryker spawns one per sandbox, so this shared file caused cross-file state races: - SQLite lock contention that hung `npm run test:unit` under high --test-concurrency (the ~95-min local hang), and - the non-deterministic baseline that forced stryker.conf.json to concurrency: 1, which in turn could not finish the ~15k-mutant run inside the nightly timeout (the cancelled 2026-06-16/17 nightly-mutation runs) — blocking Quality Gate v2 / Fase 9 Onda 2. open-sse/utils/setupPolyfill.ts could NOT host the fix: it is imported by production (bin/omniroute.mjs, proxyFetch.ts, proxyDispatcher.ts), where redirecting DATA_DIR would point the live SQLite DB at a throwaway temp dir. So this adds a TEST-ONLY tests/_setup/isolateDataDir.ts that gives each process its own temp DATA_DIR when none is set (tests that set DATA_DIR explicitly still win), wired via --import into the test, mutation and CI invocations. Verified: - Stryker dry-run A/B at concurrency=4: FAILS without the isolation import (account-fallback-service tap exit 9, a cross-file race) and PASSES with it. - Full `npm run test:unit` green with isolation (0 fail; a one-off chatcore-translation-paths timeout flake did not reproduce and passes 3/3 isolated) and noticeably faster — the DB lock contention is gone. - New tests/unit/isolate-datadir.test.ts guards the contract (unique temp DATA_DIR when unset; explicit DATA_DIR respected). Wired the --import into: package.json (13 test scripts), stryker.conf.json (tap.nodeArgs + concurrency 1→4), .github/workflows/quality.yml (TIA step), ci.yml (the 5 unit/coverage/integration commands), and bumped nightly-mutation.yml timeout 120→180 for the first cold run before the incremental cache is seeded. * ci(quality): run the TIA gate at CI concurrency (4) to stop oversubscription flakes The TIA "Impacted unit tests" step (made blocking in #4069) ran its fail-safe via `npm run test:unit` — concurrency=20, tuned for multi-core dev machines. On a 4-vCPU CI runner that is 5x oversubscribed, so timing-sensitive tests flake under the load (e.g. `db-backup-extended` "The database connection is not open", `chatcore-translation-paths` upstream-timeout). That intermittently fails a blocking gate on legitimate PRs — exactly what surfaced on the DATA_DIR-isolation PR, whose package.json/workflow changes trip the __RUN_ALL__ fail-safe. Run both the impacted set and the fail-safe at --test-concurrency=4, matching the stable ci.yml unit job. Adds a `test:unit:ci` script (test:unit at concurrency=4). The DATA_DIR isolation in this PR keeps the parallel run race-free, so the only change here is matching the runner's core count. Verified locally: db-backup-extended passes 8/8 in isolation (5 with isolation, 3 without). * docs(quality-gates): reconcile gate inventory with ci.yml + add ROI rationalization backlog (#4095) The "authoritative" gate inventory in QUALITY_GATES.md had drifted from ci.yml: it omitted 9 wired gates — `audit:deps`, `check:tracked-artifacts`, `check:lockfile`, `check:licenses` (lint job), `check:dead-code`, `check:cognitive-complexity`, `check:type-coverage`, `check:codeql-ratchet` (quality-gate job), and `check:pr-evidence` (pr-test-policy job). You can't rationalize an inventory you can't trust, so this reconciles it first. Adds those 9 rows to their job tables and a "Rationalization Backlog (ROI review)" section capturing the Fase 9 Onda 3 findings: mechanical merge/dedup candidates (CVE scanners audit:deps↔osv, the two complexity ESLint passes, cycles↔circular-deps, the two /api anti-hallucination gates, the doubly-run check:docs-sync, check:node-runtime ×11) and the operator-only flip/drop decisions (typecheck:noimplicit vs the type-coverage ratchet, test:vitest:ui parked fails, check:secrets frozen FPs, openapi-security-tiers, pr-evidence, the orphaned semgrep baseline). Also flags the undocumented advisory docs-lint job and the standalone scanner workflows. Docs-only — no gate behavior changes. The merges (CI changes) and flips (policy) are deferred to operator-scoped follow-ups; this PR only makes the map accurate. * test(dashboard): smoke e2e for the Combo Live Studio page (#4075) Integrated into release/v3.8.28 * fix(sse): friendly 413 message for ChatGPT web payload-too-large (#4080) Integrated into release/v3.8.28 * feat(sse): port Claude Code quota-probe bypass + command meta-request helpers (#4083) Integrated into release/v3.8.28 * feat(api): exact offline token counting for count_tokens fallback via tiktoken (#4087) Integrated into release/v3.8.28 * feat(compression): RTK learn/discover (sample source + API + UI) (#4088) Integrated into release/v3.8.28 * feat(dashboard): 2026-06-17 free-tier refresh — honest catalog, uncapped + boost tiers, Layout A budget table (#4089) Integrated into release/v3.8.28 * feat(mitm): capture-pipeline self-test route (Gap 12) (#4093) Integrated into release/v3.8.28 * fix(mitm): crash-safe system-state teardown + socket timeouts (ProxyBridge-inspired hardening) (#4084) Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green) * feat(mitm): attribute intercepted requests to originating process (Gap 1) (#4085) Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green) * fix(sse): route image requests only to confirmed-vision combo targets (#4071) Integrated into release/v3.8.28 * fix(security): injection guard respects INJECTION_GUARD_MODE DB feature flag (#4077) Integrated into release/v3.8.28 * fix(ws): proxy LAN /live-ws upgrades and add unset JWT_SECRET warning (#4079) Integrated into release/v3.8.28 * fix(dev): force webpack in custom dev server (Turbopack 16.2.x panics) (#4092) Integrated into release/v3.8.28 * ci(quality): dedup the doubly-run check:docs-sync + record validated ROI backlog (#4099) Onda 3 (gate ROI-review) Phase 2. Two parts, both low-risk: 1. Remove the standalone `check:docs-sync` from the `lint` job — it already runs in the `docs-sync-strict` job (via `check:docs-all`) and the husky pre-commit hook, so the `lint`-job copy was a pure duplicate. No coverage lost. 2. Update the Rationalization Backlog in QUALITY_GATES.md with trust-but-verify findings: several "obvious" merges/flips from the ROI review turned out to hide debt and are NOT clean drop-ins — - CVE merge (audit:deps→osv): different semantics (hard high/critical vs regression-ratchet) — keep both. - cycles→circular-deps: dpdm reports 91 cycles (can't promote to blocking) and is broader-scope than the green curated check:cycles — keep both. - openapi-security-tiers flip: blocked by traffic-inspector routes missing the x-loopback-only annotation. - complexity + /api merges: valid but real config/script surgery — deferred. - node-runtime ×11: ~10s savings vs a cheap guard — low ROI, skip. The remaining flips (typecheck:noimplicit, test:vitest:ui, check:secrets, pr-evidence, semgrep) are operator policy decisions, left for the owner. * chore(deps): bump actions/github-script from 7 to 9 (#4046) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/setup-node from 4 to 6 (#4048) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/upload-artifact from 4 to 7 (#4044) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/cache from 4.3.0 to 5.0.5 (#4047) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * deps: bump the development group with 10 updates (#4051) Integrated into release/v3.8.28 (dependabot dev group; cyclonedx 4->5 verified compatible with the SBOM invocation --ignore-npm-errors/--output-format JSON/--output-file) * fix(dashboard): event-driven fail-open auto-refresh for embedded log views (#4054) (#4103) The Request Logger gated each auto-refresh tick on a static document.visibilityState === "visible" read. Hosts that report a permanent non-"visible" state without ever firing a visibilitychange event (Docker dashboard wrappers, embedded/proxied webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: visibleRef starts true and is only flipped to false on a real visibilitychange → hidden transition, so a host that never signals a genuine background transition keeps polling, while normal browser tabs still pause when actually backgrounded. Regression test reproduces the misreporting-host case (RED) and the perf guard is re-encoded under the event-driven semantics. * fix(docker): raise build-stage Node heap to stop production-build OOM (#4076) (#4104) The Docker builder stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with "FATAL ERROR: ... JavaScript heap out of memory" at [builder] npm run build. The builder stage now sets NODE_OPTIONS=--max-old-space-size (default 4096 MB, overridable via --build-arg OMNIROUTE_BUILD_MEMORY_MB) before the build; the value propagates to the spawned next build (resolveNextBuildEnv spreads process.env). Build-only — the runtime heap on the runner stage is unchanged, and CI/local builds (which invoke npm run build directly) are unaffected. Regression guard: tests/unit/dockerfile-build-heap-4076.test.ts asserts the builder stage sets the heap ceiling, before npm run build, at >= 4096 MB. * feat(agent-bridge): portable JSON import/export of config (Gap 4) (#4094) Integrated into release/v3.8.28 * feat(cli): add 'omniroute launch' zero-config Claude Code launcher (#4097) Integrated into release/v3.8.28 (Fast QG TIA red = pre-existing env-doc-contract drift [MITM_IDLE_TIMEOUT_MS/TURBOPACK from #4084/#4092] + opencode-plugin-dist env flake; #4097 own test 3/3 green) * feat(mitm): loop-guard self-check + verbosity control in server.cjs (Gaps 14+15) (#4101) Integrated into release/v3.8.28 (rebased onto release — dropped the already-squash-merged #4084 commits; only the Gaps 14+15 loop-guard/verbosity delta remains) * feat(sse): generic 400 field-downgrade retry + Groq field stripping (#4096) Integrated into release/v3.8.28 * feat(providers): add Wafer AI (Anthropic-compatible, Bearer auth) (#4098) Integrated into release/v3.8.28 * chore(docs) * fix(responses): clear /v1/responses keepalive timer on cancel/abort (timer + CPU leak) (#4105) Integrated into release/v3.8.28 (r7). * perf(gemini): cache reasoning close-tag regex instead of recompiling per token (#4106) Integrated into release/v3.8.28 (r7). * fix(usage): reap orphaned pending-request details (unbounded memory leak) (#4107) Integrated into release/v3.8.28 (r7). * perf(stream): use structuredClone instead of JSON round-trip for per-chunk reasoning split (#4108) Integrated into release/v3.8.28 (r7). * fix(dashboard): restore Update Available banner with npm-binary-free version fallback (#4100) (#4112) getLatestNpmVersion() derived the latest version only from the npm CLI binary and returned null on any error, so Docker/desktop/locked-down installs without npm on PATH silently hid the home banner even when an update existed. Add resolveLatestVersion() (npm CLI -> registry HTTP fallback -> logged warning) and harden version parsing for v-prefix/pre-release strings. Extracted into testable src/lib/system/versionCheck.ts with TDD coverage. * fix(auth): prune expired entries from login brute-force guard map (unbounded growth) (#4111) Integrated into release/v3.8.28 (r8) * fix(logger): hard-cap the error-dedup map to bound memory under unique-message bursts (#4113) Integrated into release/v3.8.28 (r8) * fix(circuit-breaker): enforce MAX_REGISTRY_SIZE (declared but never applied) (#4114) Integrated into release/v3.8.28 (r8) * perf(obfuscation): cache per-word regexes instead of recompiling every request (#4109) Integrated into release/v3.8.28 (r8) * perf(registry): precompute model->provider index in parseModelFromRegistry (#4110) Integrated into release/v3.8.28 (r8) * fix(timers): unref background interval timers so they don't block clean shutdown (#4117) Integrated into release/v3.8.28 (r8) * fix(webhook): clear abort timer in finally to avoid dangling timers on fetch error (#4115) Integrated into release/v3.8.28 (r8) * fix(combo): detach per-target listener from shared hedge abort signal (#4116) Integrated into release/v3.8.28 (r8) * chore(release): finalize v3.8.28 CHANGELOG + reconcile env-doc contract - Build the complete [3.8.28] CHANGELOG section (55 bullets) covering every commit since v3.8.27, grouped by type with PR back-references and human contributor attribution (artickc's memory-leak/perf cluster, OrcaRouter, Wafer AI, MITM gaps, etc.); move the OrcaRouter bullet out of [Unreleased]. - Inject the EN [3.8.28] section into all 41 i18n CHANGELOG mirrors (parity). - Reconcile the env/docs contract: document MITM_IDLE_TIMEOUT_MS + MITM_VERBOSE in .env.example and ENVIRONMENT.md; allowlist the framework-internal TURBOPACK and the Claude Code ANTHROPIC_AUTH_TOKEN in check-env-doc-sync. - Fix 3 broken relative links in docs/providers/AGENTROUTER.md (regressed when the file was relocated this cycle) so docs-sync-strict passes. * fix(quality): treat test→test renames as relocations, not deletions The anti-test-masking gate's subcheck-1 collected deleted AND renamed test files via `--diff-filter=DR --name-only` and flagged every one as "deleted — human review required", contradicting its own documented contract ("DELETADOS ou renomeados-e-NÃO-substituídos"): a rename test→test IS a substitution (the test moved, coverage preserved). This false-positived on #4063's legitimate relocation of live-ws-startup.test.ts (unit/cli → integration, asserts 2→2) and would block every PR that relocates a test — surfacing only at release-day because the Fast QG (PR→release) doesn't run test-masking. The gate now parses `--name-status -M`: true deletions and test→non-test renames still flag; a test→test rename is run through the assert-reduction check across the move, so a clean relocation passes while gutting-via-rename (dropped asserts / new tautologies / skips) still fires. Adds partitionDeletedRenamed + 6 regression tests. --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Demiurge The Single <megamen932@gmail.com> Co-authored-by: jinhaosong-source <jinhao.song@myflashcloud.com> Co-authored-by: diego-anselmo <contato@diegoanselmo.com.br> Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Co-authored-by: Rahul sharma <sharmaR0810@gmail.com> Co-authored-by: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Co-authored-by: NOXX - Commiter <artur1992123@mail.ru>
1037 lines
42 KiB
YAML
1037 lines
42 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
types: [opened, synchronize, reopened, ready_for_review]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
CI_NODE_VERSION: "24"
|
|
CI_NODE_24_VERSION: "24"
|
|
CI_NODE_26_VERSION: "26"
|
|
|
|
jobs:
|
|
lint:
|
|
name: Lint
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
|
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
|
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-lint-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run audit:deps
|
|
- run: npm run lint
|
|
- run: npm run check:cycles
|
|
- run: npm run check:route-validation:t06
|
|
- run: npm run check:any-budget:t11
|
|
- run: npm run check:provider-consistency
|
|
- run: npm run check:fetch-targets
|
|
- run: npm run check:deps
|
|
- run: npm run check:file-size
|
|
- run: npm run check:error-helper
|
|
- run: npm run check:migration-numbering
|
|
- run: npm run check:public-creds
|
|
- run: npm run check:db-rules
|
|
- run: npm run check:known-symbols
|
|
- run: npm run check:route-guard-membership
|
|
- run: npm run check:test-discovery
|
|
- run: npm run check:tracked-artifacts
|
|
- run: npm run check:lockfile
|
|
- run: npm run check:licenses
|
|
# 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
|
|
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
|
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
|
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
|
permissions:
|
|
contents: read
|
|
security-events: read
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
|
- uses: actions/download-artifact@v8
|
|
with:
|
|
name: coverage-report
|
|
path: coverage/
|
|
- run: npm run quality:collect
|
|
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
|
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
|
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
|
|
- name: Ratchet check
|
|
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
|
# Fase 6A.5: require-tighten — 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 --require-tighten
|
|
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
|
|
# para não pesar no caminho crítico do lint.
|
|
- name: Duplication ratchet
|
|
run: npm run check:duplication
|
|
- name: Complexity ratchet
|
|
run: npm run check:complexity
|
|
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
|
|
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
|
|
# de quality-baseline.json e saem 1 em regressão.
|
|
- name: Dead-code ratchet (knip)
|
|
run: npm run check:dead-code
|
|
- name: Cognitive complexity ratchet (sonarjs)
|
|
run: npm run check:cognitive-complexity
|
|
- name: Type coverage ratchet
|
|
run: npm run check:type-coverage
|
|
# CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26).
|
|
# Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa
|
|
# regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api)
|
|
# é skip gracioso com exit 0 — security-events:read no job-level permissions.
|
|
- name: CodeQL alerts ratchet (blocking)
|
|
run: npm run check:codeql-ratchet
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
- name: Append summary
|
|
if: always()
|
|
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
|
|
- name: Upload ratchet report
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: quality-ratchet
|
|
path: .artifacts/quality-ratchet.md
|
|
if-no-files-found: warn
|
|
|
|
# Phase 7/8 extended quality gates — MIXED (Etapa 2, v3.8.26). The job no longer
|
|
# carries a job-level continue-on-error: the three ratchet-blocking steps below
|
|
# (Secret scan / Workflow lint / Bundle size, all passing --ratchet) FAIL the job
|
|
# on a measured regression vs config/quality/quality-baseline.json. The remaining
|
|
# steps stay ADVISORY via step-level continue-on-error (scanner install,
|
|
# vuln/osv ratchet, OpenAPI/oasdiff breaking-change, circular-deps/dpdm): they
|
|
# depend on external binaries/state that may legitimately self-skip, so they must
|
|
# never block. The blocking gates themselves SKIP (exit 0) when their binary/plugin
|
|
# is absent — only a measured regression on the SAME metric the baseline froze
|
|
# blocks. The CodeQL ratchet was PROMOTED to the quality-gate job (v3.8.26).
|
|
# SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets.
|
|
quality-extended:
|
|
name: Quality Gates (Extended)
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
|
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
|
|
# would lack the base ref and the gate would self-skip (base-unresolved).
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
|
|
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
|
|
- name: Circular deps (dpdm; advisory)
|
|
continue-on-error: true
|
|
run: npm run check:circular-deps
|
|
# BLOCKING ratchet (Etapa 2): bundleSize must not regress vs the baseline
|
|
# (gzip via @size-limit/file, installed by `npm ci`). --ratchet exits 1 on a
|
|
# measured regression; it SKIPs (exit 0) when the size-limit plugin/build is
|
|
# absent (a non-comparable measurement never blocks).
|
|
- name: Bundle size (ratchet, blocking)
|
|
run: npm run check:bundle-size -- --ratchet
|
|
# CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) —
|
|
# não roda aqui para evitar duplo run/duplo report.
|
|
# Install the advisory security scanners so the gates below actually run
|
|
# (they self-skip when the binaries are absent). Robustness lessons baked in:
|
|
# • `go install …/gitleaks/v8@latest` produces a binary WITHOUT the version
|
|
# ldflags gitleaks needs (and often fails) — avoided.
|
|
# • `curl …api.github.com/…/releases/latest` is UNAUTHENTICATED and
|
|
# rate-limited to 60 req/hr/IP; when throttled it returns an empty body,
|
|
# so the asset URL resolves to nothing and the install silently no-ops —
|
|
# every gate then self-skips and the metric is never produced. We instead
|
|
# use `gh release download`, which is preinstalled on GitHub runners and
|
|
# authenticated via GITHUB_TOKEN (5000 req/hr) — robust under load.
|
|
# • actionlint keeps its official download script; zizmor stays on pipx.
|
|
# We `set +e` (no single failure aborts the step), ALWAYS export $GITHUB_PATH
|
|
# at the end, and print diagnostics so the next CI run proves exactly what
|
|
# installed. The job is continue-on-error too, so an install hiccup never
|
|
# blocks the build.
|
|
- name: Install advisory security scanners (gitleaks/osv/actionlint/zizmor)
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set +e
|
|
mkdir -p "$HOME/.local/bin"
|
|
# gitleaks — download latest linux x64 tarball via gh (authed), extract binary
|
|
rm -rf /tmp/gl && mkdir -p /tmp/gl
|
|
gh release download --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
|
|
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
|
|
# osv-scanner — download latest linux amd64 bare binary via gh (authed)
|
|
rm -rf /tmp/osv && mkdir -p /tmp/osv
|
|
gh release download --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
|
|
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
|
# actionlint — official download script
|
|
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
|
|
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
|
|
pipx install zizmor || pip install --user zizmor
|
|
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
|
|
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
|
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
|
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
|
|
# ALWAYS export the bin dir (even if any step above failed)
|
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
# diagnostics — prove what installed on the next CI run
|
|
ls -la "$HOME/.local/bin"
|
|
"$HOME/.local/bin/gitleaks" version || true
|
|
"$HOME/.local/bin/actionlint" -version || true
|
|
"$HOME/.local/bin/osv-scanner" --version || true
|
|
"$HOME/.local/bin/oasdiff" --version || true
|
|
zizmor --version || true
|
|
# BLOCKING ratchet (Etapa 2): secretFindings must not regress vs the baseline.
|
|
# --ratchet exits 1 on a measured regression; it SKIPs (exit 0) when gitleaks
|
|
# is absent (a missing binary never blocks).
|
|
- name: Secret scan (gitleaks, ratchet, blocking)
|
|
run: npm run check:secrets -- --ratchet
|
|
# 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/reference/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
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:docs-all
|
|
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
|
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
|
|
# (floor ~36), openapi-security-tiers is advisory (Hard Rules #15/#17).
|
|
- name: CLI i18n consistency
|
|
run: npm run check:cli-i18n
|
|
- name: OpenAPI route coverage (ratchet)
|
|
run: npm run check:openapi-coverage
|
|
- name: OpenAPI security-tier consistency (advisory)
|
|
run: npm run check:openapi-security-tiers
|
|
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
|
|
run: npm run check:openapi-routes
|
|
- name: Doc /api refs resolve to real routes (anti-hallucination)
|
|
run: npm run check:docs-symbols
|
|
- name: i18n translation drift (warn)
|
|
run: node scripts/i18n/check-translation-drift.mjs --warn
|
|
|
|
docs-lint:
|
|
name: Docs Lint (prose — advisory)
|
|
runs-on: ubuntu-latest
|
|
# Advisory (warning-first): prose/markdown style must not block merges while the
|
|
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
|
continue-on-error: true
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
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
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
|
|
|
i18n-matrix:
|
|
name: Build language matrix
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
langs: ${{ steps.langs.outputs.langs }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- id: langs
|
|
run: |
|
|
LANG_DIR="src/i18n/messages"
|
|
LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .)
|
|
echo "langs=${LANGS}" >> "$GITHUB_OUTPUT"
|
|
|
|
i18n:
|
|
name: i18n Validation
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: true
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
|
|
needs: i18n-matrix
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-python@v6
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Validate ${{ matrix.lang }}
|
|
env:
|
|
# Pass the matrix value via env (never interpolate ${{ ... }} straight
|
|
# into the run: script body) so the shell receives a variable, not
|
|
# inlined text — zizmor template-injection mitigation. Named MATRIX_LANG
|
|
# to avoid clobbering the POSIX `LANG` locale variable.
|
|
MATRIX_LANG: ${{ matrix.lang }}
|
|
run: |
|
|
python3 scripts/i18n/validate_translation.py quick -l "$MATRIX_LANG" > result.txt
|
|
|
|
- name: Upload result
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: i18n-${{ matrix.lang }}
|
|
path: result.txt
|
|
|
|
pr-test-policy:
|
|
name: PR Test Policy
|
|
if: ${{ github.event_name == 'pull_request' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
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
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run build
|
|
- name: Archive Next.js build for E2E shards
|
|
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
|
|
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
|
|
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
|
|
run: |
|
|
tar -czf /tmp/e2e-build.tar.gz \
|
|
--exclude='.build/next/standalone/node_modules' \
|
|
--exclude='.build/next/cache' \
|
|
.build/next
|
|
- name: Upload Next.js build for E2E shards
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: e2e-next-build
|
|
path: /tmp/e2e-build.tar.gz
|
|
retention-days: 1
|
|
|
|
package-artifact:
|
|
name: Package Artifact
|
|
runs-on: ubuntu-latest
|
|
needs: build
|
|
env:
|
|
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
# build:cli runs a clean build into .build/next and assembles dist/
|
|
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
|
|
- run: npm run build:cli
|
|
- name: Assert dist/server.js exists
|
|
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
|
- run: npm run check:pack-artifact
|
|
|
|
electron-package-smoke:
|
|
name: Electron Package Smoke
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 25
|
|
needs: build
|
|
env:
|
|
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run build
|
|
- name: Install Electron dependencies
|
|
working-directory: electron
|
|
run: npm install --no-audit --no-fund
|
|
- name: Pack Electron app
|
|
working-directory: electron
|
|
run: npm run pack
|
|
- name: Smoke packaged Electron app
|
|
env:
|
|
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
|
run: xvfb-run -a npm run electron:smoke:packaged
|
|
|
|
test-unit:
|
|
name: Unit Tests (${{ matrix.shard }}/8)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
|
|
|
test-vitest:
|
|
name: Vitest (MCP / autoCombo / UI components)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: build
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
|
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
|
- run: npm run test:vitest
|
|
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
|
|
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
|
|
# fixes the components/tests; then drop continue-on-error to make it blocking.
|
|
- run: npm run test:vitest:ui
|
|
continue-on-error: true
|
|
|
|
node-24-compat:
|
|
name: Node 24 Compatibility (${{ matrix.shard }}/2)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_24_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run build
|
|
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
|
|
|
node-26-compat:
|
|
name: Node 26 Compatibility (${{ matrix.shard }}/2)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_26_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run build
|
|
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
|
|
|
test-coverage-shard:
|
|
name: Coverage Shard (${{ matrix.shard }}/8)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 25
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- name: Run c8 over shard ${{ matrix.shard }}/8
|
|
run: |
|
|
rm -rf coverage-shard coverage-shard-report
|
|
# `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge
|
|
# job reads with `c8 report --temp-directory ...`. Using `--output-dir`
|
|
# only produces the final json *report* and leaves the raw v8 files in
|
|
# `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp
|
|
# dir so the raw coverage files live there and the artifact upload picks
|
|
# them up regardless of `--test-force-exit` timing.
|
|
npx c8 \
|
|
--temp-directory=coverage-shard \
|
|
--reports-dir=coverage-shard-report \
|
|
--reporter=json \
|
|
--exclude=tests/** \
|
|
--exclude=**/*.test.* \
|
|
node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \
|
|
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
|
- name: Upload raw shard coverage
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: coverage-shard-${{ matrix.shard }}
|
|
path: coverage-shard/*.json
|
|
if-no-files-found: error
|
|
|
|
test-coverage:
|
|
name: Coverage
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
needs: test-coverage-shard
|
|
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- name: Download all shard coverage
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: coverage-shard-*
|
|
path: coverage-shards/
|
|
merge-multiple: true
|
|
- name: Merge + report + gate
|
|
# Merging 8 shards of raw v8 coverage is memory-heavy; the 6 GB heap can
|
|
# still OOM on large PR runs. Keep this job focused on the gate and
|
|
# JSON summary that downstream ratchets consume.
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
run: |
|
|
mkdir -p coverage
|
|
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
|
|
echo "::error::No raw coverage shard data was downloaded."
|
|
find . -maxdepth 3 -type f | sort
|
|
exit 1
|
|
fi
|
|
# Gate aligned to the project's local coverage bar: `npm run test:coverage`
|
|
# gates at 60/60/60/60, so CI must match it (the previous CI floor of 40
|
|
# silently undershot the local bar — a real drift). Real merged coverage is
|
|
# ~79/79/82/75, so 60 is a conservative floor with headroom; the Fase-4
|
|
# coverage ratchet (quality-baseline.json) layers "must not drop vs baseline"
|
|
# on top of this floor.
|
|
npx c8 report \
|
|
--temp-directory coverage-shards \
|
|
--reports-dir coverage \
|
|
--reporter=text-summary \
|
|
--reporter=json-summary \
|
|
--exclude=tests/** \
|
|
--exclude=**/*.test.* \
|
|
--check-coverage \
|
|
--statements 60 --lines 60 --functions 60 --branches 60
|
|
- name: Build coverage summary
|
|
if: always()
|
|
run: |
|
|
mkdir -p coverage
|
|
if [ -f coverage/coverage-summary.json ]; then
|
|
node scripts/check/test-report-summary.mjs \
|
|
--input coverage/coverage-summary.json \
|
|
--output coverage/coverage-report.md \
|
|
--threshold 60
|
|
else
|
|
printf '%s\n' \
|
|
'# Coverage Report' \
|
|
'' \
|
|
'Coverage summary JSON was not generated. Inspect the Coverage job logs.' \
|
|
> coverage/coverage-report.md
|
|
fi
|
|
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
|
|
- name: Upload coverage artifacts
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: coverage-report
|
|
path: |
|
|
coverage/coverage-summary.json
|
|
coverage/coverage-report.md
|
|
if-no-files-found: warn
|
|
|
|
sonarqube:
|
|
name: SonarQube
|
|
runs-on: ubuntu-latest
|
|
needs: test-coverage
|
|
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
|
env:
|
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
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: SonarQube Scan
|
|
if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
|
|
uses: SonarSource/sonarqube-scan-action@v8
|
|
env:
|
|
SONAR_TOKEN: ${{ env.SONAR_TOKEN }}
|
|
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
|
|
|
|
coverage-pr-comment:
|
|
name: PR Coverage Comment
|
|
runs-on: ubuntu-latest
|
|
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
|
needs:
|
|
- pr-test-policy
|
|
- test-coverage
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
pull-requests: write
|
|
steps:
|
|
- name: Download coverage artifact
|
|
if: ${{ needs.test-coverage.result != 'cancelled' }}
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: coverage-report
|
|
path: .
|
|
- name: Prepare PR coverage comment
|
|
env:
|
|
COVERAGE_RESULT: ${{ needs.test-coverage.result }}
|
|
POLICY_RESULT: ${{ needs.pr-test-policy.result }}
|
|
run: |
|
|
mkdir -p .artifacts
|
|
{
|
|
echo "<!-- omniroute-coverage-report -->"
|
|
echo "## CI Coverage Report"
|
|
echo ""
|
|
echo "- Coverage job: \`${COVERAGE_RESULT}\`"
|
|
echo "- PR test policy: \`${POLICY_RESULT}\`"
|
|
echo ""
|
|
if [ -f coverage/coverage-report.md ]; then
|
|
cat coverage/coverage-report.md
|
|
else
|
|
echo "Coverage artifact was not available for this run."
|
|
fi
|
|
if [ "${POLICY_RESULT}" = "failure" ]; then
|
|
echo ""
|
|
echo "## PR Test Policy"
|
|
echo ""
|
|
echo "This PR changes production code in \`src/\`, \`open-sse/\`, \`electron/\`, or \`bin/\` without accompanying automated tests."
|
|
fi
|
|
} > .artifacts/pr-coverage-comment.md
|
|
- uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const fs = require("fs");
|
|
const marker = "<!-- omniroute-coverage-report -->";
|
|
const body = fs.readFileSync(".artifacts/pr-coverage-comment.md", "utf8");
|
|
const { owner, repo } = context.repo;
|
|
const issue_number = context.issue.number;
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number,
|
|
per_page: 100,
|
|
});
|
|
|
|
const existing = comments.find((comment) => comment.body?.includes(marker));
|
|
|
|
if (existing) {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existing.id,
|
|
body,
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number,
|
|
body,
|
|
});
|
|
}
|
|
|
|
test-e2e:
|
|
name: E2E Tests (${{ matrix.shard }}/9)
|
|
runs-on: ubuntu-latest
|
|
# Build artifact from the `build` job is downloaded instead of rebuilding
|
|
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
|
|
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
|
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
|
|
timeout-minutes: 45
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- name: Cache Playwright browsers
|
|
uses: actions/cache@v5.0.5
|
|
with:
|
|
path: ~/.cache/ms-playwright
|
|
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
|
restore-keys: playwright-chromium-${{ runner.os }}-
|
|
- run: npx playwright install --with-deps chromium
|
|
- name: Download Next.js build artifact
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: e2e-next-build
|
|
path: /tmp/
|
|
- name: Extract Next.js build and restore standalone node_modules
|
|
run: |
|
|
tar -xzf /tmp/e2e-build.tar.gz
|
|
cp -r node_modules .build/next/standalone/node_modules
|
|
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
|
|
|
test-integration:
|
|
name: Integration Tests (${{ matrix.shard }}/2)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: build
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2]
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
INITIAL_PASSWORD: ci-test-password-for-integration
|
|
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: node --import tsx --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: build
|
|
env:
|
|
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
|
API_KEY_SECRET: ci-test-api-key-secret-long
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ env.CI_NODE_VERSION }}
|
|
cache: npm
|
|
- run: npm ci
|
|
- run: npm run check:node-runtime
|
|
- run: npm run test:security
|
|
|
|
ci-summary:
|
|
name: CI Dashboard
|
|
runs-on: ubuntu-latest
|
|
if: always()
|
|
needs:
|
|
- lint
|
|
- docs-sync-strict
|
|
- i18n-ui-coverage
|
|
- i18n
|
|
- pr-test-policy
|
|
|
|
- build
|
|
- package-artifact
|
|
- electron-package-smoke
|
|
- test-unit
|
|
- node-24-compat
|
|
- node-26-compat
|
|
- test-coverage
|
|
- sonarqube
|
|
- coverage-pr-comment
|
|
- test-e2e
|
|
- test-integration
|
|
- test-security
|
|
steps:
|
|
- name: Download i18n results
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: i18n-*
|
|
path: results
|
|
merge-multiple: true
|
|
|
|
- name: Generate dashboard
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
status() {
|
|
case "$1" in
|
|
success) echo "🟢 PASS" ;;
|
|
failure) echo "🔴 FAIL" ;;
|
|
cancelled) echo "⚫ CANCELLED" ;;
|
|
skipped) echo "⚪ SKIPPED" ;;
|
|
*) echo "🟡 UNKNOWN" ;;
|
|
esac
|
|
}
|
|
|
|
echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
total=0
|
|
langs=0
|
|
|
|
if [ -d results ]; then
|
|
for file in results/*.txt; do
|
|
[ -f "$file" ] || continue
|
|
val=$(sed -r 's/\x1B\[[0-9;]*[mK]//g' "$file" | grep "Untranslated:" | awk '{print $2}')
|
|
val=${val:-0}
|
|
total=$((total + val))
|
|
langs=$((langs + 1))
|
|
done
|
|
fi
|
|
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "|--------|------|" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Languages checked | $langs |" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "| Total untranslated | $total |" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
if [ "$total" -gt 0 ]; then
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "⚠️ **Translations need attention**" >> "$GITHUB_STEP_SUMMARY"
|
|
else
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "✅ **All translations complete**" >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|