Guides / Find and fix CI cost hotspots

Too much work per run

Find and fix CI cost hotspots

By Keith Mazanec, Founder, CostOps ยท Updated February 17, 2026

Your repository has 12 workflows. You spend two weeks optimizing the lint job. It saves 45 seconds per run. Meanwhile, one workflow accounts for 70% of your total CI spend, and nobody has looked at it. CI cost is almost always concentrated. A small number of workflows (often just one) drive the majority of your bill. Finding those hotspots and fixing them first yields outsized savings compared to spreading effort across every workflow.

Why GitHub Actions cost is concentrated in one workflow

GitHub Actions cost follows a power law. In most repositories, one workflow accounts for 55-75% of total CI spend. A 30% reduction in that single workflow saves more than a 10% reduction spread across all workflows combined. The highest-ROI approach to reducing GitHub Actions costs is to identify your most expensive workflow, break it down by job name, and apply targeted optimizations to the top-cost jobs.

Symptoms

How to tell if your CI spend is concentrated

Cost concentration is invisible unless you measure it. GitHub's billing page shows total minutes, not per-workflow breakdowns. Look for these patterns:

  • One workflow dominates the Actions tab. Open your repository's Actions tab and sort by workflow. If one workflow has 3-5x the run count of the next, it's almost certainly your cost leader. High frequency alone can make a moderate workflow the most expensive one.

  • The top workflow has expensive runners or long durations. A workflow that runs on macOS (~10x Linux rate) or uses larger runners (4-core at $0.012/min) compounds quickly. Even a 15-minute workflow at $0.012/min costs $3.60/day if it runs 20 times.

  • Optimization efforts feel scattered. Your team has tried caching, test splitting, and path filters across different workflows, but the bill hasn't meaningfully dropped. This usually means the optimizations missed the one workflow that actually matters.

Metrics

How much can you save by optimizing one workflow?

In most repositories, the top workflow accounts for 55-75% of total CI spend. A targeted 30% reduction in that workflow saves $234/month on a $1,200/month bill. Here's a typical distribution:

Before (unfocused optimization)

Total workflows 8
Top workflow share 65%
Top workflow cost $780/mo
Monthly total $1,200/mo

Optimizing 7 other workflows saves ~$50/mo total

After (targeted top workflow)

Total workflows 8
Top workflow share 52%
Top workflow cost $546/mo
Monthly total $966/mo

Save $234/mo · $2,808/year · 30% reduction in top workflow

A 30% reduction in your single most expensive workflow saves more than a 10% reduction spread across all 8 workflows combined. The savings scale with runner cost. On macOS runners at $0.062/min (~10x the Linux rate), that same top workflow costs $8,060/mo, and a 30% reduction saves $2,418/mo.


Fix 1

How to find which GitHub Actions workflow costs the most

GitHub's billing page shows total minutes consumed per repository, but not per workflow. To get a per-workflow cost breakdown, use the GitHub Actions workflow runs API. The /repos/{owner}/{repo}/actions/runs/{run_id}/timing endpoint returns billable minutes per runner OS (UBUNTU, WINDOWS, MACOS). Aggregate these by workflow name and multiply by the per-minute rate to get cost per workflow.

Here's a script that uses the GitHub CLI to pull workflow run data and calculate cost by workflow name. It queries the last 30 days and applies GitHub's standard per-minute rates.

cost-by-workflow.sh
#!/bin/bash
# Calculate CI cost per workflow for the last 30 days
# Requires: gh CLI, jq

OWNER="your-org"
REPO="your-repo"
SINCE=$(date -v-30d +%Y-%m-%dT00:00:00Z)

