From 45c91e22c250e376a9f3d21c5bbbcdf92b8b04fb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 1 Aug 2026 09:55:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(ci):=20G0=20=E2=80=94=20quality=20rail=20(?= =?UTF-8?q?PR=E2=86=92release/**)=20ganha=20ratchets+seguran=C3=A7a=20do?= =?UTF-8?q?=20trilho=20A=20(#9108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O refactor de god-files do trilho 3.8.50→3.9.0 acontece em PRs→release/**, e esse trilho pulava o motor de ratchet, o CodeQL ratchet e todos os scanners de segurança — exatamente onde a rede era necessária (5 das 13 causas da reconciliação de 07-24 eram regressões reais shipadas por CI verde por-PR). Modo enxuto, jobs EXISTENTES (a .51 consolida lanes; nenhum job novo): - lint-guard: quality:collect + ratchet --allow-missing + require-tighten + check:codeql-ratchet. O job já escreve .artifacts/eslint-results.json, então o motor entra a custo ZERO de ESLint (um inventário, dois consumidores). Coverage ausente degrada gracioso (--allow-missing); autoridade de coverage segue no trilho A. + permissions security-events:read para o CodeQL ratchet. - fast-gates: check:cycles, check:lockfile, duplication, dead-code, type-coverage, compression-budget + install endurecido dos scanners (gh release download, zizmor PINADO 1.25.2 = mesmo auditor do ci.yml) + secrets/vuln/workflows/ openapi-breaking com --ratchet (self-skip sem binário; só regressão medida bloqueia). - Fora de propósito: bundle-size (self-skip sem build → configuração morta) e o run de coverage (fast-unit já roda a suíte cheia). Runners intocados: guard tests/unit/vps-runner-variable-scope.test.ts verde; teste novo tests/unit/quality-rail-gate-membership.test.ts pina a MEMBERSHIP dos gates no trilho B (red antes da edição, green depois). Validação no tip puro (nenhum base-red fabricado para a fila de PRs abertos): cycles OK · lockfile OK · duplication 4.26% (base 5.72%) · dead-code 226 (base 227) · type-coverage 94.13% (base 92.17%) · compression OK · secrets 0 (base 0) · vuln 5 (base 10) · codeql 0 (base 0) · oasdiff 0 (base 0) · zizmor 178 (base 190) · actionlint exit 0 no arquivo editado · quality-ratchet 56 métricas OK + require-tighten OK com --allow-missing. Refs #8084 Co-authored-by: diegosouzapw --- .github/workflows/quality.yml | 104 ++++++++++++++++++ .../unit/quality-rail-gate-membership.test.ts | 102 +++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/unit/quality-rail-gate-membership.test.ts diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7a9a1d2f87..f3f5fa0a1e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -179,6 +179,81 @@ jobs: # Complexity + cognitive-complexity: ONE ESLint walk (both baselines still # enforced separately by ruleId). Avoids two cold tree walks on fast-path. - run: npm run check:complexity-ratchets + # ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ────────────── + # The god-file refactor happens in PRs→release/**; without these, the release + # rail never sees a new import cycle, dead code, duplication or a security + # regression until the release PR to main. Deliberately NOT brought here: + # bundle-size (self-skips without a build — this rail's build job is advisory + # and uploads nothing, so it would be dead configuration) and the coverage + # run (fast-unit already runs the full suite; the coverage ratchet stays on + # the main rail via --allow-missing in lint-guard). + - run: npm run check:cycles + - run: npm run check:lockfile + - name: Duplication ratchet + run: npm run check:duplication + - 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 + run: npm run check:compression-budget + # Security scanners — same hardened install as ci.yml quality-extended + # (gh release download = authenticated, 5000 req/hr; curl to api.github.com + # is rate-limited to 60/hr and silently no-ops when throttled). The blocking + # gates below SKIP (exit 0) when their binary is absent — only a measured + # regression vs config/quality/quality-baseline.json blocks. + - name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff) + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set +e + mkdir -p "$HOME/.local/bin" + # Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set + # update must be an explicit PR that re-measures/rebaselines, never a random + # red (or green) caused by whatever "latest" served that morning. + GITLEAKS_VERSION=v8.30.1 + OSV_SCANNER_VERSION=v2.3.8 + ACTIONLINT_VERSION=v1.7.12 + ZIZMOR_VERSION=1.25.2 + OASDIFF_VERSION=v1.19.1 + # gitleaks — pinned linux x64 tarball via gh (authed), extract binary + rm -rf /tmp/gl && mkdir -p /tmp/gl + gh release download "$GITLEAKS_VERSION" --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 — pinned linux amd64 bare binary via gh (authed) + rm -rf /tmp/osv && mkdir -p /tmp/osv + gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv + install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner" + # actionlint — official installer from a pinned release tag (never main) + bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin" + # zizmor — pinned PyPI package (same version as ci.yml quality-extended) + pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION" + # oasdiff — pinned linux amd64 tarball via gh (authed), extract binary + rm -rf /tmp/oasd && mkdir -p /tmp/oasd + gh release download "$OASDIFF_VERSION" --repo Tufin/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" + "$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 + - name: Secret scan (gitleaks, ratchet, blocking) + run: npm run check:secrets -- --ratchet + - name: Vulnerability ratchet (osv-scanner, ratchet, blocking) + run: npm run check:vuln-ratchet -- --ratchet + - name: Workflow lint (actionlint+zizmor, ratchet, blocking) + run: npm run check:workflows -- --ratchet + # BASE_REF is read by the script from the env (never interpolated into a + # shell body) — workflow-injection-safe. actions/checkout fetches remote + # refs, not a local branch named github.base_ref, so prefix origin/ or this + # gate self-skips every PR with reason=base-unresolved. + - name: OpenAPI breaking-change (oasdiff, ratchet, blocking) + env: + BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }} + run: npm run check:openapi-breaking -- --ratchet - name: Typecheck (core) run: npm run typecheck:core # #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not @@ -364,6 +439,12 @@ jobs: if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} + # G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open + # code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's + # quality-gate job). contents: read keeps checkout working. + permissions: + contents: read + security-events: read steps: - uses: actions/checkout@v7 with: @@ -385,6 +466,29 @@ jobs: - name: ESLint (baseline congelado — warning novo = vermelho) # lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy. run: npm run lint:json -- --max-warnings 0 + # ── G0 (trilho .50): motor de ratchet também no trilho B ───────────────────── + # This job just wrote .artifacts/eslint-results.json — collect-metrics prefers + # that file, so the ratchet engine lands here at ZERO extra ESLint cost (one + # inventory, two consumers; same reason ci.yml chains lint → quality-gate). + # The coverage-report artifact does not exist on this rail, so both ratchet + # invocations run --allow-missing: coverage.* metrics skip gracefully while + # the deterministic ones (eslint / openapi-coverage / i18n-ui) stay BLOCKING. + # Coverage authority remains on the main rail (ci.yml test-coverage → quality-gate). + - run: npm run quality:collect + - name: Ratchet check (blocking) + run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md + - name: Require-tighten (blocking) + run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten + # CodeQL alerts ratchet — same semantics as ci.yml quality-gate: exits 1 ONLY + # on a real regression (open alerts > baseline in quality-baseline.json); + # a measurement failure (gh/auth/api) self-skips with exit 0. + - name: CodeQL alerts ratchet (blocking) + run: npm run check:codeql-ratchet + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append ratchet summary + if: always() + run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" || true # Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só # explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come diff --git a/tests/unit/quality-rail-gate-membership.test.ts b/tests/unit/quality-rail-gate-membership.test.ts new file mode 100644 index 0000000000..a5fff674b1 --- /dev/null +++ b/tests/unit/quality-rail-gate-membership.test.ts @@ -0,0 +1,102 @@ +/** + * G0 guard — the PR→release/** rail (quality.yml) must keep the ratchet + security + * gates that used to run only on the PR→main rail (ci.yml). + * + * Why: the god-file refactor (trilho 3.8.50→3.9.0) happens almost entirely in + * PRs targeting release/**. Before G0, those PRs skipped the CodeQL ratchet, the + * quality-ratchet engine, and every security scanner — exactly the net the + * refactor needs. Five of the 13 root causes reconciled on 2026-07-24 were real + * production regressions shipped through green per-PR CI; the gates below are the + * part of that lesson that can be enforced by machine. + * + * This test pins MEMBERSHIP, not order: removing a gate from quality.yml (or + * moving it to a job that does not run on code PRs) must be a conscious decision + * that edits this file in the same PR, with the reason in the diff. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const workflow = fs.readFileSync(path.join(repoRoot, ".github/workflows/quality.yml"), "utf-8"); + +/** Extract one top-level job block from the workflow (2-space indented key). */ +function jobBlock(job: string): string { + const m = new RegExp(`^ {2}${job}:\\s*$`, "m").exec(workflow); + assert.ok(m, `quality.yml must define job "${job}"`); + const rest = workflow.slice(m.index + m[0].length); + const next = /^ {2}[A-Za-z0-9_-]+:\s*$/m.exec(rest); + return next ? rest.slice(0, next.index) : rest; +} + +test("lint-guard carries the quality-ratchet engine (collect → ratchet → require-tighten → codeql)", () => { + const block = jobBlock("lint-guard"); + for (const needle of [ + "npm run quality:collect", + "check-quality-ratchet.mjs --allow-missing", + "--require-tighten", + "npm run check:codeql-ratchet", + ]) { + assert.ok(block.includes(needle), `lint-guard must run "${needle}"`); + } + // The CodeQL ratchet reads open code-scanning alerts; without this permission + // the API call 403s and the gate silently self-skips forever. + assert.match(block, /security-events:\s*read/, "lint-guard needs security-events: read"); +}); + +test("fast-gates carries the deterministic ratchets and security scanners from the main rail", () => { + const block = jobBlock("fast-gates"); + for (const needle of [ + "npm run check:cycles", + "npm run check:lockfile", + "npm run check:duplication", + "npm run check:dead-code", + "npm run check:type-coverage", + "npm run check:compression-budget", + "npm run check:secrets -- --ratchet", + "npm run check:vuln-ratchet -- --ratchet", + "npm run check:workflows -- --ratchet", + "npm run check:openapi-breaking -- --ratchet", + ]) { + assert.ok(block.includes(needle), `fast-gates must run "${needle}"`); + } + assert.ok( + block.includes( + "BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}" + ), + "oasdiff must receive the origin-prefixed base ref fetched by actions/checkout" + ); + for (const version of [ + "GITLEAKS_VERSION=v", + "OSV_SCANNER_VERSION=v", + "ACTIONLINT_VERSION=v", + "ZIZMOR_VERSION=", + "OASDIFF_VERSION=v", + ]) { + assert.ok(block.includes(version), `fast-gates must pin ${version.split("=")[0]}`); + } + assert.doesNotMatch( + block, + /download-actionlint\.bash\) latest/, + "actionlint must not use latest" + ); +}); + +test("the complexity ratchet stays on the release rail (G0's written validation criterion)", () => { + assert.ok( + jobBlock("fast-gates").includes("npm run check:complexity-ratchets"), + "a complexity regression in a PR→release/** must be blocked by fast-gates" + ); +}); + +test("the new gates run on jobs that stay pinned to hosted runners", () => { + // Complements tests/unit/vps-runner-variable-scope.test.ts: neither gate job + // may pick up a USE_VPS_RUNNER switch (setup-node measured 20m06s on .113 vs 16s hosted). + for (const job of ["lint-guard", "fast-gates"]) { + const runsOn = /^ {4}runs-on:\s*(.+)$/m.exec(jobBlock(job)); + assert.ok(runsOn, `${job} must declare runs-on`); + assert.equal(runsOn[1].trim(), "ubuntu-latest", `${job} must stay pinned to ubuntu-latest`); + } +});