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.

StageWhat happensBilling effect
Job conditions re-evaluatedThe server re-checks if conditions on running jobs; a job whose condition evaluates to true is not cancelledThe job keeps running and keeps billing
Cancellation message sentThe server notifies the runner machines holding the affected jobsBilling continues until the runner acts
Step conditions re-evaluatedSteps guarded by always() or cancelled() continueCleanup steps bill in full
Signal escalationSIGINT to the step process, then SIGTERM after 7.5 seconds, then a 2.5 second wait, then the process tree is terminatedTen extra seconds per stubborn step
Cancellation timeoutAfter five minutes the server forcibly terminates whatever is still runningA 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.

PushWithout a concurrency groupJob-minutesWith cancel-in-progressJob-minutes
1Runs 0 to 8, completes48Cancelled at minute 318
2Runs 3 to 11, completes48Cancelled at minute 618
3Runs 6 to 14, completes48Cancelled at minute 812
4Runs 8 to 16, completes48Cancelled at minute 1224
5Runs 12 to 20, completes48Runs 12 to 20, completes48
Total240120

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: false

Note 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.

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.