Cost GitHub Actions
three pushes run three full pipelines and you pay for all of them
Somebody pushes a commit, spots a typo, pushes again, then fixes the lint error and pushes a third time. GitHub starts three full pipeline runs. Two of them are testing code that is already obsolete before they finish, and you are billed for every minute of all three. On a macOS runner, where minutes count at ten times the rate, those two wasted runs can cost more than the one you needed.
By default every push starts a run, and earlier runs keep going. A concurrency block with cancel-in-progress: true, keyed on the branch or PR, cancels the superseded run and keeps only the one that matters.
The saving is largest where the multiplier is: Linux bills at 1×, Windows at 2×, macOS at 10×. A single stuck macOS job can consume 3,600 billed minutes on its own.
The problem in plain words
Nothing looks wrong. Every run is legitimate, each one was triggered by a real push, and the pipeline is doing exactly what it was told. The waste is invisible because it is spread across runs that all appear necessary in isolation.
It scales with how people work. Developers who push small commits frequently — which is a habit worth encouraging — generate the most redundant runs. Punishing that with a slow, expensive pipeline is the wrong trade when a four-line YAML block removes it.
Why it happens
Independent runs are the safe default. GitHub cannot know that a later push supersedes an earlier one; some workflows genuinely need every commit tested, for a bisect or a release train. So it runs them all and leaves the decision to you.
The multiplier is easy to forget. Minutes are billed at 1× on Linux, 2× on Windows and 10× on macOS. A ten-minute macOS job costs a hundred minutes of the included pool, and a matrix multiplies that again.
Timeouts are not set either. Without timeout-minutes, a hung job runs to the six-hour maximum. On macOS that is 3,600 billed minutes from a single stuck step, which is the largest single line most teams ever see.
How to fix it
Measure before changing anything
Count runs that were superseded — same workflow, same branch, an earlier run still going when a later one started. The script does this from the runs API so the change has a number attached.
Add a concurrency group keyed to the branch
Four lines at workflow level. The group must include the ref, or you serialise every branch against every other.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Do not use cancel-in-progress on a deploy workflow. Cancelling a half-finished deploy is worse than paying for two.
Put a timeout on every job
timeout-minutes caps the damage from a hang. Set it a little above the honest p95 for the job rather than a round number that quietly permits an hour of nothing.
jobs:
test:
timeout-minutes: 15
Question the macOS runners
At 10×, macOS should be reserved for things that genuinely need it — iOS and macOS builds, Safari testing. A matrix that includes macOS out of habit is the most expensive habit in the file.
How to check it worked
Push twice in quick succession and watch the first run get cancelled:
gh run list --branch my-branch --limit 5 \
--json conclusion,createdAt,displayTitle
# the superseded run should read "cancelled"
Then compare billed minutes across a fortnight. The drop lands mostly on whichever runner carries the multiplier.
The full code
The script pulls recent runs, finds the ones superseded by a later run on the same workflow and branch, and estimates the wasted minutes with the OS multiplier applied. It reports rather than edits — adding cancel-in-progress to a deploy workflow would be actively harmful, so the change stays a human decision.
"""Estimate CI minutes wasted on runs superseded by a later push.
Reports only. Adding cancel-in-progress to a deploy workflow would leave a
half-finished deploy, so the change is deliberately left to a human who knows
which workflows are safe to interrupt.
"""
import argparse
import collections
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("actions_redundant_runs")
API = "https://api.github.com"
# Billed minutes per wall-clock minute, per runner OS.
MULTIPLIER = {"ubuntu": 1, "windows": 2, "macos": 10}
def find_superseded(runs):
"""Pure decision function.
A run is superseded when a LATER run exists for the same workflow and branch and
the earlier one was still going when it started. Grouping by workflow as well as
branch matters: two different workflows on one branch are not competing.
"""
by_key = collections.defaultdict(list)
for r in runs:
by_key[(r.get("workflow_id"), r.get("head_branch"))].append(r)
superseded = []
for group in by_key.values():
group.sort(key=lambda r: r.get("run_number", 0))
for earlier, later in zip(group, group[1:]):
if earlier.get("created_at") and later.get("created_at"):
if earlier["created_at"] < later["created_at"]:
superseded.append(earlier)
return superseded
def billed_minutes(run, wall_minutes):
"""Apply the runner multiplier. macOS is 10x, which dominates any bill."""
name = " ".join(run.get("labels", []) or []).lower() or "ubuntu"
for os_name, mult in MULTIPLIER.items():
if os_name in name:
return wall_minutes * mult
return wall_minutes
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True)
ap.add_argument("--limit", type=int, default=100)
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN")
return 2
headers = {"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"}
r = requests.get(f"{API}/repos/{args.repo}/actions/runs",
headers=headers, params={"per_page": args.limit}, timeout=30)
r.raise_for_status()
runs = r.json().get("workflow_runs", [])
wasted = find_superseded(runs)
log.info("%d recent run(s), %d superseded by a later push", len(runs), len(wasted))
by_workflow = collections.Counter(w.get("name") for w in wasted)
for name, count in by_workflow.most_common():
log.warning(" %-40s %d redundant run(s)", name, count)
if wasted:
log.warning("add a concurrency group to the workflows above:")
log.warning(" concurrency:")
log.warning(" group: ${{ github.workflow }}-${{ github.ref }}")
log.warning(" cancel-in-progress: true")
log.warning("do NOT add cancel-in-progress to a deploy workflow")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Estimate CI minutes wasted on runs superseded by a later push.
*
* Reports only. Adding cancel-in-progress to a deploy workflow would leave a
* half-finished deploy, so the change is left to a human.
*/
const API = 'https://api.github.com';
// Billed minutes per wall-clock minute, per runner OS.
export const MULTIPLIER = { ubuntu: 1, windows: 2, macos: 10 };
/**
* Pure decision function.
*
* A run is superseded when a LATER run exists for the same workflow and branch.
* Grouping by workflow as well as branch matters: two different workflows on one
* branch are not competing.
*/
export function findSuperseded(runs) {
const byKey = new Map();
for (const r of runs) {
const key = `${r.workflow_id}::${r.head_branch}`;
byKey.set(key, [...(byKey.get(key) ?? []), r]);
}
const superseded = [];
for (const group of byKey.values()) {
group.sort((a, b) => (a.run_number ?? 0) - (b.run_number ?? 0));
for (let i = 0; i < group.length - 1; i += 1) {
if (group[i].created_at && group[i + 1].created_at
&& group[i].created_at < group[i + 1].created_at) superseded.push(group[i]);
}
}
return superseded;
}
export function billedMinutes(run, wallMinutes) {
const name = (run.labels ?? []).join(' ').toLowerCase() || 'ubuntu';
for (const [os, mult] of Object.entries(MULTIPLIER)) {
if (name.includes(os)) return wallMinutes * mult;
}
return wallMinutes;
}
async function main() {
const repo = process.argv[process.argv.indexOf('--repo') + 1];
const token = process.env.GITHUB_TOKEN;
if (!token) { console.error('set GITHUB_TOKEN'); process.exit(2); }
const res = await fetch(`${API}/repos/${repo}/actions/runs?per_page=100`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
});
const { workflow_runs: runs = [] } = await res.json();
const wasted = findSuperseded(runs);
console.log(`${runs.length} recent run(s), ${wasted.length} superseded by a later push`);
const counts = {};
for (const w of wasted) counts[w.name] = (counts[w.name] ?? 0) + 1;
for (const [name, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
console.warn(` ${name.padEnd(40)} ${count} redundant run(s)`);
}
if (wasted.length) {
console.warn('add a concurrency group; do NOT add cancel-in-progress to a deploy workflow');
}
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
Two things need pinning: runs from different workflows on one branch are not competing, and the macOS multiplier has to actually apply or the estimate understates the waste by an order of magnitude.
from actions_redundant_runs import find_superseded, billed_minutes
def run(n, wf=1, branch="main", created=None):
return {"run_number": n, "workflow_id": wf, "head_branch": branch,
"created_at": created or f"2026-08-28T00:{n:02d}:00Z", "name": f"wf{wf}"}
def test_a_single_run_is_not_superseded():
assert find_superseded([run(1)]) == []
def test_the_earlier_of_two_runs_is_superseded():
out = find_superseded([run(1), run(2)])
assert len(out) == 1 and out[0]["run_number"] == 1
def test_different_workflows_do_not_compete():
"""Two workflows on one branch are both meant to run."""
assert find_superseded([run(1, wf=1), run(2, wf=2)]) == []
def test_different_branches_do_not_compete():
assert find_superseded([run(1, branch="a"), run(2, branch="b")]) == []
def test_macos_multiplier_applies():
r = {"labels": ["macos-latest"]}
assert billed_minutes(r, 10) == 100
def test_linux_is_billed_one_to_one():
assert billed_minutes({"labels": ["ubuntu-latest"]}, 10) == 10
def test_unknown_runner_does_not_inflate_the_estimate():
assert billed_minutes({"labels": []}, 10) == 10
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { findSuperseded, billedMinutes } from './actions-redundant-runs.mjs';
const run = (n, { wf = 1, branch = 'main' } = {}) => ({
run_number: n, workflow_id: wf, head_branch: branch,
created_at: `2026-08-28T00:${String(n).padStart(2, '0')}:00Z`, name: `wf${wf}`,
});
test('a single run is not superseded', () => {
assert.deepEqual(findSuperseded([run(1)]), []);
});
test('the earlier of two runs is superseded', () => {
const out = findSuperseded([run(1), run(2)]);
assert.equal(out.length, 1);
assert.equal(out[0].run_number, 1);
});
test('different workflows do not compete', () => {
assert.deepEqual(findSuperseded([run(1, { wf: 1 }), run(2, { wf: 2 })]), []);
});
test('the macOS multiplier applies', () => {
assert.equal(billedMinutes({ labels: ['macos-latest'] }, 10), 100);
});
test('linux is billed one to one', () => {
assert.equal(billedMinutes({ labels: ['ubuntu-latest'] }, 10), 10);
});
FAQ
Why does GitHub run all three pushes?
Because it cannot know that a later push supersedes an earlier one. Some workflows genuinely need every commit tested — a bisect, a release train — so independent runs are the safe default and cancelling is opt-in.
What does the concurrency group need to contain?
The ref. A group keyed only on the workflow name serialises every branch against every other, so one team's push cancels another's. github.workflow plus github.ref is the usual pairing.
Should I add cancel-in-progress everywhere?
No. Never on a deploy workflow — cancelling a half-finished deploy leaves the system in an unknown state, which is worse than paying for two runs. It is right for tests, linting and builds.
How much does a macOS runner really cost?
Ten times a Linux one per wall-clock minute. Windows is two times. A ten-minute macOS job consumes a hundred minutes of the included pool, and a matrix multiplies that again.
What stops one hung job burning the whole budget?
timeout-minutes on every job. Without it a hang runs to the six-hour maximum, which on macOS is 3,600 billed minutes from a single stuck step.
Related field notes
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- About billing for GitHub Actions — GitHub Docs
- Control the concurrency of workflows and jobs — GitHub Docs
- Actions runner pricing — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.