Setting a Minutes Budget for GitHub Actions

A GitHub Actions minutes budget is jobs per month times billed minutes per job times the rate per label, bounded by labels, timeouts, concurrency, and paths.

Last verified:

A GitHub Actions minutes budget is one row per runner label: jobs per month multiplied by billed minutes per job multiplied by the rate for that label. Four workflow controls hold that number in place once it is written down, and a scheduled pull of daywise costs tells you on day 12 of the month whether spend is on pace or already past it.

This guide covers why a limit entered in billing settings leaves runner minutes unbounded, the three inputs the model needs and where each one comes from, the four controls that bound them, and a worked monthly budget with the arithmetic shown.

Diagnosis

Budgets usually fail for a structural reason rather than a discipline reason: the number is written somewhere that cannot stop a job.

A limit in billing settings covers what GitHub meters. Jobs that run on self-hosted or third-party runner labels are not billed by GitHub, so they never draw down a spending limit configured there (GitHub Actions billing documentation, checked on 2026-08-13). The spending limit answer works through what that leaves you.

The invoice arrives as a total, and the budget is per label. A single monthly figure cannot be compared against a plan that assumed 1,200 lint jobs at two minutes each. Comparison needs the same granularity on both sides, which means the budget has to be written per label before the first invoice lands.

Run count moves faster than duration. A matrix that grows from four legs to eight doubles the jobs on that label overnight, while dependency bots, nightly schedules, and re-runs add executions that no one attributes to a decision. Duration creeps by percentages; run count jumps by multiples of itself.

The default job timeout is 360 minutes. GitHub cancels a job at that point unless the job sets timeout-minutes lower (workflow syntax reference, checked on 2026-08-13). One hung job on a macOS runner at $0.08 per minute bills $28.80 against a budget line that assumed 20 minutes.

Fix

Write the budget as one row per label

The model has three inputs and no fourth.

InputWhere it comes from
Jobs per monthRun count in the Jobs section of Reports, per repository, workflow, and job name
Billed minutes per jobThe billed time column in the CI tab of the Billing section, which sits beside execution time
Rate per labelThe pricing page and the per-minute rate for the label in runs-on

Use billed time rather than execution time. Billing is per minute, so a 35 second job bills a full minute, and a fleet of short jobs is bounded by job count before it is bounded by duration.

Every job records the label it ran on, so each budget row maps to a filter in the report that produced it. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the budget carries no fixed term and every dollar in it traces back to a job execution.

Bound each term with four workflow controls

Each control acts on one term of the multiplication. The last column shows what each one moved in the worked model below.

ControlTerm it boundsCeiling it setsEffect in the model
Runner label in runs-onRate per minuteFixed by the label, $0.003 to $0.128 per minute$172.80 to $86.40 on the integration row if the size step is safe
timeout-minutesMinutes one job can billThe value you set, or 360 minutes if you set nothingCaps a hung macOS job at $2.40 instead of $28.80
concurrency with cancel-in-progressMinutes spent on superseded runsOne run per group in flightReturns $10.37 per month on the integration row
paths filtersJobs per monthRuns only on commits touching the listed pathsTakes the iOS row from $192.00 to $48.00

Path filters move the most money because a run that is never created bills nothing at all, and timeout-minutes matters most on the expensive labels, where the 360 minute default is worth tens of dollars per incident.

Close the loop with a scheduled daywise cost pull

A budget that is checked once a month is checked once too late. A daily job compares the latest cumulative_amount against the budget prorated to the day of the month:

pace ceiling = monthly budget x day of month / days in month

On a $500 budget in a 31 day month, day 12 allows $193.55. Anything above that is a signal to look at run counts before the invoice does it for you.

Configuration

Two files. The first bounds the workflow, the second watches the number.

name: ci
on:
  pull_request:
    paths:
      - 'src/**'
      - 'package.json'
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: warp-ubuntu-latest-x64-2x
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  integration-tests:
    runs-on: warp-ubuntu-latest-x64-8x
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - run: make integration

The iOS build belongs in its own workflow so that its paths filter is independent of the Linux jobs:

name: ios
on:
  pull_request:
    paths:
      - 'ios/**'

jobs:
  ios-build:
    runs-on: warp-macos-latest-arm64-6x
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build

The monitor runs on the smallest Linux label and needs an API key with the ci scope, created from the API keys page and sent as a bearer token, as described in the automation documentation.

name: actions-budget-monitor
on:
  schedule:
    - cron: '0 8 * * *'
  workflow_dispatch:

jobs:
  check-pace:
    runs-on: warp-ubuntu-latest-x64-2x
    timeout-minutes: 5
    env:
      MONTHLY_BUDGET_USD: '500'
    steps:
      - name: Compare month to date spend against pace
        run: |
          start=$(date -u +%Y-%m-01T00:00:00Z)
          end=$(date -u -d 'tomorrow' +%Y-%m-%dT00:00:00Z)
          curl -sS -G 'https://api.warpbuild.com/api/v1/jobs/daywise-costs' \
            -H "Authorization: Bearer ${{ secrets.WARPBUILD_API_KEY }}" \
            -H 'Accept: application/json' \
            --data-urlencode "start_date=$start" \
            --data-urlencode "end_date=$end" \
            -o daywise.json
          spent=$(jq -r 'if length == 0 then 0 else .[-1].cumulative_amount end' daywise.json)
          day=$(date -u +%-d)
          days=$(date -u -d "$(date -u +%Y-%m-01) +1 month -1 day" +%-d)
          pace=$(awk -v b="$MONTHLY_BUDGET_USD" -v d="$day" -v n="$days" 'BEGIN { printf "%.2f", b * d / n }')
          echo "month to date $spent against pace ceiling $pace"
          awk -v s="$spent" -v p="$pace" 'BEGIN { exit (s > p) ? 1 : 0 }'

A failing scheduled run is the alert. Teams that want the number in a channel add a step that posts $spent and $pace to Slack before the comparison exits.

Cost or Time Model

A team merging around 300 pull requests a month, with three Linux jobs per pull request, an iOS build, and a Windows test job. Rates come from the pricing page, checked on 2026-08-13.

JobLabelJobs per monthBilled minutesMinutesRateMonthly
lintwarp-ubuntu-latest-x64-2x1,20022,400$0.004/min$9.60
unit testswarp-ubuntu-latest-x64-4x1,20078,400$0.008/min$67.20
integrationwarp-ubuntu-latest-x64-8x9001210,800$0.016/min$172.80
iOS buildwarp-macos-latest-arm64-6x120202,400$0.08/min$192.00
Windows testswarp-windows-latest-x64-4x15081,200$0.016/min$19.20
Runner minutes3,57025,200$460.80

Cache storage at 30 GB-month adds $6.00 at $0.20 per GB-month, and 120,000 cache reads and writes add $12.00 at $0.0001 per operation, so the baseline budget is $478.80. Round the ceiling to $500 and the pace formula above has its input.

Now apply the controls. Gating the iOS workflow on ios/** drops it from 120 runs to the 30 that touch iOS code, taking that row from $192.00 to $48.00. At 12 percent of pushes superseding a run in flight, and half the minutes burned before the cancel, cancel-in-progress returns 108 partial integration runs at 6 minutes each, which is 648 minutes and $10.37. Both are arithmetic on the table above rather than a projection.

LineBeforeAfter
iOS build, path filtered$192.00$48.00
Integration, superseded runs cancelled$172.80$162.43
Everything else$96.00$96.00
Runner total$460.80$306.43

The label control is the one to check last, since it needs evidence. The Jobs section reports CPU and memory at the 75th and 90th percentile per job, and a job that never crosses half its cores is a candidate for the next size down: moving 10,800 integration minutes from warp-ubuntu-latest-x64-8x at $0.016 per minute to warp-ubuntu-latest-x64-4x at $0.008 per minute takes that row from $172.80 to $86.40, provided the wall clock holds.

Price the same minutes against GitHub-hosted list prices to sanity check the budget. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, with the GitHub list price checked on 2026-08-13 at docs.github.com. The 8,400 unit test minutes cost $67.20 here and $100.80 at that list price.

Signup includes $10 free credits, enough to run a week of real jobs through the billing report and replace the estimated minutes in the table with measured ones. The per-pull-request view of the same numbers is in working out CI cost per pull request, the input side is covered in how to estimate a GitHub Actions bill, and the levers that move a row once the budget is breached are in cutting GitHub Actions costs.

FAQ

What inputs does a GitHub Actions minutes budget need?

Three per runner label: jobs per month, billed minutes per job, and the rate for that label. Run count comes from the Jobs section of Reports, billed time comes from the CI tab of the Billing section, where billed time and execution time are separate columns, and the rate comes from the pricing page. Multiply the three and sum the labels, then add the cache storage and snapshot lines, which bill alongside minutes.

Which workflow controls actually cap the number?

Label choice fixes the rate of every billed minute, timeout-minutes caps what one job can bill, concurrency with cancel-in-progress stops runs that a newer push has superseded, and paths filters stop runs from being created at all. Path filters move the largest amount of money because a run that never starts bills nothing, and timeout-minutes matters most on expensive labels, where the default ceiling is 360 minutes.

How do I know mid-month that the budget is drifting?

Pull GET /jobs/daywise-costs on a schedule. It returns one row per day with date, amount, and cumulative_amount, and it is not paginated, so a daily workflow can compare the latest cumulative_amount against the monthly budget prorated to the day of the month and fail when spend runs ahead of pace. The key needs the ci scope.

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.