GitHub Actions Matrix Builds Are a Trap: How We Optimized CI for 12 OSes and 4 Python Versions Without Losing Our Sanity

1 comment
(GitHub and Open Source) - Naive matrix builds look clean but silently destroy your CI budget and developer velocity. Here's exactly how we slashed our open source CI run time by 73% and cut monthly Actions minutes by 62% without sacrificing coverage.

GitHub Actions Matrix Builds Are a Trap: How We Optimized CI for 12 OSes and 4 Python Versions Without Losing Our Sanity

Let’s be honest. You’ve probably written a matrix build that looks clean in your YAML but quietly burns through your GitHub Actions minutes like a teenager with a credit card.

I’ve been there. We all have.

How We Helped a US Fintech Startup Survive a 10x Traffic Spike Without Burning Cash

How We Helped a US Fintech Startup Survive a 10x Traffic Spike Without Burning Cash

How We Helped a US Fintech Startup Survive a 10x Traffic Spike Without Burning Cash It was a… ...

Recently, our team at ECOA AI was maintaining an open source Python library that needed to support 12 operating systems (Ubuntu 20.04, 22.04, 24.04, macOS 11-14, Windows Server 2019/2022) across 4 Python versions (3.9 through 3.12). That’s 48 unique job combinations. Every single PR.

The naive approach? Let GitHub Actions figure it out. The result? 4+ hour CI pipelines, constant timeout failures, and a monthly Actions bill that made our CTO wince.

5 Open Source AI Tools on GitHub That Actually Deliver (Personal Picks)

5 Open Source AI Tools on GitHub That Actually Deliver (Personal Picks)

You know the feeling. You’re browsing GitHub, bookmarking repo after repo, convinced you’ve found the holy grail of… ...

Here’s how we fixed it.

The Trap: Why Naive Matrix Builds Fail at Scale

Matrix builds are *deceptively* simple. You define a `strategy.matrix` block, and GitHub spawns N parallel jobs. Looks great in docs. Falls apart in production.

The three problems nobody talks about:

1. The slowest job holds the entire pipeline hostage. You’re waiting on that one Windows + Python 3.9 job that takes 45 minutes while everything else finished 30 minutes ago.

2. Dependency caching breaks across matrix cells. Each job starts cold. Your dependencies compile fresh. Every. Single. Time.

3. Failures cascade silently. One OS-Python combination fails due to an environment quirk, and suddenly your entire PR status shows red. Developers start ignoring CI failures. Bad pattern.

We hit all three. Hard.

Step 1: Stop Running Everything on Every PR

Here’s the hard truth: you don’t need to test all 48 combinations on every single commit.

Ask yourself: *Does a README typo really need to run the full matrix? Does a docstring change need Windows Server 2022 validation?*

We implemented a path-based filtering strategy that saved us immediately:

yaml
jobs:
  test:
    if: |
      github.event_name == 'push' && 
      contains(fromJson('["src/", "tests/", "setup.py", "pyproject.toml", ".github/workflows/"]'), 
      github.event.head_commit.modified[0])

That’s a start. But we went further.

We created tiered testing:

  • Tier 1 (every commit): Ubuntu 22.04 + Python 3.10 and 3.11
  • Tier 2 (every PR): All Ubuntu versions + Python 3.9-3.12
  • Tier 3 (pre-release): Full 48-combination matrix, triggered manually or on merge to main

This single change cut our per-commit CI time from 4+ hours to 11 minutes for the average push. Developers stopped hating us.

Step 2: Cache Everything, Cache Intelligently

Default GitHub Actions caching is garbage for matrix builds. The cache key is too narrow, so each matrix cell misses and re-downloads everything.

Here’s the pattern that actually works:

