Fairsplice is a CLI tool and GitHub Action that optimizes test distribution across parallel workers. It provides CircleCI-style test splitting based on timing data for GitHub Actions.
Recommended: Compute splits once and pass to test jobs. This ensures re-running a failed job runs the same tests.
jobs:
# Compute splits once - ensures consistent re-runs
compute-splits:
runs-on: ubuntu-latest
outputs:
test-buckets: ${{ steps.split.outputs.buckets }}
steps:
- uses: actions/checkout@v4
- name: Split tests
id: split
uses: dashdoc/fairsplice@v1
with:
command: split
pattern: 'tests/**/*.py'
total: 3
cache-key: python-tests
# No index = outputs all buckets as JSON array
test:
needs: compute-splits
runs-on: ubuntu-latest
strategy:
matrix:
index: [0, 1, 2]
steps:
- uses: actions/checkout@v4
- name: Get test files
id: split
run: |
echo "tests=$(echo '${{ needs.compute-splits.outputs.test-buckets }}' | jq -r '.[${{ matrix.index }}] | join(" ")')" >> "$GITHUB_OUTPUT"
- name: Run tests
run: pytest ${{ steps.split.outputs.tests }} --junit-xml=junit.xml
- name: Convert JUnit to timing JSON
uses: dashdoc/fairsplice@v1
with:
command: convert
from: junit.xml
to: timing.json
- uses: actions/upload-artifact@v4
with:
name: timing-${{ matrix.index }}
path: timing.json
save-timings:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Merge timings
uses: dashdoc/fairsplice@v1
with:
command: merge
prefix: 'timing-*/timing'
cache-key: python-testsThat's it! Caching is handled automatically.
When you compute splits inside each matrix job (using index), re-running a failed job can run different tests:
- Other jobs may have updated the timing cache
- The re-run computes a new split with updated timings
- The failed test might now be assigned to a different worker
By computing splits once in a dedicated job and passing via workflow outputs, GitHub Actions preserves the same split on re-runs.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CI PIPELINE │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ 1. SPLIT PHASE │
└─────────────────────┘
timings (cached) fairsplice split
┌──────────────────────┐ ┌─────────────────┐
│ { │ │ │
│ "test_a.py": [2.1],│ ──────▶ │ Load timings │
│ "test_b.py": [5.3],│ │ + glob files │
│ "test_c.py": [1.8] │ │ │
│ } │ └────────┬────────┘
└──────────────────────┘ │
▼
┌─────────────────────────┐
│ Distribute tests by │
│ timing (bin packing) │
└─────────────────────────┘
│
┌─────────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Worker 0 │ │ Worker 1 │ │ Worker 2 │
│ ~5.3s │ │ ~3.9s │ │ ~5.1s │
└─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Run tests │ │ Run tests │ │ Run tests │
│ Output JUnit │ │ Output JUnit │ │ Output JUnit │
└─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘
│ │ │
└───────────────────────────┴───────────────────────────┘
│
┌─────────────────────┐ │
│ 2. MERGE PHASE │ │
└─────────────────────┘ │
▼
┌─────────────────────────┐
│ fairsplice merge │
│ (extracts timings) │
└─────────────────────────┘
│
▼
┌──────────────────────┐
│ timings (cached) │◀─── Auto-cached
└──────────────────────┘ for next run
Key concepts:
- Split phase: Distributes test files across workers based on historical timing data
- Convert phase: Extracts timing from JUnit XML into timing JSON (one per worker)
- Merge phase: Combines timing JSON files from all workers and caches for next run
- Bin packing: Assigns tests to balance total execution time (heaviest tests first)
- Rolling average: Keeps last 10 timings per test file for predictions
| Input | Required | Description |
|---|---|---|
command |
Yes | split, convert, or merge |
cache-key |
For split/merge | Cache key for storing timings (use different keys for frontend/backend workflows) |
timings-file |
No | JSON file for timings (default: .fairsplice-timings.json) |
pattern |
For split | Glob pattern to match test files |
total |
For split | Total number of workers |
index |
For split | Current worker index (0-based) |
from |
For convert | JUnit XML file to read |
to |
For convert | Timing JSON file to write |
path-prefix |
For convert | Prefix to prepend to file paths (to match split pattern) |
prefix |
For merge | Prefix to match timing JSON files |
Fairsplice uses GitHub Actions cache for storing timing history. Important characteristics:
- Repository-scoped, branch-gated: Caches are repository-scoped but restore access is gated by branch context
- Default branch is global: Caches saved from the default branch (usually
main) are restorable by all branches - Immutable, single-writer: Each cache key can only be written once; updates require a new key (handled automatically via run ID suffix)
- Asymmetric cross-branch sharing: Restore is permissive (branches can read from main), save is restricted (branches can only write to their own scope)
To seed shared timings for all branches, run the workflow on main first. Subsequent PRs and feature branches will restore timings from main's cache.
| Output | Description |
|---|---|
tests |
Space-separated list of test files (when index provided) |
buckets |
JSON array of all test buckets |
Install with Bun:
bunx fairspliceSplit tests:
fairsplice split --timings-file timings.json --pattern "tests/**/*.py" --total 3 --out split.jsonConvert JUnit XML to timing JSON:
fairsplice convert --from junit.xml --to timing.jsonMerge timing results:
fairsplice merge --timings-file timings.json --prefix timing-fairsplice split
--timings-file <file> JSON file with stored timings
--pattern <pattern> Glob pattern for test files (can repeat)
--total <n> Number of workers
--out <file> Output JSON file
fairsplice convert
--from <file> JUnit XML file to read
--to <file> Timing JSON file to write
--path-prefix <prefix> Prefix to prepend to file paths
fairsplice merge
--timings-file <file> JSON file to store timings
--prefix <prefix> Prefix to match timing JSON files
# Run locally
bun run index.ts
# Run tests
bun testMIT