How to Measure What Your GitHub Actions Actually Cost
Most teams optimise the job they assume is expensive. Pull the numbers first: the answer is usually dependency install, a wide matrix, or re-runs, and almost never the step people were about to rewrite.
GitHub shows you a total, which tells you that CI costs money and nothing about where it goes. Before changing a single workflow, get a per-workflow and per-job breakdown, because optimisation aimed at the wrong step is the most common way teams spend a sprint and save nothing.
There are three numbers worth having: minutes by workflow, minutes by job within the expensive workflows, and the share of runs that are re-runs of a previous failure. The third is the one almost nobody measures and frequently the largest.
Minutes by workflow
# per-workflow usage for the repository
gh api /repos/{owner}/{repo}/actions/workflows --paginate \
--jq '.workflows[] | "\(.id)\t\(.name)"' |
while IFS=$'\t' read -r id name; do
ms=$(gh api "/repos/{owner}/{repo}/actions/workflows/$id/timing" \
--jq '[.billable[]?.total_ms] | add // 0')
printf "%8.1f min %s\n" "$(echo "$ms/60000" | bc -l)" "$name"
done | sort -rnWhere the minutes go inside a workflow
# per-job duration across recent runs of one workflow
gh run list --workflow=ci.yml --limit 50 --json databaseId --jq '.[].databaseId' |
while read -r id; do
gh api "/repos/{owner}/{repo}/actions/runs/$id/jobs" \
--jq '.jobs[] | [.name, (((.completed_at|fromdate) - (.started_at|fromdate))/60)] | @tsv'
done | awk -F'\t' '{s[$1]+=$2; n[$1]++} END {for (k in s) printf "%8.1f min avg %4d runs %s\n", s[k]/n[k], n[k], k}' | sort -rnThe number nobody measures: re-run share
A re-run buys the same work twice. If a meaningful share of your runs are retries of a previously failed run, that is pure waste and no per-minute discount touches it.
# what fraction of recent runs are attempt 2 or later?
gh run list --limit 200 --json databaseId,conclusion --jq '.[].databaseId' |
while read -r id; do
gh api "/repos/{owner}/{repo}/actions/runs/$id" --jq '.run_attempt'
done | awk '{t++; if ($1>1) r++} END {printf "re-runs: %d of %d (%.1f%%)\n", r, t, 100*r/t}'Convert minutes to money correctly
| Runner | Rate | 10,000 min/month |
|---|---|---|
| Linux 2-core | $0.006/min | $60 |
| Linux 4-core | $0.012/min | $120 |
| Linux 8-core | $0.022/min | $220 |
| Windows 2-core | $0.010/min | $100 |
| macOS 3-4 core | $0.062/min | $620 |
Now decide what to change
- Install dominates: cache the dependency directory and key it to the lockfile hash.
- A wide matrix of short jobs dominates: the per-minute rounding is the cost. Consolidate jobs or move to a per-second biller.
- One long job dominates: shard it, or move that job alone to a larger or faster runner.
- Re-runs dominate: nothing about runner choice fixes this. Find the flaky failures and address them at source.
- macOS dominates: it is 10x Linux per minute. Move anything that does not genuinely need macOS.
Frequently asked questions
How do I see GitHub Actions minutes by workflow?
gh api /repos/{owner}/{repo}/actions/workflows/{id}/timing returns billable milliseconds per workflow. Loop it over the workflow list to get a ranked breakdown, which the billing UI does not provide.