yaml
- name: Cache Python dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
      ~/.cache/pre-commit
    key: |
      ${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock', '**/requirements*.txt') }}
    restore-keys: |
      ${{ runner.os }}-${{ matrix.python-version }}-
      ${{ runner.os }}-

The `restore-keys` fallback is the magic. If there’s no exact cache hit, it falls back to any cache for that OS and Python version. Even a partial cache saves 3-5 minutes per job.

We also added pre-build wheels caching for native dependencies like `numpy` and `pandas`. That shaved another 2 minutes per job.

Step 3: Parallelize Within Jobs, Not Just Across Them

Matrix builds parallelize *across* jobs. But what about *within* a single job? If you have 200 tests, they run sequentially by default.

We switched to `pytest-xdist` with auto CPU detection:

yaml
- name: Run tests with parallel execution
  run: |
    pip install pytest-xdist
    python -m pytest tests/ -n auto --timeout=120 -x

On a 4-core runner, this cut test execution from 18 minutes to 5.5 minutes. That’s a 70% reduction for zero extra cost.

But there’s a gotcha: some tests are not thread-safe. We had to mark about 8% of our test suite with `@pytest.mark.serial` and run those separately. Worth the effort.

Step 4: Fail Fast, Fail Smart

The default matrix behavior waits for all jobs to complete before showing a status. That’s insane. If the Ubuntu + Python 3.11 job fails in 30 seconds, why wait 45 minutes for Windows to finish?

We enabled `fail-fast: true`:

yaml
strategy:
  fail-fast: true
  matrix:
    os: [ubuntu-22.04, ubuntu-24.04, macos-13, macos-14, windows-2022]
    python-version: ['3.9', '3.10', '3.11', '3.12']

This cancels all in-progress jobs when any job fails. Sounds obvious, but you’d be surprised how many projects don’t enable this.

We also added a timeout per job:

yaml
jobs:
  test:
    timeout-minutes: 30

No more jobs running for 2+ hours because a network call hung.

The Results: What We Actually Measured

After implementing these four changes on our open source library (which has 4,200+ GitHub stars), here’s what we saw over a 30-day period:

Metric Before After Improvement
Avg CI time per commit 4h 12m 11m (Tier 1) 95% faster
Avg CI time per PR 4h 12m 38m (Tier 2) 85% faster
Monthly Actions minutes 124,500 47,310 62% reduction
Failed jobs due to timeout 23 0 100% elimination
Developer “CI is red” complaints 12/week 1/week 92% reduction

The monthly cost savings? Roughly $380/month in Actions minutes alone. For an open source project with no revenue, that’s real money.

What We Learned the Hard Way

A few things that didn’t make it into the happy path above:

Windows runners are 2-3x slower than Linux runners. Same hardware tier, same workload. We don’t know exactly why (Microsoft’s infrastructure, maybe), but we factored that into our timeouts and tiering.

macOS runners have network variability. Sometimes `pip install` takes 10 seconds. Sometimes 3 minutes. We added retry logic for dependency installation on macOS.

Cache invalidation is a silent killer. We had a bug where the cache key didn’t include the `pyproject.toml` hash. For two weeks, everyone was running tests against stale dependencies. *Always* hash your dependency files in the cache key.

Is the Full Matrix Worth It?

Honestly? For most projects, no.

We ran an analysis: of the last 500 PRs merged to our main branch, only 3 failures were caught exclusively by a non-Ubuntu, non-standard-Python-version combination. That’s a 0.6% catch rate for 75% of our CI cost.

We’re now evaluating whether to drop Windows and older macOS from Tier 3 entirely. The signal-to-noise ratio is terrible.

*Before you build a 48-job matrix, ask yourself: What is this actually protecting against?*

The Open Source Reality

Our team at ECOA AI maintains this library as part of our commitment to the open source ecosystem. We’re based in Ho Chi Minh City and Can Tho, Vietnam, and we’ve found that investing in CI optimization pays dividends in maintainer sanity.

We’ve open-sourced our full workflow configuration. You can find it in the `.github/workflows/` directory of our project. Steal it. Adapt it. Make it yours.

But more importantly: stop treating matrix builds as a set-it-and-forget-it solution. They’re not. They’re a living part of your infrastructure that needs constant tuning.

Frequently Asked Questions

Q: Should I use `include` or `exclude` in my matrix strategy to reduce combinations?

Use `include` to add specific OS-Python combinations rather than `exclude` to remove them. `exclude` creates implicit behavior that’s hard to debug. With `include`, you explicitly define exactly which combinations run. It’s more verbose but infinitely more maintainable.

Q: How do I handle flaky tests in a matrix build without disabling the entire job?

Use `pytest-flaky` with a `@pytest.mark.flaky(reruns=3)` decorator on known flaky tests. Don’t retry entire jobs — that wastes minutes for a single transient failure. We also maintain a `conftest.py` that logs flaky test occurrences to a separate file for weekly review.

Q: What’s the best way to share setup steps across matrix jobs without duplicating YAML?

Use composite actions or reusable workflows. We extracted our dependency installation, caching, and test execution into a reusable workflow that accepts `os` and `python-version` as inputs. It cut our workflow file from 340 lines to 85 lines and made maintenance trivial.

Q: How do I debug a matrix job that only fails on one specific OS-Python combination?

Add `–step-debug` to your workflow and enable debug logging in the GitHub Actions UI. We also added a manual workflow_dispatch trigger that accepts custom `os` and `python-version` inputs so we can reproduce failures without pushing code. Saves hours of guesswork.

Related reading: The Real Cost of Outsourcing Software Development: Lessons from 100+ Projects

Related reading: How to Hire Vietnamese Developers without the Headache: A Technical Leader’s Guide

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.