mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
committed by
GitHub
parent
5bffefb489
commit
7f5b1e4d83
48
.github/workflows/quality.yml
vendored
48
.github/workflows/quality.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
59
scripts/check/check-test-runner-api.mjs
Normal file
59
scripts/check/check-test-runner-api.mjs
Normal 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.");
|
||||
}
|
||||
31
scripts/ops/prune-stale-worktrees.sh
Executable file
31
scripts/ops/prune-stale-worktrees.sh
Executable 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)}"
|
||||
57
scripts/release/sync-changelog-i18n.mjs
Normal file
57
scripts/release/sync-changelog-i18n.mjs
Normal 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}]`);
|
||||
}
|
||||
35
tests/unit/build/check-test-runner-api.test.ts
Normal file
35
tests/unit/build/check-test-runner-api.test.ts
Normal 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 });
|
||||
});
|
||||
47
tests/unit/build/sync-changelog-i18n.test.ts
Normal file
47
tests/unit/build/sync-changelog-i18n.test.ts
Normal 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user