fix(ci): aggregate fast-gates into non-fail-fast loop so one red gate no longer masks later gates (#8542)

Closes #8542
Refs: base-red #9737
fix/8542-ci-base-red-compounds-becaus
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 11:25:24 -03:00
committed by GitHub
parent c7e20e95de
commit 08d1809b6e
4 changed files with 118 additions and 91 deletions

View File

@@ -151,63 +151,6 @@ jobs:
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 }}-
- run: npm run check:provider-consistency
- run: npm run check:fetch-targets
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
- run: npm run check:deps
# #8522: --base-ref mode for PR events — compare against max(frozen, base) so
# inherited drift (base already over frozen cap) doesn't red an innocent PR.
# workflow_dispatch (no PR base) falls back to absolute comparison.
- name: File-size ratchet (base-relative on PR)
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$PR_BASE_SHA" ]; then
npm run check:file-size -- --base-ref "$PR_BASE_SHA"
else
npm run check:file-size
fi
- 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:test-runner-api
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
- run: npm run check:mutation-test-coverage
- 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: 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
@@ -251,30 +194,63 @@ jobs:
"$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)
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
# step. Each gate runs in a loop with ::group::; failures are collected and
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
# not abort the job and mask every later gate. Release-added gates are folded
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
- name: Quality gates (all, non-fail-fast)
env:
# #8522: base-relative file-size mode on PR events — inherited drift (base
# already over frozen cap) must not red an innocent PR. Unset on
# workflow_dispatch (no PR base) → absolute comparison.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
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
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
- name: Typecheck (dashboard)
run: npm run check:dashboard-typecheck
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
- name: Typecheck (open-sse)
run: npm run check:open-sse-typecheck
run: |
set -uo pipefail
gates=(
provider-consistency fetch-targets deps file-size error-helper
migration-numbering public-creds db-rules known-symbols
route-guard-membership test-discovery test-runner-api
mutation-test-coverage any-budget:t11 build-scope pack-policy
complexity-ratchets
cycles lockfile duplication dead-code type-coverage compression-budget
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking
)
failed=()
for g in "${gates[@]}"; do
echo "::group::check:$g"
# #8522: file-size is base-relative on PR events (compare against
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
# workflow_dispatch (no PR base) falls back to absolute comparison.
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
else
npm run "check:$g" || failed+=("$g")
fi
echo "::endgroup::"
done
for g in "${ratchet_gates[@]}"; do
echo "::group::check:$g (ratchet)"
npm run "check:$g" -- --ratchet || failed+=("$g")
echo "::endgroup::"
done
echo "::group::typecheck:core"
npm run typecheck:core || failed+=("typecheck:core")
echo "::endgroup::"
echo "::group::check:dashboard-typecheck"
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
echo "::endgroup::"
if (( ${#failed[@]} )); then
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
exit 1
fi
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x

View File

@@ -0,0 +1 @@
- fix(ci): aggregate all fast-gates into non-fail-fast loop so one red gate no longer masks later gates (#8542)

View File

@@ -48,19 +48,15 @@ test("lint-guard carries the quality-ratchet engine (collect → ratchet → req
test("fast-gates carries the deterministic ratchets and security scanners from the main rail", () => {
const block = jobBlock("fast-gates");
// #8542: all gates run inside a single aggregation step's bash loop.
// Check that the gate names appear in the arrays or the loop body.
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",
"cycles lockfile duplication dead-code type-coverage compression-budget",
"secrets vuln-ratchet workflows openapi-breaking",
"typecheck:core",
"check:dashboard-typecheck",
]) {
assert.ok(block.includes(needle), `fast-gates must run "${needle}"`);
assert.ok(block.includes(needle), `fast-gates must contain "${needle}"`);
}
assert.ok(
block.includes(
@@ -86,7 +82,7 @@ test("fast-gates carries the deterministic ratchets and security scanners from t
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"),
jobBlock("fast-gates").includes("complexity-ratchets"),
"a complexity regression in a PR→release/** must be blocked by fast-gates"
);
});

View File

@@ -0,0 +1,54 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { parse } from "yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
const WORKFLOW = resolve(repoRoot, ".github/workflows/quality.yml");
function loadWorkflow(): any {
return parse(readFileSync(WORKFLOW, "utf8"));
}
function invokesGate(run: string): boolean {
if (!run) return false;
return /npm run (check:|typecheck:)/.test(run) || /npm run "check/.test(run) || /npm run \\"check/.test(run);
}
function stepCanFail(step: any): boolean {
return step?.["continue-on-error"] !== true;
}
test("repro #8542: fast-gates must not fail-fast into a later gate", () => {
const wf = loadWorkflow();
const job = wf.jobs?.["fast-gates"];
assert.ok(job, "fast-gates job must exist");
const steps: any[] = job.steps ?? [];
assert.ok(steps.length >= 5, `fast-gates must have >=5 steps, got ${steps.length}`);
const gateSteps = steps.map((s, i) => ({ s, i })).filter(({ s }) => invokesGate(s?.run ?? ""));
assert.ok(gateSteps.length >= 1, `expected >=1 gate step, got ${gateSteps.length}`);
const maskedPairs: string[] = [];
for (let a = 0; a < gateSteps.length; a++) {
const stepA = gateSteps[a];
if (!stepCanFail(stepA.s)) continue;
for (let b = a + 1; b < gateSteps.length; b++) {
const stepB = gateSteps[b];
maskedPairs.push(
`step ${stepA.i + 1} (${stepA.s.name ?? String(stepA.s.run).split("\n")[0].slice(0, 40)})` +
` can fail and masks step ${stepB.i + 1} (${stepB.s.name ?? String(stepB.s.run).split("\n")[0].slice(0, 40)})`
);
}
}
assert.deepEqual(
maskedPairs,
[],
`FAIL-FAST MASKING PRESENT (${maskedPairs.length} pair(s)): a failing gate step aborts the job and every later gate reports "skipped". This is the #8542 mechanism.\n` +
maskedPairs.slice(0, 12).join("\n") +
(maskedPairs.length > 12 ? `\n... (+${maskedPairs.length - 12} more)` : "")
);
});