Article · Flaky Test Detection & Quarantine Engineering

Retrying Only Failed Shards in GitHub Actions

When a sharded suite goes red because one of twelve shards had a blip, re-running the workflow repeats all twelve. That is eleven shards of wasted runner time, a longer wait for the developer, and — worse — a fresh chance for a different shard to flake, which is why large suites sometimes need three attempts to get one green run. This guide implements selective shard retry as part of CI Retry Strategies & Budgets.

12 sections URL: /flaky-test-detection-quarantine-engineering/ci-retry-strategies-and-budgets/retrying-only-failed-shards-in-github-actions/
Full rerun versus selective shard retry Re-running everything repeats eleven healthy shards and gives them a new chance to flake; retrying one shard does not. full rerun 12 shards re-run — 11 of them for nothing, each able to flake again selective retry one shard re-runs — roughly a twelfth of the cost and a twelfth of the new flake exposure with a 1% per-shard flake rate, a 12-shard suite fails about 11% of runs; retrying all 12 keeps that exposure on every attempt
Re-running healthy shards does not just waste time — it re-rolls the dice for every one of them.

Root cause #

Sharding multiplies exposure. If each shard has a 1% chance of an unrelated blip, a twelve-shard suite has roughly an 11% chance that at least one shard fails, and re-running all twelve carries the same 11% again. That is why large suites develop a folklore of “just re-run it, twice if you have to”: the retry strategy is fighting a probability it keeps recreating.

The second mechanism is attribution. A full rerun replaces the whole result set, so the record of which shard was unstable is overwritten by a green run. The flakiness data that a budget depends on quietly disappears, and the pipeline looks healthier than it is — the accounting failure described throughout Historical Flakiness Tracking & Analytics.

The third is cost, which grows with the square of the team’s tolerance. Every full rerun costs the whole suite’s runner minutes, and a team that reruns twice a day on a twenty-minute twelve-shard suite is spending several hours of compute a week rescuing single-shard blips.

Step-by-step fix #

1. Shard deterministically so a shard index means something #

Selective retry requires that “shard 3” refers to the same set of tests on the retry as on the first attempt. Playwright’s built-in sharding is deterministic given the same test list, which is what makes the index a stable handle.

# .github/workflows/e2e.yml
# Trade-off: static sharding is deterministic and can be unbalanced; duration-
# based sharding is faster and must record its assignment for retry to work.
jobs:
  e2e:
    strategy:
      fail-fast: false            # one failing shard must not cancel the others
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
    steps:
      - uses: actions/checkout@v4
      - run: npx playwright test --shard=${{ matrix.shard }}/12
      - name: Record the outcome
        if: always()
        run: echo "${{ job.status }}" > shard-${{ matrix.shard }}.status
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: shard-${{ matrix.shard }}-result
          path: |
            shard-${{ matrix.shard }}.status
            results.json

fail-fast: false is essential: with the default, the first failing shard cancels the rest and you lose the information about which others would have passed.

2. Collect the failed shard indices #

A gather job reads the artifacts and produces the list of shards to retry, as a matrix input for the next job.

  collect:
    needs: e2e
    if: always()
    outputs:
      failed: ${{ steps.list.outputs.failed }}
    steps:
      - uses: actions/download-artifact@v4
        with: { pattern: 'shard-*-result', path: results }
      - id: list
        # Trade-off: shelling out to jq keeps this readable; a small Node script
        # is easier to test if the logic grows beyond "which files say failure".
        run: |
          FAILED=$(for f in results/shard-*-result/shard-*.status; do
            idx=$(basename "$f" .status | sed 's/shard-//')
            grep -q failure "$f" && echo "$idx"
          done | jq -R . | jq -sc .)
          echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
          echo "failed shards: $FAILED"

3. Re-run only those shards, once #

The retry job runs a matrix built from the collected list, and is skipped entirely when the list is empty.

  retry:
    needs: collect
    if: needs.collect.outputs.failed != '[]'
    strategy:
      fail-fast: false
      matrix:
        shard: ${{ fromJson(needs.collect.outputs.failed) }}
    steps:
      - uses: actions/checkout@v4
      # Trade-off: a single retry pass bounds the cost and means a genuinely
      # broken shard still fails, which is what you want it to do.
      - run: npx playwright test --shard=${{ matrix.shard }}/12
Three-job structure for selective retry A sharded matrix job, a collector that lists failed shards, and a retry matrix built from that list. shard matrixfail-fast: false collectoutputs failed indices retry matrixskipped when empty the collector is the whole trick: it turns job results into a matrix input
Three small jobs replace the "re-run all jobs" button, and preserve the record of what was unstable.

4. Keep the first-attempt record #

The retry must not erase the evidence. Upload both attempts’ results and feed the difference into the flakiness store, so a shard that needed a retry is counted even though the pipeline ended green.

