Where CI minutes actually go: building a cost model
Bills are calculated in minutes, not dollars, so optimize the variable you can actually change. A useful model is:
Billed minutes = runs × jobs per run × average job duration × runner multiplier
Price per minute is set by your plan and changes rarely. The other four terms change every time someone edits a workflow file. Measure them before optimizing. Otherwise teams shave seconds off a lint job while a matrix build on an expensive runner type consumes most of the budget. Backend and platform engineers who own shared pipelines benefit most, because one workflow change affects every contributor's runs.
Attributing minutes
GitHub Actions provides usage and metrics views at the organization and repository level (see the GitHub Actions documentation). Break minutes down by workflow, then by job, then by trigger event (push, pull_request, schedule). The trigger split matters most. It shows whether spend comes from developer iteration, from merges, or from scheduled jobs that may no longer have an owner. Each job is billed separately, and hosted runners round each job up to a whole minute. Many short jobs therefore cost more than their wall-clock time suggests.
Illustrative estimate (assumed numbers, not a benchmark)
All inputs below are assumed, including the multipliers. Check your plan for real values.
| Workflow | Trigger | Runs/day | Jobs/run | Avg min | Multiplier | Billed min/day |
|---|---|---|---|---|---|---|
| Unit tests | pull_request | 120 | 6 | 9 | 1× | 6,480 |
| macOS build | pull_request | 40 | 1 | 15 | 10× | 6,000 |
| Lint | push | 200 | 1 | 2 | 1× | 400 |
| Integration | schedule | 1 | 4 | 30 | 1× | 120 |
Of the 13,000 billed minutes per day, two workflows account for 12,480, about 96%. Those two are where cuts pay off.
Who should use this approach
This guide is for the people who own CI configuration and its bill:
- Platform, DevOps and build engineers who maintain shared workflows. A single change to a reusable workflow or a monorepo pipeline changes the minutes billed for every contributor.
- Maintainers of active repositories with many pull requests per day, large test matrices, or jobs on macOS or Windows runners, which carry a higher per-minute multiplier than Linux.
- Engineering leads with a CI budget who need to explain where spend goes and decide whether to change plans, runner sizes or hosting.
It matters less for small private repositories that stay inside their plan's included minutes, and for public repositories using GitHub's standard hosted runners, which are not billed per minute. Those teams can still use the same techniques to shorten feedback time, but the cost argument is weak.
When to apply these techniques
Start this work when one of these signals appears:
- The monthly bill or minute usage keeps rising faster than the number of contributors or pull requests.
- You regularly exhaust the included minutes on your plan before the billing period ends.
- Jobs wait in a queue before starting, which means runner capacity, not test duration, is slowing feedback.
- Someone has recently added a matrix dimension, a macOS or Windows job, or a new scheduled workflow.
- You are considering larger or self-hosted runners. Remove waste first so you size the new capacity against real demand rather than duplicated runs.
Apply the cheapest fixes, such as concurrency cancellation and job-level skips, as soon as you see duplicate or superseded runs in the history. Leave matrix reductions and runner changes until you have measured which workflows dominate spend, because those changes trade minutes against coverage or operational effort.
How the cost reductions work
Each technique in this article lowers one term of the cost model:
- Runs: concurrency groups with
cancel-in-progressstop superseded runs, and removing duplicatepushandpull_requesttriggers stops the second run of the same commit. Both reduce the number of runs that are billed. - Jobs per run: job-level
ifconditions skip jobs whose inputs did not change, and a smaller pull request matrix starts fewer jobs per run. Merging tiny jobs removes per-job setup and the round-up to a whole minute. - Average job duration: dependency caching and building once with artifacts remove repeated work inside each job.
timeout-minutescaps the duration of hung jobs. - Runner multiplier: choosing runner sizes by cost per completed job, or moving to self-hosted runners, changes the price of each minute rather than the number of minutes.
The order of the sections follows the same logic. Cutting runs and jobs that cannot change the outcome removes minutes entirely. Caching and runner changes only make the remaining minutes cheaper or shorter, so they come later.
Identifying wasted pipelines: runs that cannot change the outcome
These four patterns cover most wasted minutes. Each one looks different in run history, so you can tell from the data which ones apply to you.
Duplicate triggers
A workflow listening to both push and pull_request runs twice for every commit pushed to a branch with an open PR. In usage data, the same head SHA shows up in two runs with different event types. If your runs-per-commit ratio is close to 2 on PR branches, this is the cause. The second run tests the same code, so it can't change the result.
Superseded commits
Developers often push several commits in a few minutes. Every push starts a full run, but only the newest one decides whether the PR merges. These runs appear as clusters on one branch with start times a few minutes apart, where the older runs finish after a newer run has already started. Every minute spent on those older runs is wasted.
Unread scheduled jobs
Nightly jobs keep running after people stop watching them. A failure that stays red for days with no fix commit is a good sign that nobody reads the results. In the earlier table, that sign is the Integration workflow failing night after night with nobody acting on it. Its cost is small per run, but it never stops.
Full matrices on docs-only changes
When a commit only touches *.md files, it still triggers the whole build-and-test matrix. To find these runs, join the run records with each commit's changed paths.
commit a1 ──┬─ push → test matrix (×6)
└─ pull_request → test matrix (×6) duplicate
commit a2 ──┬─ push → test matrix (×6) supersedes a1
└─ pull_request → test matrix (×6)
commit a3 ──┬─ push → test matrix (×6) only these
└─ pull_request → test matrix (×6) matter
In this diagram, three pushes produce 36 job executions, but only 6 of them decide whether the PR merges.
PR branch with three pushes a few minutes apart (each matrix = 6 jobs)
┌───────────┐ push ┌──────────────────┐
│ commit a1 ├──────────────►│ test matrix ×6 │ superseded
│ │ pull_request ├──────────────────┤
│ ├──────────────►│ test matrix ×6 │ superseded + duplicate
└─────┬─────┘ └──────────────────┘
│ next push
▼
┌───────────┐ push ┌──────────────────┐
│ commit a2 ├──────────────►│ test matrix ×6 │ superseded
│ │ pull_request ├──────────────────┤
│ ├──────────────►│ test matrix ×6 │ superseded + duplicate
└─────┬─────┘ └──────────────────┘
│ next push
▼
┌───────────┐ push ┌──────────────────┐
│ commit a3 ├──────────────►│ test matrix ×6 │ decides the merge
│ │ pull_request ├──────────────────┤
│ ├──────────────►│ test matrix ×6 │ duplicate
└───────────┘ └──────────────────┘
6 runs × 6 jobs = 36 jobs started; only 1 run (6 jobs) can change the result
Three quick pushes to a PR branch that triggers on both push and pull_request start six full matrix runs, but only one of them can decide whether the PR merges.
Cancelling superseded runs and skipping unnecessary jobs
Only the last run on a branch matters, so earlier runs should stop as soon as a newer commit arrives. Add a workflow-level concurrency block with a group keyed on ${{ github.workflow }}-${{ github.ref }} and set cancel-in-progress: true. A new push to the same ref then cancels the run already in flight. Runs on other branches are unaffected because each ref gets its own group.
To skip runs that cannot change the outcome, use paths or paths-ignore under push and pull_request. For finer control, use job-level if conditions driven by a changed-files step. The two approaches interact differently with required status checks:
- Path filters: when a filter skips the whole workflow, the required check never reports, and the pull request waits on it indefinitely.
- Job-level
if: a job skipped this way reports as skipped, which GitHub treats as passing.
So for required checks, the safer pattern is to let the workflow always trigger and decide at the job level.
Avoid placeholder workarounds, such as a duplicate workflow with the same job name that always succeeds. They work, but they double the YAML you maintain and hide which check actually ran.
Don't use these techniques everywhere:
- Deploy jobs: never set
cancel-in-progresson deploys or migrations. A cancelledterraform applyor a half-run schema change leaves external state partially applied. Give these jobs their own concurrency group withcancel-in-progress: false, so runs queue instead of being killed. - Path filters: they are only as correct as your dependency graph. A filter on
services/api/**misses changes to shared libraries, build configuration, CI files and lockfiles. Include those paths explicitly, or a skipped job will let a breaking change through.
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.diff.outputs.api }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: diff
# Match shared libs, build config, CI files and lockfiles, not just the service dir
run: |
base="${{ github.event.pull_request.base.sha || github.event.before }}"
if git diff --name-only "$base" "${{ github.sha }}" \
| grep -qE '^(services/api/|libs/shared/|\.github/|package-lock\.json|tsconfig\.json)'; then
echo "api=true" >> "$GITHUB_OUTPUT"
else
echo "api=false" >> "$GITHUB_OUTPUT"
fi
api-tests:
needs: changes
# The workflow always triggers; a job skipped here reports as skipped, which satisfies required checks
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test --workspace services/api
A GitHub Actions workflow that cancels superseded runs per ref and skips API tests with a job-level if driven by a changed-files step, so required checks still report.
Shrinking the matrix and cutting per-job overhead
A matrix multiplies every other cost in the pipeline. Three operating systems across four language versions is twelve jobs, each paying for runner startup, checkout and dependency install before any test runs. On pull requests, run one or two representative combinations, typically the production OS with the oldest and newest supported versions. Run the full matrix on pushes to main and on a nightly schedule. In GitHub Actions you can do this by selecting the matrix values with an expression on github.event_name.
Use fail-fast deliberately. Leaving it enabled on pull requests cancels sibling jobs once one fails, which saves minutes when a single failure already blocks the merge. Disable it on nightly runs, where you want the complete list of broken combinations rather than the first one.
Set timeout-minutes on every job. The default is 360 minutes, so a deadlocked integration test can bill six hours before anyone notices. A ceiling of two to three times the job's normal duration bounds the damage without causing false failures.
Merge tiny jobs. A lint job that runs for 20 seconds still pays the full setup cost, and GitHub-hosted runners round each job up to the next minute. Run lint, format checks and type checks as steps in one job.
Build once. Upload the compiled output as an artifact and download it in the test and packaging jobs instead of recompiling in each.
The trade-off is that a reduced pull request matrix moves failures to main or nightly runs. Those failures are found after merge, when the author has moved on and the fix costs more.
name: ci
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 3 * * *"
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15 # about 2-3x normal duration; the default is 360
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
# Lint, format and type checks share one job's setup cost
- run: npm run lint && npm run format:check && npm run typecheck
- run: npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
test:
needs: build
timeout-minutes: 20
strategy:
# PRs: cancel siblings on first failure. main/nightly: report every broken combination
fail-fast: ${{ github.event_name == 'pull_request' }}
# PRs: one OS, oldest and newest Node. main/nightly: full matrix
matrix:
os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","windows-latest","macos-latest"]') }}
node: ${{ fromJSON(github.event_name == 'pull_request' && '[18, 22]' || '[18, 20, 22]') }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}" }
- run: npm ci
- uses: actions/download-artifact@v4 # reuse the build output instead of recompiling
with: { name: dist, path: dist/ }
- run: npm test
A GitHub Actions workflow with a small matrix on pull requests and the full matrix on main and nightly runs. It uses fail-fast based on the event, sets timeout-minutes on each job, puts the quick checks in one job, and builds once so the test jobs reuse the artifact.
Caching deterministic inputs without making things worse
Caching cuts the per-job cost that remains after the matrix is trimmed. A dependency cache is keyed on a hash of the lockfile, so the key changes only when resolved versions change. The configuration below uses an exact key built from the OS, toolchain version and lockfile hash. It also uses a restore-key prefix without the hash. On an exact miss, the runner restores the most recent entry that matches the prefix, and the package manager downloads only the difference. The job then saves a new entry under the exact key.
To measure whether the cache helps, record two numbers per job: whether the exact key matched (actions/cache exposes a cache-hit output) and how long the restore step took. Compare restore time plus install time against a clean install on the same runner. Large caches can lose that comparison. Downloading and extracting several gigabytes of rarely changing artifacts can take longer than fetching packages from a nearby registry mirror.
The risks:
- Stale or poisoned content. A restore key that is too broad can pull in artifacts from an unrelated branch. A job that writes to the cache from untrusted pull request code can plant content that later jobs trust.
- Masked non-reproducibility. Builds that pass only on a warm cache hide missing declared dependencies. Run a scheduled job with caching disabled to catch these.
- Limits and eviction. Per-repository storage is capped, and old entries are evicted (see the GitHub Actions documentation). Too many distinct keys evict each other.
- Low hit rates. If most runs miss, every job pays the save cost without getting the restore benefit.
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Mark restore start
run: echo "RESTORE_START=$(date +%s)" >> "$GITHUB_ENV"
# Restore only. Saving happens in a separate, guarded step below.
- name: Restore npm cache
id: npm-cache
uses: actions/cache/restore@v4
with:
path: ~/.npm # cache the download cache, not node_modules (npm ci deletes it)
# Exact key: OS + toolchain + lockfile hash. Changes only when resolved versions change.
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
# Fallback on exact miss: newest entry for the same OS/toolchain. npm then fetches only the delta.
restore-keys: |
npm-${{ runner.os }}-node20-
- name: Install and record timings
run: |
RESTORE_SECS=$(( $(date +%s) - RESTORE_START ))
INSTALL_START=$(date +%s)
npm ci
# Log these per run, then compare restore_s + install_s against a cache-disabled run.
echo "cache_exact_hit=${{ steps.npm-cache.outputs.cache-hit }} restore_s=$RESTORE_SECS install_s=$(( $(date +%s) - INSTALL_START ))"
# Save only on an exact miss, and only from trusted pushes to main,
# so pull request code cannot write entries that later jobs restore.
- name: Save npm cache
if: steps.npm-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
A GitHub Actions job that restores an npm cache with an exact lockfile-hash key and a prefix restore key, logs the exact-hit flag and the restore and install times, and saves new entries only from trusted pushes to main to limit cache poisoning.
Alternatives: bigger runners and self-hosted runners
Both alternatives change the price of a minute. Neither changes how many minutes you burn on runs that cannot change the outcome, which is why they come after the waste work above. Making a wasted run faster still leaves it wasted.
Larger hosted runners
Compare runner sizes by cost per completed job, not cost per minute. A larger runner only pays off when the job actually uses the extra cores.
Illustrative estimate (assumed inputs): a 4× runner costs 4× per minute (rate r).
- CPU-bound, parallel compile: drops from 40 to 9 minutes. Cost goes from 40r to 36r, so the job is cheaper and faster.
- Network-bound dependency fetch and deploy: drops from 10 to 9 minutes. Cost goes from 10r to 36r, so you pay more than three times as much for one minute saved.
Self-hosted runners
Self-hosted runners, including Actions Runner Controller on Kubernetes (see the GitHub Actions documentation), lower the price of compute. They also add costs that a per-minute rate comparison leaves out.
| Cost factor | Hosted runners | Self-hosted / ARC |
|---|---|---|
| Compute | Per-minute rate | Your nodes, billed while up |
| Idle time | None | Warm pools and scale-down lag |
| Image maintenance | Provider | You patch and rebuild |
| Isolation | Fresh VM per job | You must enforce it |
| On-call | Provider | Your team |
Isolation matters most for public repositories. A pull request from a fork runs untrusted code. On a persistent self-hosted runner, that code can read cached credentials or tamper with jobs that run after it. Use ephemeral, single-job runners, or keep untrusted pull requests on hosted runners.
Impact on cost and engineering time, and making savings stick
Price every change in both minutes and wait time, because a cut that slows feedback can lose more in engineer hours than it saves.
Illustrative estimate (assumed numbers, not a benchmark). Net monthly value = (minutes saved × price per minute) − (added wait minutes per run × runs someone waits on ÷ 60 × loaded hourly cost)
Assume a change saves 2,000 minutes at $0.008 per minute, which is $16. If it adds 3 minutes to 400 runs that engineers wait on, at $100 per hour, the wait costs 400 × 3 ÷ 60 × 100 = $2,000. The net result is a loss of $1,984.
Removing waste usually avoids this trade-off, because skipped and cancelled runs also free runner capacity and shorten queues.
| Change | Minutes | Feedback time |
|---|---|---|
| Cancel superseded runs | Down | Faster, because queues are shorter |
| Path filters or skipped jobs | Down | Faster |
| Smaller matrix on PRs | Down | Faster on PRs, but some failures surface later on main |
| Moving tests to a nightly run | Down | Slower, so failures are found days later |
Savings erode as new workflows are added. Three practices keep them in place:
- Quarterly review of the top minute consumers. Use the Actions usage metrics. Most spend usually sits in a few workflows, so the review stays short.
- A named owner for each workflow. Without one, nobody is accountable for growth or can approve removing a workflow.
- Review guardrails. Flag new or edited workflows that trigger on every push with no path filters or concurrency group.
Prioritized order, from cheapest to most involved:
- Add concurrency cancellation.
- Add path filters and conditional jobs.
- Trim the PR matrix.
- Add caching.
- Change runner sizes.
- Move to self-hosted runners.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to reduce ci costs, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.