mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
* fix(ci): five workflow defects, one of them shipping the wrong dmg to Intel Macs
Gaps 31, 19, 16, 30 and 12 of the v3.8.49 process dossier.
## 31 — LIVE BUG: an Intel Mac downloads the ARM dmg
electron-builder runs once per macOS job and each run emits its own
`latest-mac.yml` listing only its own dmg — measured at 338 and 350 bytes,
different content, identical filename. `download-artifact` with
`merge-multiple: true` resolves that collision by ARRIVAL ORDER, so one silently
overwrites the other. arm64 won in the published v3.8.48.
Why that breaks Intel, from electron-updater's own selection code
(out/providers/Provider.js):
files.find(it => [...].some(n => n.includes(process.arch))) ?? files.shift()
The Intel dmg is `OmniRoute-X.Y.Z.dmg` — no arch suffix. On Intel `process.arch`
is "x64", nothing matches, and the fallback takes the FIRST entry. With an
arm64-only manifest that is the ARM build.
So ORDER is the fix, not tidiness: the un-suffixed entry must be first, because
it is the only one reachable through that fallback. `merge-multiple` is now off
(per-artifact subdirectories) and a new
`scripts/release/merge-mac-update-manifest.mjs` merges them deliberately. It
refuses to write when the inputs disagree on version — a manifest stitched from
two builds points at files that were never published together, which is worse
than no manifest.
Validated against the REAL v3.8.49 manifests, not just fixtures: the script
reproduces byte-for-byte the manifest I hand-merged and published, including
both sha512 values and the newer releaseDate.
## 19 — one variable, two opposite machines
`USE_VPS_RUNNER` governed the build and the test jobs together. The build needs
the .113's RAM; the tests need the hosted runner's link. Measured 2026-07-29:
`actions/setup-node` took 20m06s on .113 with 4 concurrent runners versus 16s
hosted (npm cache restore saturating the link), while the tests themselves tied
— 2m54 vs 2m31.
Self-hosted is therefore strictly worse for tests, so rather than add a second
variable to configure, `test-unit`, `test-vitest`, `fast-unit` and `fast-vitest`
are pinned to `ubuntu-latest`. `quality.yml`'s `fast-gates` deliberately keeps
the variable — I have no measurement for it, and guessing is what produced this
gap.
## 16 — a flaky shard sent the publish into the 40-minute build
The artifact reuse filter required `conclusion == "success"` on the whole run, so
any unrelated red shard discarded a perfectly good tree. The artifact is only
uploaded if the Build job succeeded, so its PRESENCE is the accurate signal. Now
it takes the 5 most recent candidate runs and tries each download until one
works. `head_repository.full_name == env.REPO` stays — that clause is the
artifact-poisoning guard, not a filter refinement.
## 30 — the gate that could be bypassed at merge
`check:agent-skills-sync` lived only in quality.yml's PR-only Merge-integrity
job, because the CHANGELOG half of that job needs a base to diff against. This
half does not. Keeping it PR-only left a real hole: this cycle's merge trains
landed with `--admin`, which bypasses required checks, so three SKILL.md files
drifted, rode the release squash into `main`, and the sync-back turned them into
a base-red blocking EVERY PR into release/v3.8.50 until #8954. It now also runs
in ci.yml's lint job, which runs on push to `main`.
## 12 — a cancelled gate reads like a passing one
The dashboard already renders `⚫ CANCELLED` per job, so my dossier entry was
imprecise: they do not vanish, they sit buried mid-table. A cancelled job
reported no verdict at all, and this cycle the Vitest job was cancelled in rounds
1, 2 and 3 — it finished only in round 4, revealing a suite broken the whole
cycle plus two production bugs. The summary now opens with a banner naming every
cancelled job and saying plainly that nothing was checked.
node --import tsx/esm --test tests/unit/mac-update-manifest-merge.test.ts # 11 pass
merge against the real v3.8.49 manifests → both dmgs, Intel first
all four workflows parse; check:workflows --ratchet → 178, baseline 190
* docs(changelog): fragment for #8988
* test(ci): align the artifact-provenance guard with the gap-16 criterion
My own assertion from #8953 encoded the criterion this PR deliberately removes:
it required `.conclusion == "success"` on the whole CI run, which discarded a
perfectly good build tree whenever any unrelated shard went red — pushing the
publish into the 40-minute build the fast path exists to avoid.
Inverted rather than deleted, and the replacement is strictly stronger. It now
pins three things where the old one pinned one: that the loose criterion is gone,
that the step actually probes for the artifact (the accurate signal, since it is
only uploaded when the Build job succeeded), and that it probes MORE THAN ONE
candidate run — without which a single miss still falls back to a full build.
The provenance clause it was originally written to protect
(head_repository.full_name == env.REPO) is untouched and still asserted above.
* fix(ci): finish gap 19 — pin fast-gates and give USE_VPS_RUNNER one meaning
This was left deliberately partial because `fast-gates` had never been measured,
and guessing is what produced gap 19 in the first place. Measured now, and the
evidence is cleaner than expected:
fast-gates, 160 quality.yml runs .... ZERO self-hosted samples
every non-skipped one is "GitHub Actions NNNN"
median duration, 72 successful runs .. 5.6 min hosted
The classifier is not at fault — in the same window ci.yml's Build demonstrably
ran on omniroute-113-7 and omniroute-113-6, so self-hosted runs are visible when
they happen. The USE_VPS_RUNNER expression on this job was dead configuration.
And had it ever fired it would have inherited the measured penalty, because this
job's first two steps are exactly the bottleneck:
actions/setup-node on .113 with 4 concurrent runners .... 20m06s
actions/setup-node hosted .............................. 16s
So it is pinned rather than switched, and the second variable the gap proposed
(USE_VPS_RUNNER_BUILD / _TESTS) turns out to be unnecessary. After this the
variable governs exactly five jobs, all of them build-like:
ci.yml:build · quality.yml:build · npm-publish:publish
nightly-release-green: release-green, main-green
One variable, one meaning: "this job needs the .113's memory". A guard test pins
that — it fails if the variable is ever attached to a test-like job again, and it
also asserts the build KEEPS it, so nobody closes this gap by removing the
variable outright.
node --import tsx/esm --test tests/unit/vps-runner-variable-scope.test.ts # 3 pass
check:workflows --ratchet → 178, baseline 190
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
1373 lines
66 KiB
YAML
1373 lines
66 KiB
YAML
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
pull_request:
|
||
branches: [main]
|
||
types: [opened, synchronize, reopened, ready_for_review]
|
||
workflow_dispatch:
|
||
|
||
concurrency:
|
||
group: ${{ github.workflow }}-${{ github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
env:
|
||
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
|
||
# test installed a fake PEM on a persistent self-hosted runner and broke all
|
||
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
|
||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||
CI_NODE_VERSION: "24"
|
||
CI_NODE_24_VERSION: "24"
|
||
CI_NODE_26_VERSION: "26"
|
||
|
||
jobs:
|
||
changes:
|
||
name: Change Classification
|
||
runs-on: ubuntu-latest
|
||
outputs:
|
||
code: ${{ steps.classify.outputs.code }}
|
||
docs: ${{ steps.classify.outputs.docs }}
|
||
i18n: ${{ steps.classify.outputs.i18n }}
|
||
workflow: ${{ steps.classify.outputs.workflow }}
|
||
testsOnly: ${{ steps.classify.outputs.testsOnly }}
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||
with:
|
||
persist-credentials: false
|
||
fetch-depth: 0
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: ${{ env.CI_NODE_VERSION }}
|
||
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
|
||
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
|
||
# a full check board attached on every push to that branch. One field comparison.
|
||
- name: Reject a PR that targets its own branch
|
||
if: github.event_name == 'pull_request'
|
||
env:
|
||
HEAD_REF: ${{ github.head_ref }}
|
||
BASE_REF: ${{ github.base_ref }}
|
||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
run: node scripts/check/check-pr-self-target.mjs
|
||
|
||
- 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
|
||
# (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the
|
||
# CHANGELOG half of that job needs a base to diff against. This half does NOT: the
|
||
# generator either reproduces the committed SKILL.md files or it does not.
|
||
#
|
||
# Keeping it PR-only left a real hole. This cycle's merge trains landed in batches with
|
||
# `--admin`, which bypasses required checks, so three SKILL.md files drifted from the route
|
||
# catalog, rode the release squash into `main`, and the next cycle's sync-back turned them
|
||
# into a base-red that blocked EVERY PR into release/v3.8.50 until #8954. Running it here
|
||
# means a push to `main` catches the drift at the source instead of the next cycle
|
||
# inheriting it.
|
||
- run: npm run check:agent-skills-sync
|
||
# 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.
|
||
# PINNED on purpose. Unpinned, the runner installed whatever PyPI served that day and
|
||
# measured 1 finding MORE than the devbox on the identical commit (190 vs 189) during
|
||
# the v3.8.49 cycle — which cost a second rebaseline push per release, chasing a
|
||
# number that was never the code's. The ratchet compares counts across machines, so
|
||
# the auditor version has to be the same on both. Bump this deliberately, and
|
||
# rebaseline in the same commit: check-workflows.mjs now prints `zizmorVersion=` next
|
||
# to the count so the new number is traceable to the tool that produced it.
|
||
ZIZMOR_VERSION=1.25.2
|
||
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
|
||
# 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:
|
||
# fetch-depth: 0 — the value-drift gate diffs en.json against the merge base to
|
||
# find rewritten English strings. On a shallow clone the base ref is missing and
|
||
# the gate self-skips (base-unresolved), so it would never actually run.
|
||
- 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
|
||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||
# #8463: a rewritten English value used to leave its 39 translations behind
|
||
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
|
||
# Key parity above cannot see it — a stale translation counts as covered.
|
||
- name: i18n UI value drift (stale translations)
|
||
env:
|
||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||
run: node scripts/i18n/check-ui-value-drift.mjs
|
||
|
||
# #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
|
||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
|
||
|
||
# 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).
|
||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||
# self-hosted is strictly worse here and there is nothing to configure.
|
||
runs-on: 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).
|
||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||
# self-hosted is strictly worse here and there is nothing to configure.
|
||
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) }}
|
||
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"
|
||
# FILES is our own newline-separated path list, so word-splitting is intended
|
||
# shellcheck disable=SC2086,SC2046
|
||
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 }}
|
||
# Workflow-controlled data (job results), not user input — safe to read here.
|
||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||
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"
|
||
|
||
# (gap 12) A cancelled job never reported a verdict, and in a long table that reads the
|
||
# same as a green one. `cancel-in-progress` plus incremental fixing cancels jobs on every
|
||
# push, and this cycle the Vitest job was cancelled in rounds 1, 2 and 3 — it only ran to
|
||
# completion in round 4, where it revealed a suite that had been broken the whole cycle
|
||
# plus two production bugs. A gate that never finishes is indistinguishable from one that
|
||
# passes, so name them at the TOP instead of leaving them to be spotted mid-table.
|
||
CANCELLED_JOBS=$(printf '%s' "$NEEDS_JSON" \
|
||
| jq -r 'to_entries | map(select(.value.result == "cancelled")) | .[].key' 2>/dev/null \
|
||
| sort | paste -sd", " -) || CANCELLED_JOBS=""
|
||
if [ -n "$CANCELLED_JOBS" ]; then
|
||
{
|
||
echo "> ### ⚫ Cancelled — no verdict was reported"
|
||
echo ">"
|
||
echo "> \`$CANCELLED_JOBS\`"
|
||
echo ">"
|
||
echo "> These did not fail; they never finished, so nothing was checked. Treat this"
|
||
echo "> run as INCOMPLETE for those gates. If the cancellation came from"
|
||
echo "> \`cancel-in-progress\` on a newer push, the newer run covers it — otherwise"
|
||
echo "> re-run them before reading this dashboard as green."
|
||
echo ""
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
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
|