ci(quality): shift heavy validations to the PR→release fast-path (release-acceleration) (#4857)

* feat(quality): add check:test-runner-api gate (vitest-only dirs must use vitest API)

* feat(release): reusable CHANGELOG i18n-mirror sync script

* chore(ops): add prune-stale-worktrees.sh (dry-run by default)

* ci(quality): run test-runner-api + docs-all + vitest + full unit suite on PR->release fast-path

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-23 20:16:23 -03:00
committed by GitHub
parent 5bffefb489
commit 7f5b1e4d83
8 changed files with 281 additions and 1 deletions

View File

@@ -40,6 +40,8 @@ jobs:
- run: npm run check:fetch-targets
- run: npm run check:openapi-routes
- run: npm run check:docs-symbols
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
run: npm run check:docs-all
- run: npm run check:deps
- run: npm run check:file-size
- run: npm run check:error-helper
@@ -49,6 +51,7 @@ jobs:
- 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
- run: npm run check:any-budget:t11
- name: Typecheck (core)
run: npm run typecheck:core
@@ -82,3 +85,48 @@ jobs:
echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL"
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
fast-vitest:
name: Vitest (fast-path)
runs-on: ubuntu-latest
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run test:vitest
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: >
node --max-old-space-size=4096 --import tsx
--import ./tests/_setup/isolateDataDir.ts
--test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2
tests/unit/*.test.ts
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"

View File

@@ -122,9 +122,10 @@
"dedicatedGate": true
},
"zizmorFindings": {
"value": 155,
"value": 159,
"direction": "down",
"dedicatedGate": true,
"_rebaseline_2026_06_23_fastpath_gates": "155 -> 159 (+4). Two new jobs added to .github/workflows/quality.yml (fast-vitest, fast-unit) to run vitest + the full unit suite on the PR->release fast-path (release-acceleration plan, _tasks/release-bench/v3.8.35/PLANO-IMPLEMENTACAO.md). The +4 are unpinned-uses: actions/checkout@v7 + actions/setup-node@v6 in each of the 2 jobs — the SAME deliberate @vN convention as every other workflow (see _scanner_harden_workflows_2026_06_16). SHA-pinning only these would violate the convention. No new template-injection/artipacked/cache-poisoning. Measured locally via `npm run check:workflows -- --ratchet` = 159.",
"_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning."
},
"vulnCount": {

View File

@@ -76,6 +76,7 @@
"gen:provider-reference": "node --import tsx scripts/docs/gen-provider-reference.ts",
"bench:compression": "node --import tsx scripts/compression/benchmark.ts",
"eval:compression": "node --import tsx scripts/compression-eval/index.ts",
"release:sync-changelog-i18n": "node scripts/release/sync-changelog-i18n.mjs",
"build": "node scripts/build/build-next-isolated.mjs",
"build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs",
"build:cli": "node --import tsx scripts/build/prepublish.ts",
@@ -135,6 +136,7 @@
"check:duplication": "node scripts/check/check-duplication.mjs",
"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: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

@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests).
// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest".
const VITEST_ONLY_DIRS = [
"tests/unit/autoCombo",
"open-sse/services/autoCombo",
"open-sse/mcp-server",
];
function walk(dir, root, out = []) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
for (const name of fs.readdirSync(abs)) {
const rel = path.join(dir, name);
const s = fs.statSync(path.join(root, rel));
if (s.isDirectory()) {
if (name === "node_modules" || name === ".git") continue;
walk(rel, root, out);
} else if (/\.test\.(ts|tsx|js|mjs)$/.test(name)) {
out.push(rel);
}
}
return out;
}
function isVitestOnly(relFile) {
const norm = relFile.replace(/\\/g, "/");
return VITEST_ONLY_DIRS.some(
(d) => norm.startsWith(d + "/") && (norm.includes("/__tests__/") || d.startsWith("tests/unit/"))
);
}
export function findRunnerMismatches(root) {
const files = VITEST_ONLY_DIRS.flatMap((d) => walk(d, root));
const bad = [];
for (const f of files) {
if (!isVitestOnly(f)) continue;
const txt = fs.readFileSync(path.join(root, f), "utf8");
const importsNodeTest = /from\s+["']node:test["']/.test(txt);
const importsVitest = /from\s+["']vitest["']/.test(txt);
if (importsNodeTest && !importsVitest) {
bad.push({ file: f, reason: "vitest-only dir but imports node:test (use the vitest API)" });
}
}
return bad;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const root = process.cwd();
const bad = findRunnerMismatches(root);
if (bad.length) {
console.error(`[test-runner-api] FAIL — ${bad.length} test(s) use the wrong runner API:`);
for (const b of bad) console.error(` ${b.file}: ${b.reason}`);
process.exit(1);
}
console.log("[test-runner-api] OK — vitest-only dirs use the vitest API.");
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Lista (ou remove com --apply) worktrees port-* cuja branch já foi merged ao
# main OU não tem commits à frente do origin/main. NUNCA toca worktrees com
# trabalho não-commitado. Rodar a partir do checkout principal.
set -euo pipefail
APPLY="${1:-}"
git fetch origin main --quiet || true
git worktree list --porcelain | awk '/^worktree /{w=$2} /^branch /{print w" "$2}' | \
while read -r dir ref; do
case "$dir" in
*"/.claude/worktrees/"*port-*|*"/.worktrees/"*port-*) ;;
*) continue ;;
esac
br="${ref#refs/heads/}"
if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then
echo "SKIP (dirty): $dir"
continue
fi
ahead=$(git -C "$dir" rev-list --count origin/main.."$br" 2>/dev/null || echo "?")
if [ "$ahead" = "0" ]; then
if [ "$APPLY" = "--apply" ]; then
git worktree remove --force "$dir" && git branch -D "$br" 2>/dev/null || true
echo "REMOVED: $dir ($br)"
else
echo "WOULD REMOVE (0 ahead of main): $dir ($br)"
fi
else
echo "KEEP ($ahead ahead): $dir ($br)"
fi
done
echo "Done.${APPLY:+ (applied)}"

View File

@@ -0,0 +1,57 @@
import fs from "node:fs";
import path from "node:path";
function sectionRange(text, ver, nextVer) {
const lines = text.split("\n");
const start = lines.findIndex((l) => l.startsWith(`## [${ver}]`));
if (start < 0) return null;
let end = lines.findIndex((l, i) => i > start && l.startsWith(`## [${nextVer}]`));
if (end < 0) end = lines.length;
return { lines, start, end };
}
// Replace the [ver] section in every docs/i18n/<loc>/CHANGELOG.md with the root one.
// If a mirror lacks the section, insert it before [nextVer]. Returns count updated.
export function syncChangelogSection(root, ver, nextVer) {
const rootCl = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
const r = sectionRange(rootCl, ver, nextVer);
if (!r) throw new Error(`root CHANGELOG missing [${ver}]`);
const block = r.lines.slice(r.start, r.end).join("\n");
const i18nDir = path.join(root, "docs/i18n");
if (!fs.existsSync(i18nDir)) return 0;
let n = 0;
for (const loc of fs.readdirSync(i18nDir)) {
const fp = path.join(i18nDir, loc, "CHANGELOG.md");
if (!fs.existsSync(fp)) continue;
const txt = fs.readFileSync(fp, "utf8");
const m = sectionRange(txt, ver, nextVer);
let next;
if (m) {
next =
m.lines.slice(0, m.start).join("\n") +
"\n" +
block +
"\n" +
m.lines.slice(m.end).join("\n");
} else {
const idx = txt.indexOf(`## [${nextVer}]`);
if (idx < 0) continue;
next = txt.slice(0, idx) + block + "\n\n" + txt.slice(idx);
}
if (next !== txt) {
fs.writeFileSync(fp, next);
n++;
}
}
return n;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const [ver, nextVer] = process.argv.slice(2);
if (!ver || !nextVer) {
console.error("usage: sync-changelog-i18n.mjs <ver> <nextVer>");
process.exit(2);
}
const n = syncChangelogSection(process.cwd(), ver, nextVer);
console.log(`[sync-changelog-i18n] updated ${n} mirror(s) for [${ver}]`);
}

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { findRunnerMismatches } from "../../../scripts/check/check-test-runner-api.mjs";
function tmpRepo() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "runner-api-"));
fs.mkdirSync(path.join(root, "tests/unit/autoCombo"), { recursive: true });
return root;
}
test("flags a vitest-only-dir test that imports node:test", () => {
const root = tmpRepo();
fs.writeFileSync(
path.join(root, "tests/unit/autoCombo/bad.test.ts"),
`import { describe, it } from "node:test";\ndescribe("x", () => it("y", () => {}));\n`
);
const bad = findRunnerMismatches(root);
assert.equal(bad.length, 1);
assert.match(bad[0].file, /autoCombo\/bad\.test\.ts$/);
assert.match(bad[0].reason, /vitest-only/);
fs.rmSync(root, { recursive: true, force: true });
});
test("accepts a vitest-only-dir test that imports vitest", () => {
const root = tmpRepo();
fs.writeFileSync(
path.join(root, "tests/unit/autoCombo/good.test.ts"),
`import { describe, it } from "vitest";\ndescribe("x", () => it("y", () => {}));\n`
);
assert.equal(findRunnerMismatches(root).length, 0);
fs.rmSync(root, { recursive: true, force: true });
});

View File

@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { syncChangelogSection } from "../../../scripts/release/sync-changelog-i18n.mjs";
function repo() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cl-i18n-"));
fs.writeFileSync(
path.join(root, "CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n---\n\n## [9.9.9] — 2026-01-01\n\n- **feat:** big new thing\n- **fix:** another\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
);
for (const loc of ["fr", "de"]) {
fs.mkdirSync(path.join(root, "docs/i18n", loc), { recursive: true });
fs.writeFileSync(
path.join(root, "docs/i18n", loc, "CHANGELOG.md"),
"# Changelog\n\n---\n\n## [9.9.9] — TBD\n\n_stub_\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
);
}
return root;
}
test("replaces the version section in every mirror with the root section", () => {
const root = repo();
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
assert.equal(n, 2);
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
assert.match(fr, /big new thing/);
assert.doesNotMatch(fr, /_stub_/);
fs.rmSync(root, { recursive: true, force: true });
});
test("inserts the section when a mirror lacks it", () => {
const root = repo();
// remove the 9.9.9 section from the fr mirror entirely
fs.writeFileSync(
path.join(root, "docs/i18n/fr/CHANGELOG.md"),
"# Changelog\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
);
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
assert.equal(n, 2);
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
assert.match(fr, /## \[9\.9\.9\]/);
assert.match(fr, /big new thing/);
fs.rmSync(root, { recursive: true, force: true });
});