Files
renovate/tools/test-shards.ts
Sebastian Poxhofer 2dbfd85506 refactor(linters): enable node/no-sync and migrate to async calls (#44693)
* ci(linter): add renovate/no-sync-io rule

Flags calls to a fixed list of Node.js sync I/O APIs (readFileSync,
writeFileSync, mkdirSync, rmSync, readdirSync, statSync, existsSync,
copyFileSync, execSync, spawnSync, execFileSync) in tools/** and
**/*.spec.ts only; production lib code is not targeted.

Set to "warn" instead of "error": the repo has 40 pre-existing
violations, exceeding the agreed fix threshold of 30, so violations
are left in place for follow-up rather than mass-fixed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(linter): enforce renovate/no-sync-io at error

Promote the rule from warn to error now that the codebase has been
migrated off synchronous I/O in tools/ and spec files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tools): migrate synchronous fs calls to async equivalents

Replace *Sync fs calls with their async/promise-based counterparts in
tools/ scripts (generate-imports, check runner, utils, cwdchanged hook)
now that renovate/no-sync-io is enforced at error there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: migrate synchronous fs calls in specs to async equivalents

Replace *Sync fs calls (readdirSync, writeFileSync, existsSync, etc.)
with their async/promise-based counterparts across affected spec
files, making the enclosing it()/module-scope initializers async as
needed, to satisfy renovate/no-sync-io now enforced at error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(linter): replace custom no-sync-io rule with built-in node/no-sync

The oxc built-in covers a superset of the custom fixed-name list.
Migrate the additionally flagged sites: async glob in test-shards,
async execa in prepare-deps, promisified gzip/generateKeyPair in
rpm/hex specs, and rename the misleading execSync mock alias
(it mocks the async rawExec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:28:26 +00:00

198 lines
5.7 KiB
TypeScript

import crypto from 'node:crypto';
import { glob } from 'glob';
import { minimatch } from 'minimatch';
import { testShards } from './test/shards.ts';
import type { RunsOn, ShardGroup } from './test/types.ts';
const TOTAL_SHARDS = Object.keys(testShards).length;
function toMatchPattern(path: string): string {
return path.endsWith('.spec.ts')
? path.replace(/\.spec\.ts$/, '{.ts,.spec.ts}')
: `${path}/**/*`;
}
/**
* Given the file list affected by commit, return the list
* of shards that test these changes.
*
* Exported for reuse in local-ci tooling.
*/
export function getMatchingShards(files: string[]): string[] {
const matchingShards = new Set<string>();
for (const file of files) {
// Only lib/ files can match test shards
if (!file.startsWith('lib/')) {
continue;
}
for (const [key, { matchPaths }] of Object.entries(testShards)) {
const patterns = matchPaths.map(toMatchPattern);
if (patterns.some((pattern) => minimatch(file, pattern))) {
matchingShards.add(key);
break;
}
}
}
return Object.keys(testShards).filter((shard) => matchingShards.has(shard));
}
export { TOTAL_SHARDS };
/**
* Convert shard names to vitest-compatible test file patterns.
*
* Vitest CLI filters are substring/regex matches, not globs.
* This expands glob patterns (like [a-c]*) into actual paths.
*/
export async function getTestPatternsForShards(
shardNames: string[],
): Promise<string[]> {
const patterns: string[] = [];
for (const name of shardNames) {
const shard = testShards[name];
for (const path of shard.matchPaths) {
// Specific .spec.ts files pass through directly
if (path.endsWith('.spec.ts')) {
patterns.push(path);
continue;
}
const isGlobPattern = path.includes('[') || path.includes('*');
if (isGlobPattern) {
const expanded = await glob(path, { nodir: false });
patterns.push(...expanded);
} else {
patterns.push(path);
}
}
}
return patterns;
}
/**
* Distribute items evenly across runner instances.
*/
function scheduleItems<T>(items: T[], availableInstances: number): T[][] {
const numInstances = Math.min(items.length, availableInstances);
const maxPerInstance = Math.ceil(items.length / numInstances);
const lighterInstancesIdx =
items.length % numInstances === 0
? numInstances
: items.length % numInstances;
const partitionSizes = Array.from({ length: numInstances }, (_, idx) =>
idx < lighterInstancesIdx ? maxPerInstance : maxPerInstance - 1,
);
const result: T[][] = Array.from({ length: numInstances }, () => []);
let rest = items.slice();
for (let idx = 0; idx < numInstances; idx += 1) {
const partitionSize = partitionSizes[idx];
const partition = rest.slice(0, partitionSize);
result[idx] = partition;
rest = rest.slice(partitionSize);
}
return result;
}
/**
* Main function for CI shard scheduling.
* Only runs when SCHEDULE_TEST_SHARDS=true is set.
*/
function main(): void {
let shardKeys = Object.keys(testShards);
if (process.env.FILTER_SHARDS === 'true' && process.env.CHANGED_FILES) {
try {
const changedFiles: string[] = JSON.parse(process.env.CHANGED_FILES);
const matchingShards = getMatchingShards(changedFiles);
if (matchingShards.length === 0) {
console.log(`test-matrix-empty=true`);
process.exit(0);
}
shardKeys = shardKeys.filter((key) => matchingShards.includes(key));
} catch (err) {
console.error(err);
process.exit(1);
}
}
/**
* Not all runners are created equal.
* Minutes cost proportion is 1:2:10 for Ubuntu:Windows:MacOS.
*
* Although it's free in our case,
* we can't run as many Windows and MacOS runners as we want.
*
* Because of this, we partition shards into groups, given that:
* - There are 16 shards in total
* - We can't run more than 10 Windows runners
* - We can't run more than 5 MacOS runners
*/
const shardGrouping: Record<string, string[][]> = {
'ubuntu-latest': scheduleItems(shardKeys, 16),
};
if (process.env.ALL_PLATFORMS === 'true') {
// shardGrouping['windows-latest'] = scheduleItems(shardKeys, 8);
shardGrouping['macos-latest'] = scheduleItems(shardKeys, 4);
}
const shardGroups: ShardGroup[] = [];
for (const [os, groups] of Object.entries(shardGrouping)) {
const coverage = os === 'ubuntu-latest';
const total = groups.length;
for (let idx = 0; idx < groups.length; idx += 1) {
const number = idx + 1;
const platform = os.replace(/-latest$/, '');
const name =
platform === 'ubuntu'
? `test (${number}/${total})`
: `test-${platform} (${number}/${total})`;
const shards = groups[idx];
const cacheKey = crypto
.createHash('md5')
.update(shards.join(':'))
.digest('hex');
const runnerTimeoutMinutes =
{
ubuntu: 10,
windows: 20,
macos: 20,
}[platform] ?? 20;
const testTimeoutMilliseconds =
{
windows: 240000,
}[platform] ?? 120000;
shardGroups.push({
os: os as RunsOn,
coverage,
name,
shards: shards.join(' '),
'cache-key': cacheKey,
'runner-timeout-minutes': runnerTimeoutMinutes,
'test-timeout-milliseconds': testTimeoutMilliseconds,
'upload-artifact-name': `coverage-${shards.sort().join('_')}`,
});
}
}
/**
* Output will be consumed by `setup` CI job.
*/
console.log(`test-shard-matrix=${JSON.stringify(shardGroups)}`);
}
// Only run main when invoked as a script (not when imported)
if (process.env.SCHEDULE_TEST_SHARDS === 'true') {
main();
}