Skip to content
Latchkey LogoLatchkey home

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.

Three reads: minutes by workflow, minutes by job, and the share of runs that are second attempts
The three measurements and where each one comes from. Endpoint paths from the GitHub REST API reference for Actions workflow runs, read on 21 September 2026.

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.

Terminal
# 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 -rn

Two: 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.

Terminal
# 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 -rn

Three: 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.

Terminal
# 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.

QuestionWhere the answer isWhat that source cannot tell you
Which workflow costs most?Workflow timing endpoint, per repositoryAnything about rounding, or about other repositories
Which job inside it?Run timing and jobs endpointsMoney, until you multiply by the runner rate yourself
How much is re-runs?run_attempt on recent runsWhy they were re-run
Who and what across the org?Detailed usage report, 31 daysAnything older than 31 days
Is storage the problem?Usage report, grouped by SKUWhich 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_attempt above 1: re-run share is the cost measurement almost nobody takes.
  • Only the detailed usage report carries workflow_path and username, and only for 31 days.

Frequently asked questions

How do I see GitHub Actions minutes by workflow?
Call the workflow timing endpoint for each workflow id, which returns billable milliseconds broken down by operating system, and sort the result. The billing dashboard does not provide this breakdown. Treat the numbers as a ranking, because the invoice rounds every job up to a whole minute and this endpoint does not.
Why does the timing endpoint return nothing for my repository?
Almost certainly because the repository is public. Standard GitHub-hosted runners are free and unlimited on public repositories, so there are no billable milliseconds to report. Larger runners are billed even on public repositories, so a public repository using those will still return figures.
What is the biggest hidden GitHub Actions cost?
Re-runs. A retry buys the same work a second time, and the failure is often unrelated to the code that triggered it. Measure the share of runs with a 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?
The summarized report covers up to a year and sums usage by date, SKU, repository and cost centre. The detailed report covers a maximum of 31 days and adds 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

Re-runs are the one line a cheaper minute cannot touch. Latchkey runners repair transient failures instead. Start free → 30-day trial · No credit card