From e2522e2e0aa6cdb7bb97dc3f13cd9159eb05ca8d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:46:15 -0300 Subject: [PATCH] chore(quality): release-green pre-flight validator + nightly signal (C+D) (#4622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C — scripts/quality/validate-release-green.mjs (npm run check:release-green): reproduces the release-equivalent validation (typecheck, eslint, db-rules, public-creds, full unit, vitest, ratchets, optional --with-build package-artifact) against the current working tree and classifies each red as HARD (real defect, exit 1) vs DRIFT (ratchet — reported, never affects exit / never blocks). Pure helpers exported + orchestration behind a direct-run guard; unit-tested. D — .github/workflows/nightly-release-green.yml: runs C on the active release branch nightly (and on workflow_dispatch) and opens/updates a single tracking issue on HARD failures. Never a required check, never touches a contributor PR. Closes the gap where the full gate (ci.yml) only ran on the release PR, so reds accrued silently on release/** and surfaced in 40-min layers at release time. Non-blocking by construction; drift is the maintainer's to rebaseline at release. Co-authored-by: Diego Rodrigues de Sa e Souza --- .github/workflows/nightly-release-green.yml | 137 +++++++++++ CHANGELOG.md | 4 + package.json | 1 + scripts/quality/validate-release-green.mjs | 250 ++++++++++++++++++++ tests/unit/validate-release-green.test.ts | 74 ++++++ 5 files changed, 466 insertions(+) create mode 100644 .github/workflows/nightly-release-green.yml create mode 100644 scripts/quality/validate-release-green.mjs create mode 100644 tests/unit/validate-release-green.test.ts diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml new file mode 100644 index 0000000000..c1d5128a74 --- /dev/null +++ b/.github/workflows/nightly-release-green.yml @@ -0,0 +1,137 @@ +name: Nightly Release-Green + +# Solution D — continuous, NON-BLOCKING drift signal for the active release branch. +# +# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds +# accrue silently on release/** and explode — in layers — at release time. This +# nightly reproduces the release-equivalent validation on the active release branch +# HEAD and, when there are HARD failures, opens/updates a single tracking issue. +# +# It is NOT a required status check and never touches a contributor PR — it only +# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is +# expected mid-cycle and is reported but never raises the alarm on its own; only +# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds / +# package-artifact) flip the issue open. + +on: + schedule: + - cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies + workflow_dispatch: + inputs: + branch: + description: "Release branch to validate (default: highest release/vX.Y.Z)" + required: false + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: nightly-release-green + cancel-in-progress: true + +jobs: + release-green: + name: Validate active release branch + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-nightly-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve active release branch + id: branch + env: + INPUT_BRANCH: ${{ github.event.inputs.branch }} + run: | + set -euo pipefail + if [ -n "${INPUT_BRANCH:-}" ]; then + TARGET="$INPUT_BRANCH" + else + # highest release/vX.Y.Z by semver among remote branches + TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \ + | sed 's#origin/##' \ + | sort -t/ -k2 -V \ + | tail -1) + fi + if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi + # Strict format guard — reject anything that isn't release/vX.Y.Z (blocks + # ref/command injection via the workflow_dispatch input). + if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Refusing non-canonical branch name: $TARGET"; exit 1 + fi + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "Active release branch: $TARGET" + + - name: Checkout the release branch + env: + TARGET: ${{ steps.branch.outputs.target }} + run: | + set -euo pipefail + git checkout "$TARGET" + git log -1 --oneline + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - uses: ./.github/actions/npm-ci-retry + + - name: Release-green validation (full) + id: validate + run: | + set +e + node scripts/quality/validate-release-green.mjs --json --with-build \ + 1> release-green.json 2> release-green.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + echo "------- report -------" + cat release-green.log + + - name: Open / update tracking issue on HARD failure + if: steps.validate.outputs.exit != '0' + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.branch.outputs.target }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TITLE="🔴 Release branch not green: ${TARGET}" + { + echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`." + echo "These are real defects that would block the release PR — fix them in the" + echo "originating PR branch (via co-authorship), not by demanding it from contributors." + echo "" + echo "**Run:** ${RUN_URL}" + echo "" + echo '```' + sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log + echo '```' + echo "" + echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._" + } > issue-body.md + + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md + echo "Updated existing issue #$EXISTING" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md + fi + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-green-report + path: | + release-green.json + release-green.log + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 07185a19a3..de45e0007b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development — bullets added per PR; finalized at release._ +### 📝 Maintenance + +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. (thanks @diegosouzapw) + --- ## [3.8.33] — 2026-06-21 diff --git a/package.json b/package.json index f9023fed42..967d12a2d2 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", "check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs", + "check:release-green": "node scripts/quality/validate-release-green.mjs", "check:type-coverage": "node scripts/check/check-type-coverage.mjs", "check:lockfile": "node scripts/check/check-lockfile.mjs", "check:bundle-size": "node scripts/check/check-bundle-size.mjs", diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs new file mode 100644 index 0000000000..477cacea4c --- /dev/null +++ b/scripts/quality/validate-release-green.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +// scripts/quality/validate-release-green.mjs +// +// "Release-green" pre-flight validator (Solution C). +// +// WHY: the full gate (ci.yml — unit shards, vitest, ratchets, package-artifact) +// runs ONLY on the release PR (PR → main). PRs into release/** only get the +// fast-gates (quality.yml: TIA-impacted tests + typecheck + lint checks). So +// reds accumulate silently on the release branch and explode — in layers — at +// release time. This script reproduces the release-equivalent validation against +// the CURRENT working tree so the maintainer (or the nightly, Solution D) can see +// the real state of the release branch at any time. +// +// DESIGN — never blocking to contributors: +// • HARD checks (typecheck, lint errors, unit, vitest, db-rules, public-creds, +// optionally package-artifact) → a failure here is a real defect; exit 1. +// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size) → ratchet +// drift accrued across the cycle is NOT a contributor's fault; it is reported +// and rebaselined by the maintainer at release. Drift NEVER changes the exit +// code, so wiring this as a check can never block anyone on drift. +// +// This script DIAGNOSES + REPORTS only (no auto-fix). The fix-to-green +// orchestration lives in the (future) /green-prs + review-prs flows that call it. +// +// Usage: +// node scripts/quality/validate-release-green.mjs [--json] [--with-build] [--quick] +// --json emit machine-readable JSON to stdout (report goes to stderr) +// --with-build also run check:pack-artifact (needs a dist/ build — slow) +// --quick skip the slow unit + vitest suites (drift + typecheck + lint only) + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + +// ─── Pure helpers (exported for tests) ────────────────────────────────────── + +/** Read the committed ratchet baseline value for a metric (null if unknown). */ +export function baselineValue(metric, root = ROOT) { + try { + const raw = JSON.parse(readFileSync(join(root, "config/quality/quality-baseline.json"), "utf8")); + const metrics = raw.metrics || raw; + const v = metrics?.[metric]?.value; + return typeof v === "number" ? v : null; + } catch { + return null; + } +} + +/** Best-effort "first meaningful failure line" from captured command output. */ +export function firstFailureLine(out) { + const lines = String(out || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const hit = lines.find((l) => /✖|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l)); + return (hit || lines[lines.length - 1] || "failed").slice(0, 200); +} + +/** Sum {errorCount,warningCount} across an eslint --format json result array. */ +export function eslintCounts(parsed) { + let errors = 0; + let warnings = 0; + for (const f of parsed || []) { + errors += f.errorCount || 0; + warnings += f.warningCount || 0; + } + return { errors, warnings }; +} + +/** Parse the eslint JSON array out of mixed stdout (tolerates a leading banner). */ +export function parseEslintJson(out) { + const start = String(out || "").indexOf("["); + if (start < 0) return null; + try { + return JSON.parse(String(out).slice(start)); + } catch { + return null; + } +} + +/** Pull the cognitive-complexity violation count from the gate's output. */ +export function parseCognitiveCount(out) { + const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + return m ? Number(m[1]) : null; +} + +/** + * Drift verdict for a ratchet: a metric that grew past its committed baseline is + * "drift" (reported, never blocking). `direction:"down"` metrics (warnings, + * complexity, file-size counts) regress when current > baseline. + */ +export function isDrift(current, baseline) { + if (typeof current !== "number" || typeof baseline !== "number") return false; + return current > baseline; +} + +/** releaseGreen iff there are zero failing HARD checks (drift never blocks). */ +export function computeVerdict(results) { + const hardFailures = results.filter((r) => r.kind === "hard" && !r.ok); + const drift = results.filter((r) => r.kind === "drift" && !r.ok); + return { releaseGreen: hardFailures.length === 0, hardFailures, drift }; +} + +// ─── Orchestration (only when run directly) ───────────────────────────────── + +function run(cmd, cmdArgs) { + try { + const out = execFileSync(cmd, cmdArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, FORCE_COLOR: "0" }, + }); + return { code: 0, out }; + } catch (err) { + return { + code: typeof err.status === "number" ? err.status : 1, + out: `${err.stdout || ""}${err.stderr || ""}`, + }; + } +} + +function main() { + const args = new Set(process.argv.slice(2)); + const JSON_OUT = args.has("--json"); + const WITH_BUILD = args.has("--with-build"); + const QUICK = args.has("--quick"); + + const results = []; + const record = (r) => { + results.push(r); + const icon = r.ok ? "✅" : r.kind === "drift" ? "🟡" : "❌"; + process.stderr.write(`${icon} [${r.kind}] ${r.label}${r.detail ? ` — ${r.detail}` : ""}\n`); + }; + + const hardCmd = (id, label, cmd, cmdArgs) => { + const { code, out } = run(cmd, cmdArgs); + record({ id, label, kind: "hard", ok: code === 0, detail: code === 0 ? "pass" : firstFailureLine(out) }); + }; + + process.stderr.write("🔎 Release-green validation (current working tree)\n\n"); + + hardCmd("typecheck", "Typecheck (core)", npmCmd, ["run", "typecheck:core"]); + + // ESLint: ONE pass → errors (hard) + warnings (drift) + { + const { out } = run("npx", ["eslint", ".", "--format", "json"]); + const parsed = parseEslintJson(out); + if (!parsed) { + record({ id: "lint", label: "ESLint", kind: "hard", ok: false, detail: "could not parse eslint json" }); + } else { + const { errors, warnings } = eslintCounts(parsed); + record({ id: "lint-errors", label: "ESLint errors", kind: "hard", ok: errors === 0, detail: `${errors} error(s)` }); + const base = baselineValue("eslintWarnings"); + const over = isDrift(warnings, base); + record({ + id: "eslint-warnings", + label: "ESLint warnings (ratchet)", + kind: "drift", + ok: !over, + detail: + base == null + ? `${warnings} (no baseline)` + : `${warnings} vs baseline ${base}${over ? ` (+${warnings - base} drift → rebaseline at release)` : ""}`, + }); + } + } + + hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]); + hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]); + + // Cognitive-complexity (drift) + { + const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]); + const current = parseCognitiveCount(out); + const base = baselineValue("cognitiveComplexity"); + const over = isDrift(current, base); + record({ + id: "cognitive-complexity", + label: "Cognitive complexity (ratchet)", + kind: "drift", + ok: !over, + detail: + current == null + ? "could not parse count" + : `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`, + }); + } + + // file-size (drift) + { + const { code, out } = run(npmCmd, ["run", "check:file-size"]); + record({ + id: "file-size", + label: "File-size ratchet", + kind: "drift", + ok: code === 0, + detail: code === 0 ? "within frozen caps" : firstFailureLine(out), + }); + } + + if (!QUICK) { + hardCmd("unit", "Unit tests (full, CI concurrency)", npmCmd, ["run", "test:unit:ci"]); + hardCmd("vitest", "Vitest (MCP / autoCombo / cache)", npmCmd, ["run", "test:vitest"]); + } + if (WITH_BUILD) { + hardCmd("pack-artifact", "Package artifact (npm pack policy)", npmCmd, ["run", "check:pack-artifact"]); + } + + const { releaseGreen, hardFailures, drift } = computeVerdict(results); + + process.stderr.write("\n──────── verdict ────────\n"); + process.stderr.write(`HARD failures (block — real defects): ${hardFailures.length}\n`); + hardFailures.forEach((r) => process.stderr.write(` ❌ ${r.label}: ${r.detail}\n`)); + process.stderr.write(`Ratchet drift (non-blocking — rebaseline at release): ${drift.length}\n`); + drift.forEach((r) => process.stderr.write(` 🟡 ${r.label}: ${r.detail}\n`)); + process.stderr.write( + releaseGreen + ? "\n✅ RELEASE-GREEN (no hard failures). Any drift above is rebaselined at release, not a contributor concern.\n" + : "\n❌ NOT release-green — hard failures must be fixed (in the originating PR branch, via co-authorship).\n" + ); + + if (JSON_OUT) { + process.stdout.write( + JSON.stringify( + { + releaseGreen, + hardFailures: hardFailures.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + drift: drift.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + checks: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok, detail: r.detail })), + }, + null, + 2 + ) + "\n" + ); + } + + process.exit(releaseGreen ? 0 : 1); +} + +// Run only when invoked directly (so tests can import the pure helpers). +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts new file mode 100644 index 0000000000..0712a6747c --- /dev/null +++ b/tests/unit/validate-release-green.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure helpers of the release-green validator (Solution C). The orchestration is +// guarded behind a direct-run check, so importing the module here is side-effect-free. +const mod = await import("../../scripts/quality/validate-release-green.mjs"); +const { + firstFailureLine, + eslintCounts, + parseEslintJson, + parseCognitiveCount, + isDrift, + computeVerdict, +} = mod; + +test("eslintCounts sums errors + warnings across files", () => { + const parsed = [ + { errorCount: 2, warningCount: 5 }, + { errorCount: 0, warningCount: 3 }, + {}, + ]; + assert.deepEqual(eslintCounts(parsed), { errors: 2, warnings: 8 }); +}); + +test("parseEslintJson tolerates a leading non-JSON banner", () => { + const out = "npm warn something\n[{\"errorCount\":0,\"warningCount\":1}]"; + assert.deepEqual(parseEslintJson(out), [{ errorCount: 0, warningCount: 1 }]); + assert.equal(parseEslintJson("no json here"), null); +}); + +test("parseCognitiveCount reads the gate's count (en + pt)", () => { + assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797); + assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801); + assert.equal(parseCognitiveCount("no number"), null); +}); + +test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { + assert.equal(isDrift(3900, 3867), true); // grew → drift + assert.equal(isDrift(3867, 3867), false); // equal → ok + assert.equal(isDrift(3800, 3867), false); // improved → ok + assert.equal(isDrift(10, null), false); // no baseline → never drift + assert.equal(isDrift(null, 10), false); // unparsed → never drift +}); + +test("firstFailureLine surfaces the meaningful failure, not boilerplate", () => { + const out = [ + "> omniroute@3.8.34 typecheck:core", + "src/x.ts(10,5): error TS2322: Type 'string' is not assignable to 'number'.", + "done", + ].join("\n"); + assert.match(firstFailureLine(out), /error TS2322/); +}); + +test("computeVerdict: releaseGreen iff zero HARD failures (drift never blocks)", () => { + const onlyDrift = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: false }, + ]); + assert.equal(onlyDrift.releaseGreen, true); + assert.equal(onlyDrift.drift.length, 1); + + const hardFail = computeVerdict([ + { kind: "hard", ok: false }, + { kind: "drift", ok: false }, + ]); + assert.equal(hardFail.releaseGreen, false); + assert.equal(hardFail.hardFailures.length, 1); + + const allGreen = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: true }, + ]); + assert.equal(allGreen.releaseGreen, true); +});