From 138f84b93e1d5b321d9225cbf1c91c15fe7361da Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:38:25 -0300 Subject: [PATCH] =?UTF-8?q?chore(ci):=20harden=20release=20flow=20?= =?UTF-8?q?=E2=80=94=20ratchet=20decoupling,=20fast-path=20drift=20gates,?= =?UTF-8?q?=20build-scope=20guard,=20heap=20default=20(#5054)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements improvements 1-4 from the v3.8.36 release benchmark (_tasks/release-bench/v3.8.36/PLANO-MELHORIA.md): 1. Quality Ratchet decoupled from flaky coverage (ci.yml): the shard→coverage→ratchet chain meant a single flaky Coverage Shard SKIPPED the whole Quality Ratchet on the release PR (v3.8.36 #4854), so cycle drift only surfaced post-merge in #5029. The job now runs on !cancelled(); coverage download is continue-on-error and the ratchet runs --allow-missing, so the DETERMINISTIC gates (eslint/complexity/cognitive/duplication/ codeql) stay blocking even when coverage is unavailable. 2. Fast-path drift gates (quality.yml PR→release): added check:complexity, check:cognitive-complexity, and a new lightweight check:pack-policy (pack-artifact unexpected-files check WITHOUT a build, via --policy-only) so drift + stray-tarball-file regressions are caught/rebaselined PER-PR instead of cascading onto the release PR. 3. Build heap default 4096→8192 MB (build-next-isolated.mjs): the clean graph peaks ~3.9 GB and brushed the old 4 GB ceiling; 8 GB gives headroom. Comment notes heap is NOT the fix for a poisoned scope (run check:build-scope instead). 4. check:build-scope gate (new): fails if .ts/.tsx/.js/.jsx files in the tsconfig scope exceed a threshold — catches worktrees/cruft leaking into the build scope (the v3.8.36 OOM root cause: 355,215 vs 4,547 files) BEFORE it detonates next build. Wired into the fast-path. --- .github/workflows/ci.yml | 21 +++++- .github/workflows/quality.yml | 12 ++++ package.json | 2 + scripts/build/build-next-isolated.mjs | 7 +- scripts/build/validate-pack-artifact.ts | 17 +++-- scripts/check/check-build-scope.mjs | 95 +++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 scripts/check/check-build-scope.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5181ebc246..06dcd7f9e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,15 @@ jobs: name: Quality Ratchet runs-on: ubuntu-latest needs: test-coverage - if: ${{ !cancelled() && needs.test-coverage.result == 'success' }} + # 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. + if: ${{ !cancelled() }} # security-events: read lets the CodeQL ratchet read open code-scanning alerts # via `gh api .../code-scanning/alerts`. contents: read keeps checkout working. permissions: @@ -156,7 +164,12 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry # 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/ @@ -164,8 +177,10 @@ jobs: # 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 --summary .artifacts/quality-ratchet.md + 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 @@ -174,7 +189,7 @@ jobs: # 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 + 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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1c3fdc5e36..4242fb6648 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -53,6 +53,18 @@ jobs: - run: npm run check:test-discovery - run: npm run check:test-runner-api - run: npm run check:any-budget:t11 + # Build-scope guard: fails if worktrees/cruft leak into the tsconfig include + # scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031. + - run: npm run check:build-scope + # Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file + # leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on + # the release PR's heavy Package Artifact job. + - run: npm run check:pack-policy + # Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so + # cycle drift is rebaselined PER-PR instead of cascading onto the release PR's + # Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge). + - run: npm run check:complexity + - run: npm run check:cognitive-complexity - name: Typecheck (core) run: npm run typecheck:core # TIA: build the impact map at runtime (gitignored, ~21MB) and run only the diff --git a/package.json b/package.json index 4eda3c224e..2d31648785 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs", "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", + "check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only", "check:cli-i18n": "node scripts/check/check-cli-i18n.mjs", "check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs", "check:openapi-security-tiers": "node scripts/check/check-openapi-security-tiers.mjs", @@ -137,6 +138,7 @@ "check:tracked-artifacts": "node scripts/check/check-tracked-artifacts.mjs", "check:test-masking": "node scripts/check/check-test-masking.mjs", "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", + "check:build-scope": "node scripts/check/check-build-scope.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", "check:migration-numbering": "node scripts/check/check-migration-numbering.mjs", "check:public-creds": "node scripts/check/check-public-creds.mjs", diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 323b428409..58a8162bac 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -120,7 +120,12 @@ export function resolveNextBuildEnv(baseEnv = process.env) { // was left unprotected. Respect an existing --max-old-space-size (Docker already // sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override. if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) { - const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 4096; + // Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack + // production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives + // headroom without risk. NOTE: heap size does NOT fix a poisoned scope — if the build + // OOMs/livelocks far above this, check for worktrees/cruft leaking into the tsconfig + // scope (run `npm run check:build-scope`), not for "more heap". See incident 2026-06-25. + const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 8192; env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim(); } diff --git a/scripts/build/validate-pack-artifact.ts b/scripts/build/validate-pack-artifact.ts index bd92a48ecd..94b09fe166 100644 --- a/scripts/build/validate-pack-artifact.ts +++ b/scripts/build/validate-pack-artifact.ts @@ -74,18 +74,25 @@ function formatBytes(bytes: number): string { return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; } +// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the +// required-runtime-files check (which needs the built dist/), running ONLY the +// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are +// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches +// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release), +// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029). +const POLICY_ONLY = process.argv.includes("--policy-only"); + try { - ensureAppStagingReady(); + if (!POLICY_ONLY) ensureAppStagingReady(); const packReport = runPackDryRun(); const artifactPaths: string[] = packReport.files.map((file: any) => file.path); const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, { exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS, prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES, }); - const missingRequiredPaths: string[] = findMissingArtifactPaths( - artifactPaths, - PACK_ARTIFACT_REQUIRED_PATHS - ); + const missingRequiredPaths: string[] = POLICY_ONLY + ? [] + : findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS); console.log("📦 npm pack artifact summary"); console.log(` File: ${packReport.filename}`); diff --git a/scripts/check/check-build-scope.mjs b/scripts/check/check-build-scope.mjs new file mode 100644 index 0000000000..5880f84581 --- /dev/null +++ b/scripts/check/check-build-scope.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// check-build-scope.mjs — guards against worktrees / cruft leaking into the +// TypeScript build scope and poisoning `next build`. +// +// Root cause of the 2026-06-25 build OOM/GC-livelock incident: `tsconfig.json` +// uses `include: ["**/*.ts","**/*.tsx","**/*.js","**/*.jsx"]` (recursive glob), +// and 69 git worktrees under `.claude/worktrees/` were NOT in `exclude` — so the +// TS scope ballooned to 355,215 files (vs 4,547 real source files) and `next build` +// processed ~70x the codebase, OOMing even at a 64 GB heap. The CI built fine +// because its checkout is clean. +// +// This gate counts the .ts/.tsx/.js/.jsx files that tsconfig's include would match +// (respecting its top-level exclude dirs) and FAILS if the count exceeds a +// threshold — catching a leak BEFORE it detonates the build. Heap size is NOT the +// fix for an over-large scope; a clean scope is. +// +// Usage: node scripts/check/check-build-scope.mjs [--max N] [--json] + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const args = process.argv.slice(2); +const MAX = Number(args[args.indexOf("--max") + 1]) || 12000; +const JSON_OUT = args.includes("--json"); + +const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]); + +// Read tsconfig.json exclude (the source of truth for what's out of scope). +const tsconfigPath = path.join(ROOT, "tsconfig.json"); +let exclude = []; +try { + exclude = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")).exclude || []; +} catch { + console.error("[build-scope] could not read tsconfig.json exclude — aborting"); + process.exit(2); +} +// Always skip VCS + the exclude dirs (normalise to bare top-level names). +const SKIP_DIRS = new Set([".git", ...exclude.map((e) => e.replace(/^\.\//, "").replace(/\/.*$/, ""))]); + +let count = 0; +const byTop = {}; +function walk(dir, top) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.isSymbolicLink()) continue; // don't follow symlinks (e.g. node_modules) + const full = path.join(dir, e.name); + if (e.isDirectory()) { + walk(full, top); + } else if (EXT.has(path.extname(e.name))) { + count++; + byTop[top] = (byTop[top] || 0) + 1; + } + } +} + +for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) { + if (!e.isDirectory()) { + if (EXT.has(path.extname(e.name))) { + count++; + byTop["(root)"] = (byTop["(root)"] || 0) + 1; + } + continue; + } + if (SKIP_DIRS.has(e.name)) continue; + walk(path.join(ROOT, e.name), e.name); +} + +if (JSON_OUT) { + console.log(JSON.stringify({ count, max: MAX, byTop }, null, 2)); +} else { + console.log(`[build-scope] ${count} .ts/.tsx/.js/.jsx files in tsconfig scope (max ${MAX})`); + const top = Object.entries(byTop) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + for (const [d, n] of top) console.log(` ${String(n).padStart(7)} ${d}`); +} + +if (count > MAX) { + console.error( + `\n❌ [build-scope] scope of ${count} files exceeds ${MAX} — something is leaking into the\n` + + ` tsconfig include scope (worktree, vendored copy, or build output). This poisons\n` + + ` \`next build\` (OOM/GC-livelock). Add the offending dir to tsconfig.json "exclude"\n` + + ` (and .dockerignore). Worktrees MUST live under .claude/worktrees/ (already excluded).\n` + + ` Heap size does NOT fix this — a clean scope does. See incident 2026-06-25.` + ); + process.exit(1); +} +console.log("✅ [build-scope] OK — no leak into the build scope.");