Working Out CI Cost per Pull Request
Cost per pull request is jobs triggered times billed minutes times the rate for each runner label, times attempts. Build it from billing rows, then cut it.
Last verified:
Cost per pull request is an arithmetic result rather than a field on any invoice: multiply the jobs a pull request triggers by the billed minutes each one takes, price every job at the per minute rate for its runner label, sum that into a cost per attempt, then multiply by the average attempts per pull request. The inputs come from per job billing rows grouped by repository, and the three levers that move the answer are path filters, cancel-in-progress, and matrix width.
This guide covers why the number resists a direct query, the report path that produces each input, a workflow with all three levers applied, and a worked model that prices one pull request in a multi service repository before and after the changes.
Diagnosis
The unit that gets billed and the unit engineers reason about are different objects, and everything hard about this number follows from that gap.
Billing is per job execution, and a pull request is a group of runs. One row in the CI tab of the Billing report is a single job execution carrying its repository, job name, runner label, stack, execution time, billed time, and cost. Nothing on the row names a pull request. A pull request maps to one or more workflow runs, each run holds many jobs, and each job is a separate row.
Job count per pull request is a variable. A matrix that expands to six legs bills six jobs. A path filter that skips the integration group on documentation changes bills none of them. Two pull requests against the same repository in the same week can differ by a factor of three in jobs triggered, so a single average hides two populations.
Attempts are the multiplier nobody models. Pushing a fixup commit starts a fresh run. A flaky suite gets a re-run. A required check that reruns the whole matrix bills the whole matrix again. Attempts per pull request sits well above one in most repositories, and it multiplies every other term.
Billed time and execution time are separate columns. The billing table reports both, which is what makes a row auditable. Model with billed time, because that is what appears on the invoice.
Repository averages fold expensive repositories into cheap ones. The daily chart in the CI tab groups by repository or by runner label, and that grouping is the level the model has to be built at. A monorepo with a 24 leg e2e matrix and a docs site with one lint job share an organization and share nothing else.
Fix
Write the model down first, then fill each term from a report.
The model
cost per pull request = attempts x SUM over jobs (billed minutes x rate per minute)Four terms, four sources:
| Term | Where it comes from |
|---|---|
| Jobs triggered | CI billing table, filtered to one repository, counted per workflow run |
| Billed minutes per job | The billed time column on each row, or duration P75 from the Jobs report |
| Rate per minute | The runner label on the row, priced from the runner catalog |
| Attempts per pull request | Workflow runs in the period divided by pull requests in the period |
Pull the inputs, grouped by repository
Filter the CI billing table to one repository and a date range that covers at least a full sprint. The summary cards give total cost, runner cost, snapshot cost, and total jobs for that window. The table gives the per row detail, and CSV export writes every row matching the current filters and sort order rather than the visible page, which is the shape the arithmetic wants.
The Jobs section of the same report aggregates per repository, workflow, and job name combination, with run count, success rate, duration at P75 and P90, and queue time at P75 and P90. Use it to get a stable per job minute figure instead of an average dragged around by one long tail run.
For attempts, count workflow runs against pull requests over the same window through GitHub's workflow runs endpoint, which returns the pull requests associated with each run. Dividing total runs by total pull requests gives the attempts term without joining every billing row individually. Per workflow grouping is covered in how do I see cost per workflow, and per team grouping in attributing GitHub Actions cost.
The three levers
Path filters remove job groups from pull requests that cannot have broken them. This is the largest lever in a repository holding more than one service, because it subtracts whole groups rather than trimming minutes. Mechanics and the pattern ordering rules are in running a job only when files change.
cancel-in-progress attacks the attempts term. A concurrency group keyed on the pull request number stops the previous attempt the moment a new commit arrives, so a five commit review stops paying for five complete runs. What re-runs cost when left alone is modelled in re-run storms.
Matrix width is the smallest of the three and the one most often set wrong in both directions. Every leg repeats checkout, dependency install, and toolchain setup, so a wider matrix buys wall clock with billed minutes. Narrowing it recovers minutes and costs wall clock.
Configuration
The workflow below shows the three levers applied together. The labels here are the Linux x64 sizes from the cloud runners catalog.
name: pr
on:
pull_request:
paths:
- 'services/api/**'
- 'services/web/**'
- 'packages/**'
- '!**/*.md'
concurrency:
group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
lint:
runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v5
- run: bun install --frozen-lockfile
- run: bun run lint
unit:
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v5
- run: bun install --frozen-lockfile
- run: bun run test --shard=${{ matrix.shard }}/4
integration:
needs: unit
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- run: make integration
e2e:
needs: unit
runs-on: warp-ubuntu-latest-x64-8x
strategy:
fail-fast: false
matrix:
suite: [checkout, billing, admin]
steps:
- uses: actions/checkout@v5
- run: make e2e SUITE=${{ matrix.suite }}Three details decide whether this behaves the way the model assumes. The trigger mixes includes and excludes with ! prefixes under paths, because paths and paths-ignore cannot both be set for the same event in one workflow, and pattern order decides the result (workflow syntax). The concurrency group is keyed on the pull request number so that attempts on one review cancel each other while other reviews run untouched (concurrency). And fail-fast: false keeps a red leg from cancelling its siblings, which trades a few minutes for a complete failure list on the first attempt.
Cost or Time Model
One multi service repository, 500 pull requests per month, 1.4 attempts per pull request measured as workflow runs divided by pull requests. Every rate comes from the runner catalog on the pricing page, checked on 2026-08-13. The starting state has a six leg unit matrix and no path filters.
| Job group | Runner label | Jobs | Billed minutes each | Minutes | Rate per minute | Cost |
|---|---|---|---|---|---|---|
| lint | warp-ubuntu-latest-x64-2x | 1 | 3 | 3 | $0.004 | $0.012 |
| unit | warp-ubuntu-latest-x64-4x | 6 | 7 | 42 | $0.008 | $0.336 |
| integration | warp-ubuntu-latest-x64-8x | 1 | 12 | 12 | $0.016 | $0.192 |
| e2e | warp-ubuntu-latest-x64-8x | 3 | 9 | 27 | $0.016 | $0.432 |
| Total | 11 | 84 | $0.972 |
One attempt costs $0.972. At 1.4 attempts the average pull request costs $1.3608, and 500 of them cost $680.40 for the month.
Now apply the levers in order. Path filters: 40 percent of pull requests touch a single service, so integration and e2e drop out and those attempts cost $0.348. Matrix width: six unit legs carry 2 minutes of setup each, so 30 minutes of test work plus 12 minutes of setup gives 42; at four legs the same work gives 7.5 minutes of tests plus 2 of setup per leg, or 38 minutes, saving $0.032 per attempt. cancel-in-progress: superseded attempts stop early, taking attempts from 1.4 to 1.15.
| State | Cost per attempt | Attempts | Cost per pull request | 500 pull requests |
|---|---|---|---|---|
| Baseline | $0.972 | 1.4 | $1.3608 | $680.40 |
| Plus path filters | $0.7224 | 1.4 | $1.0114 | $505.68 |
| Plus matrix width | $0.6904 | 1.4 | $0.9666 | $483.28 |
| Plus cancel-in-progress | $0.6904 | 1.15 | $0.7940 | $396.98 |
The cost per attempt after path filters is the blend of two populations: $0.972 on 60 percent of pull requests and $0.348 on the other 40 percent. End to end the model moves from $680.40 to $396.98 per month, a difference of $283.42 at an identical test suite. The ordering is the useful part. Path filters and cancel-in-progress together account for $261.02 of that, and the matrix change for $22.40, which is why matrix width is the last thing to touch and never the first.
The rate side of the model is worth pricing against the GitHub-hosted baseline, because the integration and e2e groups are 39 of the 84 minutes. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, from GitHub's minute multipliers reference, checked on 2026-08-13.
| Runner | Shape | Rate per minute | 39 minutes per attempt |
|---|---|---|---|
| warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.624 |
| 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | $0.858 |
Two structural notes for anyone building this model against a real bill. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the per minute rates above are the whole of the runner line and nothing gets allocated by seat count. Signup includes $10 free credits, which covers about seven pull requests at the baseline mix above, or twelve after the levers land, enough to run your own repository through the billing report and check the four terms before changing any workflow.
FAQ
What goes into a cost per pull request number?
Four inputs. The jobs a pull request triggers, the billed minutes each job takes, the per minute rate for the runner label each job ran on, and the average attempts per pull request. Multiply the first three per job, sum them for one attempt, then multiply by attempts. Every input except attempts is a column in the CI billing table; attempts comes from counting workflow runs against pull requests over the same period.
Which lever moves cost per pull request the most?
Path filters, in a repository holding more than one service, because they remove whole job groups from most pull requests rather than trimming minutes off every job. In the worked model above, filtering 40 percent of pull requests down to lint and unit tests takes the average from $1.36 to $1.01, cancel-in-progress takes it to $0.79 by cutting attempts from 1.4 to 1.15, and narrowing the unit matrix from six legs to four saves $0.032 per attempt because per leg setup stops being repeated.
How do I get the pull request count and the billed minutes for the same period?
Take the minutes from the CI billing report, filtered to one repository and one date range, and the pull request count from the GitHub REST API for the same range. The billing rows carry a workflow run id rather than a pull request number, so joining them to individual pull requests goes through the workflow runs endpoint. For an average across the repository you can skip the join and divide total cost by pull request count.
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.