chore(ci): harden release flow — ratchet decoupling, fast-path drift gates, build-scope guard, heap default (#5054)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-25 19:38:25 -03:00
committed by GitHub
parent 2f517a8692
commit 138f84b93e
6 changed files with 145 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
}

View File

@@ -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}`);

View File

@@ -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.");