fix(release): post-merge quality gates to main for v3.8.26 (#3964)

Cherry-picks #3961 + #3962 from release/v3.8.26 to main (parity before tagging).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-16 02:33:19 -03:00
committed by GitHub
parent 81a37b67ed
commit 4d21044ba5
9 changed files with 642 additions and 64 deletions

View File

@@ -16,13 +16,23 @@
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
// e sai 0.
//
// Esta versão é ADVISORY: sempre sai 0 independente do resultado.
// O ratchet (direction:down) é gerenciado pelo motor de quality-baseline.json na
// integração com o CI (Task 12 INT).
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
//
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
// medida na MESMA métrica do baseline bloqueia.
//
// Uso:
// node scripts/check/check-bundle-size.mjs
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
@@ -31,6 +41,8 @@ import { pathToFileURL, fileURLToPath } from "node:url";
const ROOT = process.cwd();
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const RATCHET = process.argv.includes("--ratchet");
/**
* Tenta rodar size-limit --json e retorna o array de resultados.
@@ -110,9 +122,7 @@ export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
let found = 0;
const entries = [];
for (const entry of config) {
const entryPath = path.isAbsolute(entry.path)
? entry.path
: path.join(cwd, entry.path);
const entryPath = path.isAbsolute(entry.path) ? entry.path : path.join(cwd, entry.path);
if (!fs.existsSync(entryPath)) {
entries.push({ name: entry.name, path: entry.path, size: null });
continue;
@@ -125,6 +135,83 @@ export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
return { total, entries, allMissing: found === 0 };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia o total MEDIDO de bytes contra o baseline.
* Direction: down (o tamanho só pode CAIR — maior = regressão).
*
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
* @param {number} baseline - Total congelado em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateBundleSizeRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.bundleSize.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.bundleSize;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
*
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
*/
function applyRatchet(totalBytes) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
if (baselineValue === null) {
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
process.exitCode = 0;
return;
}
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
if (regressed) {
console.error(
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
);
process.exitCode = 1;
return;
}
console.log(
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
);
process.exitCode = 0;
}
function main() {
// Step 1: tenta com size-limit + plugin instalado
let totalBytes = null;
@@ -140,10 +227,13 @@ function main() {
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
if (allMissing) {
// Step 3: skip gracioso — entradas não existem (build necessário)
// Step 3: skip gracioso — entradas não existem (build necessário).
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
console.log("bundleSize=SKIP reason=no-build");
if (process.env.CI) {
console.log("::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)");
console.log(
"::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"
);
}
return;
}
@@ -158,7 +248,8 @@ function main() {
}
}
} else {
// Erro inesperado — reporta mas não falha (advisory)
// Erro inesperado — reporta mas não falha (advisory).
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
console.log("bundleSize=SKIP reason=size-limit-error");
return;
@@ -167,7 +258,28 @@ function main() {
const kb = (totalBytes / 1024).toFixed(2);
console.log(`bundleSize=${totalBytes}`);
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
if (mode !== "size-limit") {
if (RATCHET) {
console.log(
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
);
process.exitCode = 0;
return;
}
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
return;
}
if (!RATCHET) {
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
}
applyRatchet(totalBytes);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -10,13 +10,18 @@
// secretFindings=N — número de findings do gitleaks
// secretFindings=SKIP reason=binary-absent — gitleaks não está no PATH
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.18 INT).
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
// lê metrics.secretFindings.value de config/quality/quality-baseline.json, compara
// a contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente, nenhum
// dir de fonte) SAI 0 mesmo com --ratchet — falta de infraestrutura nunca bloqueia,
// só uma regressão medida bloqueia.
//
// Uso:
// node scripts/check/check-secrets.mjs
// node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks
// node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks
// node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-secrets.mjs --ratchet # falha (exit 1) numa regressão
import fs from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
@@ -26,7 +31,9 @@ import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const RATCHET = process.argv.includes("--ratchet");
const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// Source directories to scan for secrets. We deliberately scope to the
// production/source trees instead of scanning the whole working dir:
@@ -105,6 +112,46 @@ export function parseGitleaksJson(gitleaksJson) {
return { findingCount, byRule, byFile };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de secrets contra o baseline.
* Direction: down (a contagem só pode CAIR — mais secrets = regressão).
*
* @param {number} current - Contagem de findings medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateSecretsRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.secretFindings.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineSecretsValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.secretFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
@@ -156,7 +203,7 @@ function main() {
process.stderr.write(
"[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" +
"[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" +
"[check-secrets] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
"[check-secrets] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
);
}
process.exitCode = 0;
@@ -271,12 +318,57 @@ function main() {
} else {
process.stderr.write("[check-secrets] Nenhum finding detectado.\n");
}
process.stderr.write(
"[check-secrets] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(findingCount);
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} findingCount - Contagem MEDIDA (medição bem-sucedida).
*/
function applyRatchet(findingCount) {
if (!RATCHET) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
);
}
process.exitCode = 0;
return;
}
const baselineValue = readBaselineSecretsValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] baseline ausente (metrics.secretFindings) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed } = evaluateSecretsRatchet(findingCount, baselineValue);
if (regressed) {
process.stderr.write(
`[check-secrets] REGRESSÃO — ${findingCount} secret findings > baseline ${baselineValue}\n` +
" → Remova o novo secret (ou allowliste em .gitleaks.toml se for falso-positivo legítimo),\n" +
" depois re-baseline metrics.secretFindings em config/quality/quality-baseline.json.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[check-secrets] OK — sem regressão (${findingCount} findings, baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}

View File

@@ -20,12 +20,22 @@
// zizmorFindings=<n> — findings from zizmor alone
//
// Exit codes:
// 0 — SKIP (binary absent) or all tools passed (0 findings each)
// 1 — one or more findings (gate failure; advisory mode = always 0; see --advisory)
// 0 — SKIP (binary absent) or all tools passed / no ratchet regression
// 1 — gate failure: --strict + any finding, OR --ratchet + zizmorFindings
// regression (measured > baseline)
//
// Ratchet mode (--ratchet): reads metrics.zizmorFindings.value from
// config/quality/quality-baseline.json and exits 1 IF — AND ONLY IF — the MEASURED
// zizmor count is GREATER than the baseline (real regression, direction:down).
// ONLY zizmorFindings is ratcheted; actionlint findings are REPORTED but NOT
// ratcheted (use the separate --strict all-or-nothing flag for those). Any graceful
// SKIP (binary absent, no workflows) exits 0 even with --ratchet — missing infra
// never blocks, only a measured regression does.
//
// Usage:
// node scripts/check/check-workflows.mjs # advisory (exit 0 always)
// node scripts/check/check-workflows.mjs --strict # fail on any finding
// node scripts/check/check-workflows.mjs --ratchet # fail on zizmor regression
// node scripts/check/check-workflows.mjs --quiet # suppress progress logs
import { execFileSync, spawnSync } from "node:child_process";
@@ -36,8 +46,10 @@ import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
const ZIZMOR_CONFIG = path.join(ROOT, ".zizmor.yml");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const STRICT = process.argv.includes("--strict");
const RATCHET = process.argv.includes("--ratchet");
const QUIET = process.argv.includes("--quiet");
// ---------------------------------------------------------------------------
@@ -129,6 +141,46 @@ export function parseZizmorOutput(stdout) {
}
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down, zizmorFindings only) — exported for tests
// ---------------------------------------------------------------------------
/**
* Evaluates the MEASURED zizmor finding count against the baseline.
* Direction: down (the count may only DROP — more findings = regression).
*
* @param {number} current - Measured zizmor finding count.
* @param {number} baseline - Frozen count in quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateZizmorRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Reads metrics.zizmorFindings.value from quality-baseline.json.
* Returns null when the file or metric is missing (no baseline → no ratchet
* possible; the caller treats this as a graceful SKIP, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineZizmorValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.zizmorFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Runner helpers
// ---------------------------------------------------------------------------
@@ -216,7 +268,7 @@ function main() {
" Install them to enable workflow linting and security audit:\n" +
" • actionlint: https://github.com/rhysd/actionlint\n" +
" • zizmor: https://github.com/woodruffw/zizmor\n" +
" This gate is advisory — the build is not blocked."
" Graceful SKIP — exits 0 even with --ratchet (missing binaries never block)."
);
process.stdout.write("workflowFindings=SKIP\n");
process.exit(0);
@@ -226,7 +278,9 @@ function main() {
if (workflowFiles.length === 0) {
if (!QUIET) {
console.log(`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`);
console.log(
`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`
);
}
process.stdout.write("workflowFindings=0\nactionlintFindings=0\nzizmorFindings=0\n");
process.exit(0);
@@ -285,16 +339,56 @@ function main() {
process.stdout.write(`zizmorFindings=${zizmorCount}\n`);
if (STRICT && total > 0) {
console.error(
`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`
);
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
process.exit(1);
}
// ── ratchet (zizmorFindings only, direction:down) ──────────────────────────
// We can only ratchet zizmor when zizmor actually RAN (binary present). If
// zizmor is absent we have no comparable measurement → graceful SKIP (exit 0):
// a missing binary must never block, only a measured regression does.
if (RATCHET) {
if (!hasZizmor) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: zizmor absent — SKIP (no measurement, never blocks).\n"
);
}
process.exit(0);
}
const baselineValue = readBaselineZizmorValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: baseline absent (metrics.zizmorFindings) — SKIP, exit 0.\n"
);
}
process.exit(0);
}
const { regressed } = evaluateZizmorRatchet(zizmorCount, baselineValue);
if (regressed) {
console.error(
`\n[check-workflows] REGRESSION — ${zizmorCount} zizmor finding(s) > baseline ${baselineValue}.\n` +
" → Fix the new workflow finding(s), or re-baseline metrics.zizmorFindings in\n" +
" config/quality/quality-baseline.json if the rise is a legitimate, justified drift.\n" +
" (actionlint findings are reported, not ratcheted — use --strict for those.)"
);
process.exit(1);
}
if (!QUIET) {
process.stderr.write(
`[check-workflows] --ratchet OK — ${zizmorCount} zizmor finding(s), baseline ${baselineValue} (no regression).\n`
);
}
process.exit(0);
}
if (total > 0 && !QUIET) {
console.log(
`[check-workflows] ADVISORY — ${total} finding(s) detected. ` +
"Pass --strict to block the gate."
"Pass --strict to block on any finding, or --ratchet to block on a zizmor regression."
);
}