garrytan/gstack · error · Error
--shard must be between 1 and ${shards.length}. Received: ${
Error message
--shard must be between 1 and ${shards.length}. Received: ${options.shardIndex} What it means
Thrown by main() in scripts/test-free-shards.ts:324 when --shard N is passed and N fails one of three checks: not an integer (Number.isInteger), less than 1, or greater than shards.length. Critically, shards.length is the count of NON-EMPTY shards returned by assignFilesToShards() — line 219 filters out empty shards, so shards.length can be smaller than the requested shardCount when there are fewer test files than shards. A user who passes --shards 20 --shard 15 with only 10 files will see 'between 1 and 10', not 'between 1 and 20'.
Source
Thrown at scripts/test-free-shards.ts:324
}
}
if (options.listOnly) {
console.log(`\nDiscovered ${files.length} test files.`);
for (const file of files) console.log(` ${file}`);
return 0;
}
const shards = assignFilesToShards(files, options.shardCount);
if (options.dryRun) {
console.log(`\nWould run ${files.length} files across ${shards.length} shards.`);
for (const line of formatShardSummary(shards)) console.log(line);
return 0;
}
if (options.shardIndex !== null) {
if (!Number.isInteger(options.shardIndex) || options.shardIndex < 1 || options.shardIndex > shards.length) {
throw new Error(`--shard must be between 1 and ${shards.length}. Received: ${options.shardIndex}`);
}
return runShard(shards[options.shardIndex - 1], options.shardIndex, shards.length);
}
for (let index = 0; index < shards.length; index += 1) {
const exitCode = runShard(shards[index], index + 1, shards.length);
if (exitCode !== 0) return exitCode;
}
return 0;
}
if (import.meta.main) {
process.exitCode = main();
}
View on GitHub (pinned to 94993f7401)
Solutions
- Run with --dry-run first: it prints the realized shard count and per-shard file lists (lines 316-319) without running tests, so you can see the valid 1..shards.length range.
- Drive the CI matrix from the realized shard count, not the requested --shards value: either run --dry-run to capture the count, or cap --shard at min(requestedShardCount, fileCount).
- If the suite shrank, lower --shards to match the file count (e.g. --shards 10), or remove the --shard pin and let the loop at lines 329-332 run all shards in-process.
- Validate the --shard argument is a base-10 integer before invoking — parseInt with radix 10 and a NaN check (the script already does Number.isInteger at line 323 but only after parsing).
- For 0-indexed habit: remember this script is 1-indexed (line 326 uses options.shardIndex - 1 to access the array); pass --shard 1 for the first shard.
Example fix
// before: matrix hardcoded against --shards, breaks when suite shrinks
# strategy: matrix shard in [1..20], cmd: --shards 20 --shard ${{ matrix.shard }}
// after: derive matrix bound from realized shard count via --dry-run
- run: bun run scripts/test-free-shards.ts --dry-run --shards 20 > plan.txt
- id: count
run: echo "n=$(grep -c '^Shard ' plan.txt)" >> $GITHUB_OUTPUT
- strategy:
matrix:
shard: [1, 2, 3, "${{steps.count.outputs.n}}"] # bounded by reality
- run: bun run scripts/test-free-shards.ts --shards 20 --shard ${{ matrix.shard }} Defensive patterns
Strategy: validation
Validate before calling
// Before calling main(), bound the requested shard to the realized shard count.
// Realized count = number of non-empty shards, which is <= options.shardCount
// when files are scarce. Use --dry-run output or compute it directly.
import { collectFreeTestFiles, assignFilesToShards, DEFAULT_SHARD_COUNT } from './scripts/test-free-shards.ts';
const files = collectFreeTestFiles();
const shards = assignFilesToShards(files, shardCount ?? DEFAULT_SHARD_COUNT);
const maxShard = shards.length; // realized, <= requested
if (shardIndex !== null && (!Number.isInteger(shardIndex) || shardIndex < 1 || shardIndex > maxShard)) {
console.error(`--shard out of range [1, ${maxShard}] (realized). Received: ${shardIndex}`);
process.exit(2);
} Type guard
// Narrow a parsed shard index to a valid 1-based integer in range.
function isValidShardIndex(v: unknown, max: number): v is number {
return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= max;
} Try / catch
// Distinguish out-of-range from test failure so CI doesn't retry pointlessly.
if (import.meta.main) {
try { process.exitCode = main(); }
catch (err) {
const msg = (err as Error).message;
if (msg.startsWith('--shard must be between')) {
console.error('[test-free-shards] shard index out of realized range; run with --dry-run to see valid range');
process.exitCode = 2;
} else { console.error(msg); process.exitCode = 1; }
}
} Prevention
- Always run --dry-run once when changing --shards or the test set; it prints the realized shard count and per-shard file counts (lines 316-319) so the valid 1..N range is visible.
- Remember shards.length is post-filter (empty shards removed at line 219), so the valid upper bound can be smaller than the --shards value you passed.
- Generate CI matrix bounds from the realized count, not the requested count — query --dry-run or compute assignFilesToShards().length.
- Treat the script as 1-indexed for --shard; line 326 subtracts 1 internally, so --shard 0 always throws.
- Sanitize untrusted/CI-injected shard values with Number.isInteger before passing — parseInt('') or parseInt('foo') yields NaN and trips the same guard.
When it happens
Trigger: Passing --shard 0 or --shard -1 (1-indexed lower bound). Passing --shard with a non-integer like --shard 1.5 or --shard foo (Number.parseInt produces NaN; Number.isInteger(NaN) is false). Passing --shard N where N exceeds shards.length after empty-shard filtering — most commonly when --windows-only excluded enough tests, or --shards was set higher than the file count. CI matrix generates shard indices from the raw shardCount rather than the realized shard count.
Common situations: A GitHub Actions matrix uses `shard: [1,2,...,20]` against `--shards 20` but the free suite only has 12 files; shards 13-20 never get created and the worker for shard 13 throws. A developer hardcodes `--shard 5` from memory after a refactor that shrank the suite. NaN slips in from an unquoted CI variable (${SHARD_INDEX} that expanded empty, then parseInt → NaN). Someone assumes 0-indexed and passes --shard 0.
Related errors
- Unknown argument: ${arg}
- No free test files were discovered.
- Usage: browse viewport [<WxH>] [--scale <n>] (e.g. 375x812,
- Usage: browse cookie <name>=<value>
- Usage: browse header <name>:<value>
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/0dbcf1ba8e41ccf3.
Report an issue: GitHub.