// scripts/record-shard-retries.js
// Trade-off: keeping both attempts means more artifacts to manage and is the
// only way a selective retry does not quietly improve your statistics.
const first = loadResults('attempt-1');
const second = loadResults('attempt-2');

const rescued = first.failures.filter((f) => !second.failures.some((s) => s.title === f.title));
recordFlaky(rescued);            // counted against the budget

5. Prefer duration-based sharding, and record the assignment #

Static index sharding is deterministic but often unbalanced — one shard finishes in four minutes and another in fourteen. Duration-based partitioning fixes the imbalance but makes the shard index depend on timing data, so the assignment has to be recorded for the retry to run the same tests.

// Trade-off: balanced shards cut wall-clock time significantly and require the
// partition to be persisted, or a retry may run a different set of tests.
const partition = partitionByDuration(testList, durations, { shards: 12 });
writeFileSync(`shard-assignment.json`, JSON.stringify(partition));

6. Cap the retry and escalate the rest #

One selective retry is a blip absorber. A shard that fails twice has a real problem — either a genuine regression or a test that belongs in quarantine — and should be treated as such rather than retried a third time.

Pitfalls #

  • Leaving fail-fast at its default. The first failing shard cancels the others and you cannot tell which else would have failed. Mitigation: set it to false.
  • Re-running the whole workflow. Costly, and it overwrites the flakiness record. Mitigation: selective retry with preserved artifacts.
  • Non-deterministic shard assignment. The retry runs different tests than the failure. Mitigation: persist the partition and reuse it.
  • Not recording the first attempt. The rescued failures vanish from the statistics. Mitigation: count rescued tests against the budget.
  • Unlimited selective retries. A broken shard cycles forever. Mitigation: one retry pass, then escalate.
  • Retrying a shard whose failure was environmental. The same runner problem recurs. Mitigation: check the fingerprint before retrying, as in CI Environment & Browser Drift.
Cost of a rerun by strategy A full workflow rerun costs twelve shards; a selective retry costs one, with the same chance of rescuing the run. full rerun 12 shards × 20 min = 240 runner-minutes selective 20 runner-minutes same probability of a green result, one twelfth of the compute and of the re-flake exposure
The saving compounds: teams that stop paying for full reruns stop rationing reruns, and the pipeline gets more predictable.

Reliability targets #

Metric Target Notes
Full-workflow reruns 0 Replaced by selective shard retry
Retry passes per run ≤ 1 Then escalate
Rescued failures recorded 100% Counted against the flake budget
Shard duration spread < 25% between longest and shortest Duration-based partitioning
Runner minutes spent on retries < 5% of total Tracked per week
Shard retry scorecard Targets for full reruns, retry passes, recorded rescues and shard balance. 0full reruns 1retry pass 100%rescues recorded < 25%shard spread
Recording the rescues is what keeps selective retry from becoming a cheaper way to hide the same problem.

Frequently Asked Questions #

Q: Does Playwright’s built-in retries make this redundant? A: No, they solve adjacent problems. Test-level retries handle an individual test blip inside a shard and are the first line. Shard retry handles a failure that kills the whole shard — a runner dying, a service failing to start, an out-of-memory condition — where no per-test retry can help because the process is gone.

Q: Why not just re-run the failed job from the interface? A: You can, and it is the manual version of the same idea. Automating it matters because a manual re-run does not record what was rescued, happens only when someone is watching, and tempts people into re-running everything when the interface makes that the easier button.

Q: Should the retry run on a fresh runner or reuse the original? A: A fresh one. Part of the value of a retry is escaping whatever local condition caused the failure — a noisy neighbour, exhausted memory, a service that failed to start — and reusing the same machine keeps that condition in place. A fresh runner also makes the retry a genuinely independent trial, which is what the statistics behind the budget assume.

Q: Our shards are wildly unbalanced. Does that break this? A: It makes it less effective, not incorrect: a slow shard retried alone still costs its full duration. Balance by partitioning on recorded durations rather than by index, persist that partition, and reuse it on the retry so the same tests run again.

Q: What happens if the collector job itself fails? A: Treat that as a pipeline failure rather than letting it pass silently. The collector runs with if: always(), so it executes even when shards fail — but if it errors while parsing artifacts, the retry job’s matrix input is missing and the workflow can end green with failures unaddressed. Fail closed: if the collector cannot determine the failed set, mark the run red and let a human look.

Q: Does this work with other CI providers? A: The mechanism is generic — run shards, record per-shard status, compute the failed set, run a second matrix from it. What differs is how each provider expresses a dynamic matrix, and some require the retry job to be a separate pipeline triggered with parameters rather than a downstream job. The constraint that matters everywhere is deterministic shard assignment; without it, the retry runs a different set of tests.

Q: How does this interact with the flake budget? A: Directly. Every failure rescued by a shard retry must be recorded and counted, or selective retry becomes a cheaper way of hiding the rate — the same failure mode as an unreported test retry, and the reason Setting a Retry Budget That Fails the Build insists on reporting before enforcement.