Where your GitHub Actions time goes, and what to fix first. ci-wizard reads a repository's workflow runs, jobs and workflow files from the GitHub API, times every sampled step, finds the patterns that cost the most (installs with no cache, the same commit run twice, outdated runs nobody cancels, a workflow that fires far more often than code changes), and checks the workflow file for the cause before it says so. Every finding carries its evidence, a confidence and an estimated weekly saving, and every fix is written as an option with its trade-off. It is read-only: nothing is written to the repository.
npx github:askalf/ci-wizard#v0.1.2 owner/repo# CI audit: askalf/dario (500 runs over 0.71 days, 8 workflows sampled, 54 API calls)
1. many small scheduled jobs (measured): 5 periodic workflows each run a job of under
30 s, and each pays its own setup, checkout and wrap-up. about 109 job-min/week (estimate)
2. self-hosted wrap-up tail in 7 workflows (measured): median 4 s after the last step
before the job reports done (GitHub-hosted is about 2 s). about 104 job-min/week (estimate)
3. runs far more often than code changes in Fleet status (measured): 1746 runs a week,
79% of its job time fixed cost. about 104 job-min/week (estimate)
| Workflow | Runs/week | Job-min/week | Developer wait p50 / p90 | Biggest shares |
| Fleet status | 1746 | 262 | 21 s / 43 s | tail 42%, overhead 21% |
That is an abridged run on askalf/dario, one of ours, on
2026-11-03 at 12:35 UTC, from ci-wizard askalf/dario --json (v0.1.1, run from a checkout as
node bin/ci-wizard.mjs askalf/dario --json). The full result is
docs/samples/askalf-dario.json; ci-wizard renders it as the
markdown report shown in the job summary. A run takes a few seconds on a small repository (4 s
and 12 API calls on this one) and about half a minute on a busy one (26 s and 54 calls for
dario's 500 runs).
name: CI audit
on:
workflow_dispatch:
schedule:
- cron: '0 6 * * 1'
permissions:
actions: read
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: askalf/ci-wizard@v0.1.2Pin it to the release commit's full SHA in your workflows. The report lands in the job summary
and in $RUNNER_TEMP/ci-wizard/report.md (with report.json beside it). Inputs: repository
(default: the one the workflow runs in), token (default: the job's token), days (7),
workflows (8), per-workflow (5), only (workflow names, comma-separated, to time those
instead of the busiest). Outputs: report-path, findings. The audit never fails
the job for what it finds; it fails only when it cannot read the repository.
npx github:askalf/ci-wizard#v0.1.2 owner/repo
npx github:askalf/ci-wizard#v0.1.2 https://github.com/owner/repo --save before.json
npx github:askalf/ci-wizard#v0.1.2 owner/repo --days 14 --json > before.json
npx github:askalf/ci-wizard#v0.1.2 owner/repo --html audit.html
npx github:askalf/ci-wizard#v0.1.2 owner/repo --only CI --save before.json
npx github:askalf/ci-wizard#v0.1.2 compare before.json after.json
npx github:askalf/ci-wizard#v0.1.2 --versionThe token comes from GITHUB_TOKEN or GH_TOKEN, or from gh auth token when the GitHub CLI is
signed in. The repository is owner/repo, a GitHub URL or an SSH remote; left out, it is the
origin of the git checkout you run in. Options: --days, --workflows, --per-workflow,
--only <name> (time only the workflows named, by the name GitHub shows, so two workflows that
share a name are both selected; repeat it or separate names with commas), --json, --save <file> (the JSON report beside the markdown, as a baseline
for compare), --html <file>, --version. ci-wizard --help lists them all. Exit codes: 0 report written, 1 the repository was not found or not readable, no
runs in the window, or an API error, 2 usage, 3 stopped near the rate limit.
-
Risks come first. For self-hosted runners, two findings are about security and stability, not minutes, and lead the report: a fork's pull request that can run on a self-hosted runner, and runners that take more than one job (persistent, so jobs can see what the one before left). They measure the method askalf/pitlane documents.
-
Start with names the one finding to fix first, and the paragraph under it says what all the findings add up to against the job-minutes (and billed minutes) the sampled workflows use.
-
What to fix first: each finding says where it is, what it saves (job-minutes, billed minutes, or developer wait), how sure it is (confirmed by the workflow file, measured, or cause inferred), why (the evidence), the fix, the fix's trade-off, and, where the fix is a change to the workflow file, the YAML for it, written for the package manager and steps that workflow already has.
-
Where the time goes: per workflow, runs a week, job-minutes, billed minutes (GitHub rounds each hosted job up to a whole minute, so a run of short jobs bills far more than it runs), developer wait (only for workflows somebody waits on), queue wait, and the biggest shares of job time. The legend under the table explains each share; † marks a workflow timed from fewer than 3 runs, ‡ one nobody waits on.
-
Next step: prove the fix: save this report, make one change, measure again, and
compare.
- Measure. Each sampled job's steps, timed from the Actions API: queue wait, run time, and the time before the first step and after the last.
- Diagnose. Time split into runner overhead, checkout, toolchain, dependencies, cache, artifacts, docker and work, by the command each step runs (read from the workflow file), not only by its name.
- Confirm. The workflow file is parsed for the cause: triggers and push branch patterns,
caches (including a setup action's cache input), concurrency and cancel-in-progress, how
dependencies install, whether a lockfile is committed. A commented-out step or a feature
switched off with
falsenever counts. - Prove. Save the report with
--save before.json(or--json) before the change. After the change has run for a while, measure again and runci-wizard compare before.json after.json: per workflow, the runs a week, job-minutes, developer wait, queue wait and wrap-up tail before and after, with the change, and the findings that disappeared or appeared.
- Up to 500 workflow runs from the window, the jobs and steps of the newest successful runs of the busiest workflows, those workflows' files, the repository's root listing (to see which lockfile it commits) and its default branch (for advice that names it). About 50 API calls for the defaults on a busy repository.
- A token with actions: read and contents: read. The job's own
GITHUB_TOKENis enough for the repository a workflow runs in; a fine-grained token with those two permissions works from the command line. A repository the token cannot see reads as not found. - While GitHub reports the remaining count (api.github.com does), the client sends no request once the token has 25 calls or fewer left, retries included, so an audit leaves at least 25 for the repository's own CI, and it says how many were left. Without a remaining count the reserve cannot be enforced, and the report says so. When GitHub's answers carry the rate-limit headers, as api.github.com's do, the report states the budget the audit drew on ("41 API calls, 959 of 1000 left this hour"); without them it leaves the line out. An audit that finds no runs writes no report.
comparereads two saved reports and needs no token.
A workflow's own GITHUB_TOKEN has 1,000 requests an hour per repository, shared with every other
step that calls the API. A person's token has 5,000, shared with everything that person runs. A
GitHub App's installation token has its own budget of 5,000 or more, and one App installed on
several repositories can audit all of them. An audit of a busy repository takes about 50 calls,
so the job token is enough for one audit a day; an App is worth it for frequent audits, several
repositories, or a repository whose CI already spends its job token.
Create a private GitHub App with Actions: read and Contents: read, install it on the repositories to audit, keep its client ID as a repository variable and its private key as a secret, and mint a token in the job:
steps:
- id: app
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.CI_WIZARD_APP_CLIENT_ID }}
private-key: ${{ secrets.CI_WIZARD_APP_KEY }}
owner: ${{ github.repository_owner }}
permission-actions: read
permission-contents: read
- uses: askalf/ci-wizard@v0.1.2
with:
token: ${{ steps.app.outputs.token }}The token lasts an hour and is revoked when the job ends. From the command line, any token with
the same two permissions works through GITHUB_TOKEN or GH_TOKEN.
- Sampled. The window is up to 500 runs, and job-level timing comes from a few recent successful runs of each busy workflow. A busy repository's 500 runs may cover less than a day; weekly figures scale the window to 7 days, so a short window is noisier, and the report says when the cap cut the window short. A workflow timed from fewer than 3 runs is marked †.
- Estimates. Savings come from simple formulas over measured time (see
docs/rules.md), not from running the fix.
compareon a run after the change is what proves one. - GitHub-hosted and self-hosted differ. The wrap-up tail and queue-wait rules mean different things on each, and only hosted jobs have billed minutes, so a workflow on self-hosted runners shows 0 there. Self-hosted runners far from GitHub's service region pay more per uploaded log.
- Billed minutes are minutes, not money. They round each hosted job up as GitHub does, but larger runners, Windows and macOS cost more per minute than standard Linux, a plan's included minutes are not subtracted, and a public repository's standard runners are free (the report says so, and the short-hosted-jobs rule does not fire there).
- Fork exposure reads the sampled workflows. Only the files of the workflows the audit samples are checked for self-hosted jobs a fork can reach; a quiet workflow outside them is not. Runner reuse is judged from the sampled jobs.
- Names and files. A step is classified by its command when the workflow file gives one, by its name otherwise; reusable workflows and composite actions are read only as far as their calling step.
- Options, not orders. A fix can disagree with a repository's own history: dario keeps cancel-in-progress off for its status job on purpose, because cancelled queued runs piled up there before.
npm ci
npm test # node:test, no network
npm run build # bundles src/ into dist/ (committed; the action runs from it)See CONTRIBUTING.md. Security reports: SECURITY.md.
MIT