Identify high cost GitHub Actions workflows
To identify high cost GitHub Actions workflows you need three numbers that the billing dashboard does not give you: minutes by workflow, minutes by job inside the worst workflow, and the share of your runs that are retries of a previous attempt. All three come from the REST API and the usage report, and the third one is the one almost nobody measures and frequently the largest.

The billing page shows a total. A total tells you that CI costs money and nothing about where it goes, and optimisation aimed at the wrong job is the most common way a team spends a sprint and saves nothing.
Everything below is read-only and takes under an hour on a repository you already have a token for. Do it before you change a single workflow file, and rank what you find by money rather than by minutes, because a Linux job and a macOS job of the same length are not the same line on the invoice.
One: minutes by workflow
The workflow timing endpoint returns billable milliseconds per operating system for a single workflow, which is exactly the breakdown the billing dashboard will not give you. Loop it over the workflow list and you have a ranked table in a few seconds.
Read the result as a ranking rather than as an invoice. The figure is machine time in milliseconds, and the bill rounds the minutes and partial minutes each job uses up to the nearest whole minute, so a workflow made of many short jobs will cost more than this endpoint suggests and a workflow made of a few long ones will cost about what it says.
There is one answer that looks like a broken script and is not. On a public repository the billable object comes back empty, because the use of standard GitHub-hosted runners is free and unlimited there. If you get nothing back from a public repository, nothing is wrong: there is nothing to bill. Larger runners are the exception and are charged on public repositories too.
# per-workflow billable time, ranked
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 -rnTwo: where the minutes go inside that workflow
Once you know which workflow is expensive, the question is which job inside it. The run timing endpoint gives billable milliseconds per operating system for a single run along with the total run duration, and the jobs endpoint gives you the start and completion timestamps of every job in that run.
Average across the last fifty runs rather than reading one. A single run tells you about one moment of registry weather; fifty tell you which job is structurally slow. The pattern that comes back most often is that dependency installation, not compilation and not the tests, is the largest single block of time in the pipeline.
Keep the two operating systems separate while you do it. A five-minute macOS job and a five-minute Linux job are $0.31 and $0.03 respectively, so a ranking by duration puts them side by side and a ranking by cost puts them ten rows apart. Sort by minutes multiplied by that runner type's published rate, never by minutes alone.
# average per-job duration across the last 50 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 -rnThree: the number nobody measures
Every workflow run carries a run_attempt field, and it is 1 on a first attempt. Count how many of your recent runs are at attempt 2 or higher and you have the share of your bill that bought the same work twice.
This is the measurement that changes what you do next, because no per-minute discount touches it. A pipeline that re-runs a meaningful share of its jobs is paying for the failure, paying again for the retry, and paying a third time in the hour between the failure and somebody noticing. If that share is more than a few percent, reliability is your cost problem and the rate is a distraction.
Look at what the retries are for while you are there. Retries of registry timeouts, DNS failures and runner losses are a different problem from retries of a genuinely flaky test, and they are the ones with nothing to do with your code at all.
# what fraction of recent runs are attempt 2 or later?
gh run list --limit 200 --json databaseId --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}'The one report the API cannot replace
The API is per repository. The usage report is per account, and it is the only surface that attributes spend to a workflow file across an organisation. GitHub offers a summarized report covering up to a year and a detailed report covering up to 31 days, and the detailed one adds two fields that matter here: username and workflow_path.
Read discount_amount before you read anything else. That field carries the usage that was discounted, including your included allowance and the discounts for standard runners in public repositories and for self-hosted runners. Reading gross_amount without it is how a team concludes that its open source project costs thousands of dollars a month.
The reports are delivered by email to the address on the account and you can only request one at a time, so start the request before you begin the API work rather than after. Group the result by sku first: that single grouping usually answers the question, because the expensive SKU is rarely the one people name when you ask them to guess.
| Question | Where the answer is | What that source cannot tell you |
|---|---|---|
| Which workflow costs most? | Workflow timing endpoint, per repository | Anything about rounding, or about other repositories |
| Which job inside it? | Run timing and jobs endpoints | Money, until you multiply by the runner rate yourself |
| How much is re-runs? | run_attempt on recent runs | Why they were re-run |
| Who and what across the org? | Detailed usage report, 31 days | Anything older than 31 days |
| Is storage the problem? | Usage report, grouped by SKU | Which artifact or cache is responsible |
Turn the measurement into a decision
Five findings, five different next moves. If installation dominates, cache the dependency directory and key it to the lockfile hash. If a wide matrix of short jobs dominates, the rounding is the cost and consolidation is the fix. If one long job dominates, shard it or size it up, and check the break-even first. If re-runs dominate, nothing about runner choice helps you. If macOS dominates, move everything that does not need Xcode, because that minute is more than ten times a Linux one.
Only after that is the rate worth touching. A cheaper runner multiplies whatever is left, so it is the last change rather than the first, and it is worth the most to the team that has already removed the work it was never going to need. How to reduce GitHub Actions costs prices seven changes against one worked month, and the cost calculator prices your own month at two rates.
If the numbers came back surprising rather than clarifying, why is my CI so expensive works through what each shape of invoice usually means.
Key takeaways
- Rank workflows by minutes multiplied by that runner type's rate, never by minutes alone.
- The workflow timing endpoint returns billable milliseconds, and returns nothing at all on a public repository.
- Count
run_attemptabove 1: re-run share is the cost measurement almost nobody takes. - Only the detailed usage report carries
workflow_pathandusername, and only for 31 days.
Frequently asked questions
How do I see GitHub Actions minutes by workflow?
Why does the timing endpoint return nothing for my repository?
What is the biggest hidden GitHub Actions cost?
run_attempt above 1 before optimising your per-minute rate; above a few percent it dominates any rate difference on the market.What is the difference between the summarized and detailed usage reports?
username and workflow_path, which is what lets you attribute spend to a specific workflow file. Both arrive by email and you can only request one at a time.Related guides
References
- GitHub REST API: Actions workflow runs, timing and jobs endpoints (verified 2026-09-21)
- GitHub billing reports: usage report types and every field (verified 2026-09-21)
- GitHub: Actions runner pricing, the rates to multiply minutes by (verified 2026-09-21)
- GitHub: Actions billing, rounding, allowances and public repositories (verified 2026-09-21)
- GitHub Actions documentation