Right Sizing GitHub Actions Runners
GitHub Actions reports job duration and nothing about the machine. Right size runners from peak CPU, peak memory and the per job recommendation endpoint.
Last verified:
Right sizing a GitHub Actions runner means matching the vCPU and memory of the label to the peak the job actually reaches, measured across many runs rather than one. On WarpBuild that comes from two inputs: a per job recommendation endpoint that returns a suggested label for every job in the last 7 days that should move, and the underlying utilization metrics, which let you redo the arithmetic before you edit a workflow file.
This guide covers the signals that separate an undersized job from an oversized one, the request and response shape of the recommendation endpoint, the manual method from peak CPU, peak memory and the duration profile, and a worked monthly model for a fleet where the qualifying job families move one size down.
Diagnosis
GitHub Actions reports how long a job took. It reports nothing about how hard the machine worked while the job ran, so duration alone cannot separate a job that needs more cores from a job that spends its minutes waiting on a network call.
WarpBuild agents collect CPU, memory, filesystem, and network utilization from every runner they operate. The observability documentation defines each metric and the threshold at which the Recommendations view flags an instance as under provisioned.
| Metric | What it records | Threshold that raises a flag |
|---|---|---|
| Max sustained CPU | Maximum rolling average CPU over the last 30 seconds | 80 percent or above, labelled High CPU Usage |
| Max memory utilization | Maximum memory usage percentage | 80 percent or above, labelled High Memory Usage |
| Max filesystem utilization | Maximum storage usage percentage | 80 percent or above, labelled High Filesystem Usage |
| Max disk I/O | Maximum rolling average of read plus write throughput over the last 30 seconds | 80 percent or above of supported throughput, labelled High Disk IO |
Three readings cover most jobs. Peak CPU near 100 percent with memory headroom is a job that returns wall clock time for cores. Peak memory near 100 percent with CPU in the middle of the range is a memory bound job, and adding cores at the same ratio changes nothing. Both metrics in the low tens for the whole run is an oversized job paying for capacity it never touches, which is where a downgrade lives.
Two collection limits shape how you read the numbers. Metrics are collected only for jobs longer than about one minute, so short jobs carry no utilization history and stay at whatever label they were given. And a single run is not evidence: a cold cache, a retry, or one unusually large pull request moves a peak far enough to mislead. Aggregate across runs and read the P90, which is what the Jobs report and the recommendation endpoint both do. Runner level metrics for GitHub Actions jobs covers those reports field by field, and runner utilization defines the underlying term.
Queue time is a separate axis. A job that waits before it starts is not a sizing problem, and moving it up a size makes the bill worse without touching the wait. How do I detect an undersized runner separates the two signals.
Fix
Two paths lead to the same decision. Start with the endpoint, then check the arithmetic by hand on the jobs where the money is.
Read the recommendation endpoint
Create a key with the ci scope on the API keys page, as described in the automation documentation, then call the recommendations endpoint.
curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \
-H 'Authorization: Bearer wkey-xxxx' \
-H 'Accept: application/json' \
--data-urlencode 'search=integration' \
--data-urlencode 'page=1' \
--data-urlencode 'per_page=200'The query parameters are search (text across repository, workflow name, and job name), repo_workflows (repeatable, each value a repository and workflow name joined by a newline), account_id (a GitHub org or user id, keeping only repositories that account owns), page (1-based, default 1), and per_page (default 50, maximum 200).
The response returns one entry per job that should move. Jobs already on the right size are absent, so an empty recommendations array is the healthy result.
{
"organization_id": "worg-xxxx",
"page": 1,
"per_page": 200,
"total_pages": 1,
"total_rows": 12,
"recommendations": [
{
"repository": "acme/platform",
"workflow_name": "ci.yml",
"job_name": "unit-tests",
"workflow_url": "https://github.com/acme/platform/actions/workflows/ci.yml",
"total_runs": 412,
"recommendation": {
"type": "downgrade",
"current_label": "warp-ubuntu-latest-x64-16x",
"current_vcpu": 16,
"current_memory_gb": 64,
"recommended_label": "warp-ubuntu-latest-x64-8x",
"recommended_vcpu": 8,
"recommended_memory_gb": 32,
"resources": ["cpu", "memory"],
"already_at_max_size": false
},
"runs": [
{
"runner_instance_id": "wrun-xxxx",
"runner_display_name": "warp-ubuntu-latest-x64-16x",
"started_at": "2026-08-11T09:14:02Z",
"ended_at": "2026-08-11T09:21:36Z",
"max_sustained_cpu": 31.4,
"max_memory_utilization": 27.8,
"max_filesystem_utilization": 18.2,
"avg_filesystem_utilization": 15.6,
"max_sustained_disk_io_bytes": 41943040,
"max_sustained_network_bytes": 10485760,
"job_link": "https://github.com/acme/platform/actions/runs/1234567890/job/9876543210"
}
]
}
],
"available_filters": { "repos": [] }
}Three fields carry the decision. recommendation.type is upgrade or downgrade. recommendation.resources is the subset of cpu, memory, and disk that is hot on an upgrade or underused on a downgrade, which tells you whether cores or RAM drove the call. And already_at_max_size is set when the job is on the largest runner and still needs more, which is a signal to shard the work rather than buy a wider machine. The endpoint is marked alpha in the API reference, so pin the fields you parse and re-check them at each verification pass.
Do the arithmetic yourself
Utilization arrives as a percentage of the machine, so one multiplication turns it into an absolute figure that transfers between labels.
Take a job on warp-ubuntu-latest-x64-16x, which carries 16 vCPU and 64 GB, with max sustained CPU at 34 percent and max memory utilization at 30 percent, both read at P90 across a month of runs.
0.34 x 16 vCPU = 5.4 vCPU held
0.30 x 64 GB = 19.2 GB held
on warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB):
5.4 / 8 = 68 percent CPU
19.2 / 32 = 60 percent memoryBoth projections stay under the 80 percent flag threshold, so the smaller label fits with headroom. Apply the same projection in the other direction and the memory floor becomes obvious: a job peaking at 62 percent of 64 GB holds 39.7 GB, which is above the 32 GB of the 8 vCPU size, so 16 vCPU is its floor on this ladder. The Linux ratio is fixed at 4 GB per vCPU across every size, so a memory bound job buys cores it does not need on the way to the RAM it does.
| Runner label | vCPU | RAM | Rate per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 |
Rates come from the pricing page and are billed per minute. Each step down the ladder halves the rate and halves both resources, which is why the projection above is the whole decision for a Linux x64 job. The same two multiplications apply on each once you read the vCPU and memory figures for the label.
CPU projections carry one caveat. A CPU bound job stretches roughly in proportion to the cores you remove, while a job dominated by package installs, network calls, or a database barely moves. Treat the projection as a candidate and confirm it with a week of runs at both labels.
Configuration
Set the label from the measurement, and run the recommendation check on a schedule so drift shows up as a diff rather than a surprise on the invoice.
name: ci
on:
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- run: make test
integration-tests:
runs-on: warp-ubuntu-latest-x64-16x
steps:
- uses: actions/checkout@v4
- run: make integrationTo compare two sizes with evidence instead of argument, run the same job at both for a week and read duration at P90 from the Jobs report:
jobs:
size-trial:
strategy:
fail-fast: false
matrix:
label:
- warp-ubuntu-latest-x64-8x
- warp-ubuntu-latest-x64-16x
runs-on: ${{ matrix.label }}
steps:
- uses: actions/checkout@v4
- run: make testThe weekly drift check turns the endpoint into a report your team reads without opening a dashboard:
name: runner-right-sizing
on:
schedule:
- cron: "0 8 * * 1"
workflow_dispatch:
jobs:
recommendations:
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: Fetch recommendations
env:
WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }}
run: |
curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \
-H "Authorization: Bearer $WARPBUILD_API_KEY" \
-H 'Accept: application/json' \
--data-urlencode 'per_page=200' \
> recs.json
echo "| Repository | Job | Move | From | To |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- | --- | --- | --- |" >> $GITHUB_STEP_SUMMARY
jq -r '.recommendations[]
| "| \(.repository) | \(.job_name) | \(.recommendation.type) | \(.recommendation.current_label) | \(.recommendation.recommended_label) |"' \
recs.json >> $GITHUB_STEP_SUMMARYCost or Time Model
Substitute your own numbers. This fleet runs four job families on Linux x64 for a month, and the recommendation endpoint returns a downgrade for two of them.
| Job family | Label | Jobs per month | Billed minutes per job | Minutes | Rate | Cost |
|---|---|---|---|---|---|---|
| Unit tests | warp-ubuntu-latest-x64-16x | 6,000 | 7 | 42,000 | $0.032 | $1,344.00 |
| Lint and typecheck | warp-ubuntu-latest-x64-8x | 9,000 | 3 | 27,000 | $0.016 | $432.00 |
| Integration tests | warp-ubuntu-latest-x64-16x | 1,200 | 22 | 26,400 | $0.032 | $844.80 |
| Package build | warp-ubuntu-latest-x64-4x | 2,000 | 9 | 18,000 | $0.008 | $144.00 |
| Total | 18,200 | 113,400 | $2,764.80 |
Unit tests peak at 34 percent CPU and 30 percent memory, and lint peaks at 39 percent CPU and 22 percent memory, so both project under 80 percent one size down. Integration tests peak at 91 percent CPU and stay where they are. The package build peaks at 74 percent of 16 GB, which is 11.8 GB, above the 8 GB of the 2 vCPU size, so it stays too.
| Job family | New label | Minutes | Rate | Cost |
|---|---|---|---|---|
| Unit tests | warp-ubuntu-latest-x64-8x | 42,000 | $0.016 | $672.00 |
| Lint and typecheck | warp-ubuntu-latest-x64-4x | 27,000 | $0.008 | $216.00 |
| Integration tests | warp-ubuntu-latest-x64-16x | 26,400 | $0.032 | $844.80 |
| Package build | warp-ubuntu-latest-x64-4x | 18,000 | $0.008 | $144.00 |
| Total | 113,400 | $1,876.80 |
That is $888.00 per month against $2,764.80, or 32 percent of the runner line, from two label edits and no workflow rewrites. The model assumes duration holds at the smaller size. Rerun it with the pessimistic case: if both moved families take 10 percent longer, their minutes become 46,200 and 29,700, costing $739.20 and $237.60, for a monthly total of $1,965.60 and a reduction of $799.20. Either way the direction holds, and a week of matrix runs replaces the assumption with a measurement.
The remaining lever is the rate itself on the jobs that keep their size. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, GitHub list price checked on 2026-08-13 in the GitHub Actions billing reference. The 26,400 integration minutes cost $844.80 here against $1,108.80 at that list price for identical machine shapes.
Two structural notes for the budget. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a label change moves the bill by nothing more than the minute arithmetic above, and per repository or per label attribution of the result is covered in how do I see cost per runner label. Signup includes $10 free credits, which covers a week of one job family at the new label before you commit the change.
The metrics this guide reads come from CI observability. For the estate-wide view that these per job decisions roll up into, see GitHub Actions observability.
FAQ
How do I know whether a GitHub Actions runner is oversized?
Read peak CPU and peak memory across many runs rather than one, then project both onto the next size down. A job on warp-ubuntu-latest-x64-16x peaking at 34 percent CPU and 30 percent memory is holding 5.4 vCPU and 19.2 GB, which lands at 68 percent CPU and 60 percent memory on warp-ubuntu-latest-x64-8x. Both stay under the 80 percent threshold that WarpBuild observability uses to flag an instance, so the smaller label fits.
What does the WarpBuild runner recommendation endpoint return?
One entry per job in the organization's last 7 days that should move to a different runner size, plus a sample of that job's recent runs. Each entry carries the repository, workflow name, job name, total run count, and a recommendation object holding the type (upgrade or downgrade), the current label with its vCPU and memory, the recommended label with its vCPU and memory, the resources that drove the call, and an already_at_max_size flag. The endpoint is marked alpha in the API reference, so pin the fields you parse.
Will moving down a size make my jobs slower?
It depends on what the job waits for. A CPU bound job stretches roughly in proportion to the cores you remove, while a job that spends its time on network calls, package installs, or a database barely moves. Run both labels through a matrix for a week and compare duration at P90 before you delete the larger one, and rerun the cost model with the observed duration rather than the assumed one.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.