How Is a Cancelled GitHub Actions Job Billed?
A cancelled GitHub Actions job bills the minutes it ran before termination and nothing after, which is why cancel-in-progress on a busy branch lowers the bill.
Answer
A cancelled GitHub Actions job is billed for the minutes it ran before the runner stopped, and for nothing after that. GitHub applies the same rule to its own hosted minutes, where a run that stops early consumes only the time it used (GitHub billing documentation, checked 2026-08-13), and WarpBuild bills cloud runners on a per-minute basis for the time the job holds the machine (WarpBuild pricing).
That single rule is the whole argument for cancel-in-progress. A run that is superseded by a newer commit keeps burning minutes until something stops it, so the cost of a push storm is set by how long each obsolete run survives. Stop run number three at minute two and you pay two minutes for it. Leave it alone and you pay its full length for work nobody will read.
Per-minute granularity has one consequence worth knowing before you model anything: a job cancelled twenty seconds after it starts still bills its first minute. Savings come from cutting long tails rather than from shaving seconds.
Detail
What a cancelled job still bills
Cancellation travels to the runner as a message, and the runner needs a few seconds to act on it. GitHub documents the sequence in its workflow cancellation reference (checked 2026-08-13), and every row below is billable time on the runner.
| Stage | What happens | Billing effect |
|---|---|---|
| Job conditions re-evaluated | The server re-checks if conditions on running jobs; a job whose condition evaluates to true is not cancelled | The job keeps running and keeps billing |
| Cancellation message sent | The server notifies the runner machines holding the affected jobs | Billing continues until the runner acts |
| Step conditions re-evaluated | Steps guarded by always() or cancelled() continue | Cleanup steps bill in full |
| Signal escalation | SIGINT to the step process, then SIGTERM after 7.5 seconds, then a 2.5 second wait, then the process tree is terminated | Ten extra seconds per stubborn step |
| Cancellation timeout | After five minutes the server forcibly terminates whatever is still running | A worst case of five extra billed minutes per job |
A job with a long if: always() upload step therefore costs more to cancel than a job that exits on the first signal. If your cancelled runs consistently bill four or five minutes more than the moment you clicked cancel, the cleanup steps are the reason.
A push storm with and without cancel-in-progress
Take a pull request workflow with a six job matrix, eight minutes per job, all on warp-ubuntu-latest-x64-4x at $0.008 per minute (cloud runners documentation). Five commits land on the branch at minutes 0, 3, 6, 8, and 12.
| Push | Without a concurrency group | Job-minutes | With cancel-in-progress | Job-minutes |
|---|---|---|---|---|
| 1 | Runs 0 to 8, completes | 48 | Cancelled at minute 3 | 18 |
| 2 | Runs 3 to 11, completes | 48 | Cancelled at minute 6 | 18 |
| 3 | Runs 6 to 14, completes | 48 | Cancelled at minute 8 | 12 |
| 4 | Runs 8 to 16, completes | 48 | Cancelled at minute 12 | 24 |
| 5 | Runs 12 to 20, completes | 48 | Runs 12 to 20, completes | 48 |
| Total | 240 | 120 |
At $0.008 per minute that is $1.92 for the storm without a concurrency group and $0.96 with one. A repository that sees 120 storms of this shape in a month bills 14,400 fewer job-minutes with cancel-in-progress enabled, or $115.20 less.
Platform choice changes the scale of the same arithmetic. The identical timeline for a single macOS job of eight minutes on warp-macos-latest-arm64-6x at $0.08 per minute (cloud runners documentation) bills 40 minutes and $3.20 uncancelled against 20 minutes and $1.60 cancelled, a $1.60 saving on that one job. All four bill the same way for a job that stops early.
The workflow shape
The concurrency syntax keys a concurrency group on an expression and cancels whatever is running in it. Keying on the ref gives one live run per branch.
name: ci
on:
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
strategy:
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
- run: ./scripts/test.sh --shard ${{ matrix.shard }}
- name: Upload shard report
if: always()
timeout-minutes: 1
uses: actions/upload-artifact@v4
with:
name: report-${{ matrix.shard }}
path: reports/Two details matter for the bill. The group includes github.workflow so two different workflows on the same branch do not cancel each other. The if: always() upload carries timeout-minutes: 1, which caps the cleanup tail that keeps billing after cancellation instead of letting it run to the five minute limit.
When cancelling is the wrong call
Cancellation is safe for work that only writes to the runner. It is unsafe for work that mutates state somewhere else. A terraform apply killed between the plan and the state write, a database migration killed halfway through, or a package publish killed after the upload but before the tag all leave a mess that costs more engineering time than the minutes ever saved.
Give those jobs their own group and let the second run wait:
concurrency:
group: deploy-production
cancel-in-progress: falseNote the queue behavior that comes with it: when a run is waiting in a group, a newer run arriving replaces the pending one, so the deploy that eventually runs is the newest commit rather than every commit in order. Release branches deserve the same treatment, which is why cancel-in-progress: ${{ !contains(github.ref, 'release/') }} is a common middle ground.
Reading it back in the reports
The CI tab of the billing report has one row per job execution with execution time, billed time, and cost, so a cancelled run appears as a short row rather than a missing one (reports documentation). Filter by runner label and sort by billed time to find the cancelled jobs whose cleanup steps ran long.
Related Questions
Does cancelling a GitHub Actions run refund the minutes already used?
No. The minutes that ran before the cancellation message reached the runner are billed, and the minutes that would have followed are not. Cancelling early is what saves money, so a superseded run should be stopped at the first commit that replaces it rather than at code review time. Per-minute rates for each label are on the WarpBuild pricing page.
How much does cancel-in-progress actually save on a busy branch?
On the six job matrix modeled above, five pushes in twelve minutes bill 240 job-minutes without a concurrency group and 120 job-minutes with one, which is $1.92 against $0.96 at $0.008 per minute. Multiply by the number of storms your busiest repository sees in a month to get the real figure, and see how to cancel in-progress runs on a new push for the configuration.
Which jobs should never be cancelled in progress?
Anything that mutates state outside the runner: deploys, database migrations, Terraform applies, and package publishes. Give those a separate concurrency group with cancel-in-progress: false so a new run queues behind the current one instead of interrupting it. Cleanup steps guarded by always() also keep billing after cancellation, so cap them with timeout-minutes. The guide to re-run storms covers the other repeat-work patterns that push the same line up, and the concurrency group definition covers the expression forms.
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.