See where your CI time actually goes — for any GitHub repo, in one command.
npx ci-audit vercel/next.js ci-audit vercel/next.js
last 200 runs · 11.6 hours · Mon 27 Jul · push and PR triggers
⏱ 28m 41s median wait per commit · 45m 22s at p95
348 CI-hours across 3884 jobs
43 commits · 200 runs · 4.7 workflow runs per push
💸 $1,406 billed in this window — 3284 larger-runner jobs, billed even on public repos
SLOWEST JOB FAMILIES shards total p50 share
────────────────────────────────────────────────────────────────
test prod 10 57h 32m 12m 18s 17%
test dev 10 43h 36m 9m 05s 13%
test turbopack production 7 40h 05m 11m 51s 12%
top 8 = 73% of CI time · 90 more families = 27% (161 job variants total)
🎯 critical path (inferred from timing · 42% of commits)
build (1m 53s) → Stats (10m 19s) = 12m 12s
→ 43% of the 28m 41s median wait is these 2 jobs in sequence
→ Stats is on the path in 67% of commits — a minute saved there is a minute saved everywhere
♻️ 5 superseded runs ran to completion on commits a newer push had already replaced
0.8 CI-hours (0% of total, $1.54) — a concurrency block with cancel-in-progress reclaims it
INSIDE test unit windows (p50 per step)
────────────────────────────────────────────────────────────────
▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░ Run node run… --type unit 3m 49s 53%
▓▓▓▓░░░░░░░░░░░░░░░░░░ Run pnpm install 1m 14s 17%
▓▓▓░░░░░░░░░░░░░░░░░░░ Cache pnpm store 1m 00s 14%
+ 38 steps under 1%
44 steps in one job — worth a look on its own
MOST FAILING runs failed rate burned $
────────────────────────────────────────────────────────────────
Rspack inte…evelopment) 16 15 94% 2h 32m $10
5 families fail more than half the time, burning 4h 52m of runner time ($20)
Four things nothing else gives you:
- The critical path, reconstructed from timing. Your slowest job is 11m but your median wait
is 30m — the difference is
needs:edges running jobs in sequence.ci-auditinfers those edges from job start and end times (a job starting as another finishes is gated on it) and prints the chain, so you getbuild → test → publish = 13m 50s, 46% of your median waitwithout fetching or parsing a single line of workflow YAML. - Job families, not shards.
test (1/10)throughtest (10/10)are one logical job. Split, each looks like 1% of your CI and nothing looks worth fixing. Grouped, the real bottleneck usually turns out to be 15–25% of total time. - Per-step timing. Which job is slow is a starting point. Which line of your workflow YAML is slow is a ticket.
- Wait, not fleet hours. Aggregate CI-hours is a platform-team metric.
ci-auditleads with the median wait from push to green, measured per commit across every workflow the push fired.
Plus two findings that are usually free money: jobs that fail more often than they pass (with
what their failures cost in runner time and dollars), and runs that kept going after a newer push
had already replaced them — which a concurrency block reclaims in four lines of YAML.
ci-audit <owner/repo> [options]
--days <n> cover the last n days (spreads the sample evenly across them)
--runs <n> runs to analyze (default 200, max 1000)
--sample quick pass, stays under the 60 req/hr unauthenticated limit
--branch <name> only runs on this branch
--workflow <f> only this workflow (file name, e.g. ci.yml)
--events <list> only these triggers (default: push, pull_request, pull_request_target,
merge_group; "all" keeps cron and bot triggers too)
--ungrouped don't collapse matrix shards into job families
--inside <job> which job to break down per step (default: slowest visible one)
--json machine-readable output
--markdown markdown output
--token <t> GitHub token
--days 30 --runs 300 is usually what you want: a month of history, sampled evenly across it,
for the cost of 300 requests. Plain --runs 300 on a busy repo may only cover a few hours.
You need one — the Actions API allows 60 req/hr unauthenticated and one audit is ~300 requests. In order of preference:
- Nothing to do if you've run
gh auth login— the token is auto-detected. GITHUB_TOKEN/GH_TOKENin the environment.--token <t>.
No token at all still works via --sample (50 runs), but percentiles get noisy.
Being explicit, because a CI report you can't trust is worse than none:
-
Inside composite actions. If one step is a composite action or one large script, the API reports it as a single step.
ci-auditdetects this, says so, and breaks down the slowest job it can see into instead of printing a 98% bar and seven 0% rows. -
Self-hosted runners. Excluded from cost entirely and labelled. Their time is still counted.
-
Cache hit rate. Not exposed by any API — only derivable by parsing multi-MB step logs. Not attempted.
-
Billed vs wall-clock time.
completed_at − started_atexcludes some runner overhead, so it won't exactly match your invoice. Step timings are share of wall-clock, never billed minutes. -
Flakes hidden by retry actions.
nick-fields/retryretries inside one attempt, so the run never looks rerun. Only ~3% of runs are ever rerun, soci-auditadds a second signal — the same commit passing in one run and failing in another — plus a plain failure-rate table that always has data. An empty flaky table means "no evidence", not "no flakes". -
Dollar amounts on public repos. Standard hosted runners are free for public repos, so the real number is $0. Larger runners are billed on public repos, and that measured figure is the headline; the
~$X/mo if privatenumber is hypothetical and marked with~. -
True
needs:edges. The critical path is inferred from timing, not read from your YAML. A job that happens to start as another finishes will be treated as depending on it. The chain is right often enough to act on and is labelled with the share of commits it matched — treat it as a strong hint, not a graph. -
CI that isn't in Actions. Only triggers a developer blocks on are sampled, because a repo with a cron firing every few minutes can fill all 1000 runs the API will page through and leave no room for the real CI. If every job in the window finishes in under a minute,
ci-auditsays so outright instead of presenting label bots as your test suite.
Two deliberate exclusions from the wait calculation, both verified against real repos: reruns (a rerun clicked two days later would report as a 49-hour wait) and workflows fired more than six hours after the push (nightly cron reuses the commit's sha).
npm install
npm test
node src/cli.ts owner/repo # Node 24+, runs TypeScript directly
npm run buildMIT.