Runner mismatch
Reduce macOS and Windows CI spend
By Keith Mazanec, Founder, CostOps ยท Updated February 2, 2026
A developer adds macos-latest to the CI matrix so tests run on all three platforms. Every PR now spins up a macOS runner at $0.062/min, 10x the Linux rate, to run the same lint and unit tests that have nothing to do with macOS. Multiply that across 20 PRs a day and the bill adds up fast. The fix is straightforward: run platform-independent work on Linux, and reserve macOS and Windows runners for jobs that actually need them.
Symptoms
How to tell if expensive runners are inflating your bill
Check your GitHub Actions usage breakdown by runner type. If macOS or Windows dominate spend but most of the jobs running there are platform-independent, you're overpaying.
-
macOS/Windows dominate your bill. More than 40% of your total CI cost comes from macOS or Windows runners, even though most of your codebase is platform-independent. Linting, formatting, unit tests, and dependency checks rarely need anything beyond Linux.
-
Short jobs on expensive runners. Jobs that finish in under 2 minutes (lint, format, typecheck) are running on macos-latest or windows-latest. GitHub rounds up to the nearest minute, so a 30-second macOS job costs a full minute at $0.062.
-
Full cross-platform matrix on every PR. Your matrix includes os: [ubuntu-latest, macos-latest, windows-latest] and runs on every push. Feature branch pushes trigger macOS and Windows builds for code that will be tested again at merge anyway.
-
Free tier burns out fast. On the Free plan, you get 2,000 included minutes per month. macOS consumes those at 10x: 1 macOS minute = 10 included minutes deducted. That means you effectively get only 200 macOS minutes per month before overages kick in.
Metrics
What macOS and Windows runners actually cost
Consider a cross-platform project running a 3-OS matrix (Linux, macOS, Windows) on every PR. Each platform runs the same test suite for 12 minutes. The team merges 8 PRs per day, and each PR averages 3 pushes.
Before: full matrix on every PR
24 runs × 12 min × 22 days × per-OS rate
After: Linux on PRs, full matrix on main
Save $237/mo · $2,844/year · 59% reduction
The math: PR runs drop from 3 OS to 1 (Linux), saving the macOS and Windows legs entirely. Main-branch runs still cover all three platforms but only fire once per merge (8/day) instead of on every push (24/day). The macOS savings alone account for $215/mo of the reduction, because at $0.062/min even modest minute reductions have outsized impact.
Fix 1
Move platform-independent jobs to Linux
Most CI jobs do not need macOS or Windows. Linting, formatting, typechecking, dependency audits, code coverage, and static analysis all produce identical results on Linux. If the job does not use Xcode, the iOS Simulator, Windows-specific APIs, or a platform-native build toolchain, it belongs on ubuntu-latest.
Even tools associated with Apple development can run on Linux. Swift is preinstalled on Ubuntu runners, and tools like SwiftLint and SwiftFormat support Linux. Only jobs that require platform-specific SDKs (Xcode, .NET for Windows, Win32 APIs) need to stay on expensive runners.
jobs: lint: runs-on: macos-latest steps: - uses: actions/checkout@v4 - run: npm run lint # Lint has no macOS dependency. # Paying 10x for the same result.
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run lint # Same result, 10x cheaper.
A 2-minute lint job on macOS costs $0.124 per run. On Linux, the same job costs $0.012. Over 500 runs/month, that single job shift saves $56/mo. Audit every job in your workflow and ask: does this job use a platform-specific SDK? If not, move it to Linux. For trivial jobs (labeling, notifications), you can go even cheaper with ubuntu-slim at $0.002/min. See overpowered runners for lightweight jobs for runner-sizing strategies beyond OS selection.
Fix 2
Gate cross-platform matrix to main and release branches
If your project needs cross-platform testing, it does not need it on every push to every feature branch. Platform-specific bugs are rare; most failures are logic errors that surface identically on Linux. Run the full OS matrix only on main and release branches, where you need final validation before shipping. On PRs, run Linux only.
Use the matrix exclude pattern with a conditional expression to automatically skip macOS and Windows legs on feature branches:
name: CI on: push: branches: [main] pull_request: jobs: test: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] isMain: - ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} exclude: # Skip macOS on feature branches - os: macos-latest isMain: false # Skip Windows on feature branches - os: windows-latest isMain: false fail-fast: true runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - run: npm ci && npm test
On PRs, the matrix evaluates isMain as false, and the exclude rules remove the macOS and Windows entries. Only ubuntu-latest runs. On pushes to main, isMain evaluates to true, so nothing is excluded and all three platforms run.
One caveat: if you need required status checks that match specific matrix combinations (e.g., test (macos-latest, ...)), those checks will not exist on PRs and branch protection may block merging. Either make only the Linux check required, or use a separate workflow for the full matrix that triggers on push to main only.
Fix 3
Split platform-specific tests into a gated workflow
For finer control, move macOS and Windows testing into a separate workflow that only triggers on main, release branches, or tags. This keeps your PR workflow fast and cheap while still validating platform compatibility before release.
name: CI on: pull_request: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test
name: Cross-Platform Tests on: push: branches: [main] tags: ['v*'] jobs: test: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: npm ci && npm test
This pattern gives you clear separation: the PR workflow is your fast feedback loop (Linux only, sub-5-minute), and the cross-platform workflow is your safety net (all three OSes, runs only on merged code). If a platform-specific bug slips through, you catch it immediately after merge instead of on every PR push.
Fix 4
Set explicit timeouts on expensive runners
The default timeout-minutes in GitHub Actions is 360 (6 hours). A single stuck macOS job at the default timeout would consume 360 minutes at $0.062/min, costing $22.32 for one failed run. Against included minutes, that is 3,600 minutes deducted (10x multiplier), which burns through nearly the entire Team plan quota.
Always set explicit timeouts, especially on macOS and Windows jobs. Base the timeout on the job's actual p90 runtime plus a small buffer.
jobs: test-macos: runs-on: macos-latest timeout-minutes: 20 # p90 is ~12 min; cap at 20 steps: - uses: actions/checkout@v4 - run: npm ci && npm test test-windows: runs-on: windows-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - run: npm ci && npm test
Setting timeout-minutes: 20 on a macOS job caps worst-case cost at $1.24 per run instead of $22.32. On the Free plan, that caps the included-minutes damage at 200 instead of 3,600.
Reference
GitHub Actions runner rates by OS
These are the current standard hosted runner rates as of January 2026. Larger runners (4+ cores) are available on Team and Enterprise Cloud plans at higher per-minute rates but are always billed per-minute (included plan minutes cannot be used).
| Runner | Rate | Multiplier | Cost/hr |
|---|---|---|---|
| Linux 2-core (x64) | $0.006/min | 1x | $0.36 |
| Linux 2-core (arm64) | $0.005/min | 0.8x | $0.30 |
| Windows 2-core | $0.010/min | 2x | $0.60 |
| macOS (M1/Intel) | $0.062/min | 10x | $3.72 |
| macOS 12-core (Intel) | $0.077/min | n/a | $4.62 |
| macOS M2 Pro (5-core) | $0.102/min | n/a | $6.12 |
Larger runners (macOS 12-core, M2 Pro) are only available on Team and Enterprise Cloud plans. They are always billed per-minute; included plan minutes do not apply. Free tier included minutes by plan: 2,000/mo (Free), 3,000/mo (Team/Pro), 50,000/mo (Enterprise). macOS deducts included minutes at 10x; Windows at 2x.
Team / Enterprise Cloud Larger runners require GitHub Team or Enterprise Cloud. Standard runners are available on all plans.
Reference
Free tier impact by runner OS
The included minutes on each plan are consumed at different rates depending on the runner OS. Here is how many effective minutes you get per OS before overages start:
| Plan | Included | Linux | Windows | macOS |
|---|---|---|---|---|
| Free | 2,000 | 2,000 min | 1,000 min | 200 min |
| Team/Pro | 3,000 | 3,000 min | 1,500 min | 300 min |
| Enterprise | 50,000 | 50,000 min | 25,000 min | 5,000 min |
On the Free plan, a team running 10 minutes of macOS CI per day exhausts their entire monthly allocation in 20 days (10 min × 10x × 20 days = 2,000). Moving those jobs to Linux would consume only 200 of the 2,000 included minutes over the same period.
Related guides
Matrix Explosion: When Parallelism Increases Cost
Trim matrix dimensions to reduce combinatorial job growth.
Overpowered Runners for Lightweight Jobs
Match runner size and type to the actual workload.
Underpowered Runners: Paying More by Going Slow
When a faster runner actually costs less per job.
Reduce CI Timeouts
Set explicit timeout-minutes to cap worst-case spend.