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

  1. 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.
  2. 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).
  3. 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.
  4. 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).
  5. 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

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


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/0dbcf1ba8e41ccf3. Report an issue: GitHub.