# Fetch workflow runs with timing data
gh api --paginate \
  "/repos/$OWNER/$REPO/actions/runs?created=>=$SINCE&status=completed" \
  --jq '.workflow_runs[] |
    [.name, .run_started_at, .updated_at, .id] | @tsv' \
  | while IFS=$'\t' read -r name started updated run_id; do
      # Get billable minutes per OS for each run
      gh api "/repos/$OWNER/$REPO/actions/runs/$run_id/timing" \
        --jq "[\"$name\",
          (.billable.UBUNTU.total_ms // 0),
          (.billable.WINDOWS.total_ms // 0),
          (.billable.MACOS.total_ms // 0)] | @tsv"
    done | awk -F'\t' '{
      # Accumulate minutes and apply per-minute rates
      linux[$1]  += $2/60000 * 0.006
      win[$1]    += $3/60000 * 0.010
      mac[$1]    += $4/60000 * 0.062
    } END {
      for (w in linux) {
        total = linux[w] + win[w] + mac[w]
        printf "%s\t$%.2f\n", w, total
      }
    }' | sort -t$'\t' -k2 -rn

One caveat: this script makes one API call per run, which can be slow for repositories with thousands of runs. GitHub rate-limits authenticated requests to 5,000/hour. For high-volume repositories, consider using GitHub's usage report CSV export (available under Settings → Billing → Actions) or a tool like CostOps that tracks this continuously.

Fix 2

How to identify the most expensive jobs in a GitHub Actions workflow

After identifying your costliest workflow, break it down by job name to find where the minutes go. A workflow with 6 jobs typically has one job that accounts for 40-50% of its total runtime. That single job is the highest-leverage optimization target.

Use the workflow jobs API to pull job-level timing. Each job has a started_at, completed_at, and runner_name, which tells you exactly how long it ran and on what runner type.

cost-by-job.sh
#!/bin/bash
# Break down cost by job name for a specific workflow

OWNER="your-org"
REPO="your-repo"
WORKFLOW="ci.yml"       # your top-cost workflow file
SINCE=$(date -v-30d +%Y-%m-%dT00:00:00Z)

# Get recent completed run IDs for this workflow
gh api --paginate \
  "/repos/$OWNER/$REPO/actions/workflows/$WORKFLOW/runs?created=>=$SINCE&status=completed" \
  --jq '.workflow_runs[].id' \
  | while read -r run_id; do
      # Get jobs for each run
      gh api "/repos/$OWNER/$REPO/actions/runs/$run_id/jobs" \
        --jq '.jobs[] | select(.conclusion == "success" or .conclusion == "failure") |
          [.name,
           ((.completed_at | fromdateiso8601) -
            (.started_at | fromdateiso8601))] | @tsv'
    done | awk -F'\t' '{
      seconds[$1] += $2
      count[$1]++
    } END {
      for (j in seconds) {
        mins = seconds[j] / 60
        printf "%s\t%.0f mins\t%d runs\t$%.2f\n",
          j, mins, count[j], mins * 0.006
      }
    }' | sort -t$'\t' -k4 -rn

The output gives you a ranked list of jobs by cost. A typical result looks like:

Job name Total mins Runs Cost
test-integration 4,200 420 $25.20
build 2,520 420 $15.12
test-unit 1,680 420 $10.08
lint 840 420 $5.04
deploy-preview 420 420 $2.52

In this example, test-integration is 43% of the workflow's cost. That one job is the target. The lint job you spent a week optimizing? It's 9% of the workflow and an even smaller share of overall CI spend.

Fix 3

How to reduce reruns on your most expensive workflow

Reruns on your most expensive workflow cost the most because the per-rerun bill scales with the workflow's size. If 15% of runs for your top workflow are reruns, and that workflow costs $780/month, you're spending roughly $117/month on retries alone. Reruns are often more expensive than first attempts because caches may have been evicted and the full pipeline re-executes instead of just the failed jobs.

Start by checking if the workflow uses concurrency groups to cancel superseded runs. Then look at failure patterns: are most failures from flaky tests, infra timeouts, or genuine code issues?

.github/workflows/ci.yml
name: CI

on:
  pull_request:

# Cancel stale runs on the same PR
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  test-integration:
    runs-on: ubuntu-latest
    # Set a timeout to cap runaway costs
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test:integration

Adding timeout-minutes to your most expensive job is a simple safeguard. Without it, a hung test can burn minutes indefinitely. Set it to 1.5x the p90 duration (if the job normally finishes in 12 minutes, set 18). See our guide to reducing CI timeouts for more detail.

Fix 4

What optimization to apply to each CI job type

Match the optimization to the job type. Any optimization applied to your highest-cost job has a multiplier effect because that job runs on every trigger. The table below maps common GitHub Actions job types to their recommended optimizations.

Job type Optimization Guide
test-* Split by timing, run focused suite on PRs Optimize CI tests
build Cache outputs, build once and share artifacts Build once, use everywhere
setup/install Add dependency caching, consolidate jobs Reduce setup overhead
deploy-* Gate to main/tags, remove from PR runs Separate deploy from CI
lint/typecheck Run on changed files only, use incremental caches Optimize lint/typecheck

For test-heavy hotspot jobs specifically, here's an example of adding dependency caching and test splitting to reduce the most expensive job in your workflow:

.github/workflows/ci.yml (hotspot job optimized)
test-integration:
  runs-on: ubuntu-latest
  timeout-minutes: 20
  strategy:
    matrix:
      shard: [1, 2, 3]
  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: npm

    - run: npm ci

    # Split tests across shards by timing data
    - run: |
        npx jest --shard=${{ matrix.shard }}/3 \
          --ci --forceExit

One caveat: splitting a 10-minute job into 3 shards adds 2 extra jobs of setup overhead (checkout, install, cache restore). If setup takes 2 minutes per shard, you go from 10 minutes to 3 × (2 + 3.3) = 16 total billable minutes. Only shard when the test time is large enough that splitting produces a net reduction in total minutes, not just wall-clock time. See our over-parallelized test suites guide for sizing guidance.


Reference

GitHub Actions per-minute runner pricing

GitHub Actions bills per minute of runner time, rounded up to the nearest minute per job. To calculate per-workflow cost, multiply billable minutes by the runner's per-minute rate. These are the standard hosted runner rates:

Runner Rate Multiplier
Linux 2-core $0.006/min 1x
Windows 2-core $0.010/min ~1.7x
macOS 3-core (M1) $0.062/min ~10x
Linux 4-core $0.012/min 2x
Linux 8-core $0.024/min 4x

Larger runners are available on GitHub Team and Enterprise plans. If your hotspot workflow uses ubuntu-latest-4core or similar, the cost is 2x per minute ($0.012 vs $0.006). Factor this into your calculation when comparing the cost of splitting jobs (which adds more runner-minutes) versus running them on a single larger runner.

Team / Enterprise Larger runners (4-core and above) require GitHub Team or Enterprise Cloud. Standard 2-core runners are available on all plans.

FAQ

Common questions about CI cost hotspots

Does GitHub show per-workflow cost on the billing page?

No. GitHub's billing page shows total minutes consumed per repository and per runner OS, but does not break down cost by workflow name. To get per-workflow data, use the Actions API timing endpoint or export the usage report CSV from Settings → Billing → Actions.

What percentage of CI cost is typically in one workflow?

In most repositories, the single most expensive workflow accounts for 55-75% of total CI spend. The top 3 workflows typically cover over 80% of the bill. This concentration is driven by a combination of run frequency, job count, and runner type.

Should I optimize all workflows equally?

No. Because CI cost is concentrated, optimizing your top workflow yields 5-10x the savings of optimizing lower-cost workflows. A 30% reduction in a workflow that accounts for 65% of spend saves $234/month on a $1,200 bill. The same effort spread across 7 smaller workflows saves roughly $50/month.

How does GitHub Actions round billable minutes?

GitHub rounds each job up to the nearest whole minute. A job that runs for 35 seconds is billed as 1 minute. This rounding matters most for workflows with many short jobs, where the rounding overhead can add 20-30% to the total bill.

Related guides

Guides / Find and fix CI cost hotspots

See which workflows cost the most

CostOps breaks down per-workflow and per-job cost automatically. Find your hotspot in 30 seconds instead of writing scripts.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.