GitHub Actions Optimizer
The order is the skill. For every job, ask in this order — and do not skip ahead:
- Does this job need to run at all? (redundant with a deploy platform, dead, obsolete)
- Does it need to run this often / on these triggers? (push+PR double-runs, unfiltered paths, stale schedules)
- Only then — how do we make it faster? (cache, concurrency, runner choice)
Jumping straight to caching a job that shouldn't exist optimizes waste. A deleted job saves 100%; a cached one saves 30%.
Phase 0 — Is this even a problem?
Two independent ways a repo can have $0 GitHub Actions spend — check both before declaring the exercise moot:
Self-hosted runners are free. GitHub bills nothing for runs-on: [self-hosted, …] — the cost lives in your own infra, not GitHub's meter. A repo whose expensive jobs run self-hosted has no GitHub minutes to save no matter how often it runs. Two traps here:
- A custom runner group (
runs-on: { group: … }) is NOT a free signal by itself. Runner groups can contain GitHub-hosted larger runners, which are billed (even on public repos). Don't declare $0 off the group: syntax — check what the group actually contains, or let the billing net/gross decide.
- Don't confuse self-hosted with larger GitHub-hosted runners.
runs-on: ubuntu-latest-4-core is billed; runs-on: self-hosted is not.
Where GitHub genuinely bills $0 (true self-hosted), time/queue optimization can still be worth reporting; dollar savings are zero.
Public repo on standard GitHub-hosted runners. Actions is free and unlimited there — but only there. So for a public repo confirm both:
runs-on is a standard label (ubuntu-latest, windows-latest, macos-latest) — not a larger-runner label, and
- its billing
net is 0 (org path → REFERENCE §1).
Either case true → say it up front: no cost to save (concurrency-cancel and friends are still worth reporting for queue time, not money). A public repo on larger runners is a real cost target — treat it like a private one. When in doubt, let the usage report's net/gross decide — a repo that bills $0 across a full month has nothing to optimize for money.
Auditing a whole org? Two traps, in order. (Recipe → REFERENCE §1.)
- Public repos are listed too, usually at $0. Ranking by raw minutes floats free repos to the top and sends you optimizing work that cannot save a cent. Annotate
visibility and drop them — but only the ones billed at zero. Public is free on standard runners only; larger runners are billed on public repos too, so a public repo with net > 0 has real spend and stays in scope.
- Then rank the private/internal repos by
gross (list-price cost) — not by net, not by raw minutes. net is order-dependent: the included minutes are a shared org pool, so once it's drained whatever runs next gets billed — net fingers whichever repo happened to go last, not the one that ate the pool (observed: the org's highest bill was 72 Linux minutes, while two backup repos burning 1,640 min/month between them showed a smaller net). Raw minutes is order-independent but SKU-blind — 200 macOS min outcost 1,000 Linux min. gross (pre-discount pricePerUnit × quantity) is both SKU-weighted and order-independent, so it names the real culprit; on an all-Linux org it collapses to the minutes ranking. Treat net > 0 as an org-level alarm ("marginal minutes cost money" — the standard-runner pool is drained or the org runs always-billed larger runners), never as a per-repo verdict.
Phase 1 — Measure, don't guess
- Read every file in
.github/workflows/.
- If the
gh CLI (or the GitHub MCP tools) is available, pull real run data and compute per workflow: average duration, run frequency, and the superseded rate. Treat raw cancelled ÷ total as an upper bound, not measured waste — the cancelled conclusion also covers manual cancellations and runs an existing concurrency group already killed; confirm a newer run on the same workflow + ref before counting a cancel as superseded (a failing run that catches a bug is CI working, not waste). Exact commands → REFERENCE.md §1.
- Ask the API for billed minutes. The usage report gives real charged minutes and dollars per repo/SKU — the only source with multipliers and per-job rounding already applied. Per-run duration comes from the jobs endpoint (
started_at→completed_at); the older run-timing endpoint is closing down (billing-platform migration, 2025-04-01) — legacy fallback only. Either way the per-job number is raw job time: apply the OS multiplier (Linux ×1, Windows ×2, macOS ≈×10) and per-job rounding yourself when estimating from it — and real SKUs (Actions macOS 3-core at $0.062/min vs Linux $0.006) and larger runners have their own rates. Watch two zero/low cases the ×1 Linux rate hides: self-hosted runners are free ($0), and ubuntu-slim (1-core, $0.002) / arm64 ($0.005) cost less than the $0.006 catch-all — full SKU table in REFERENCE §Multipliers.
- No
gh / no billing access? Say so explicitly. Never invent minute numbers — label every figure "estimate based on workflow content only" and derive it from trigger frequency × step count, clearly marked as such.
Phase 2 — Redundancy audit (the highest-value pass)
Look for work a deploy platform already does:
- Vercel / Netlify / Cloudflare Pages build every push automatically. A workflow that runs
next build / npm run build for deploy purposes is a double-build → CRITICAL. Detect via vercel.json, .vercel/, netlify.toml, wrangler.toml — and verify the platform is actually connected (config file alone isn't proof; check for platform bot comments on PRs, deployment statuses, or ask the user) before declaring the CI build redundant. A build that exists only to gate (typecheck/test) is not redundant — but then it shouldn't upload/deploy artifacts.
- Cloudways / git-pull deployments: a workflow doing rsync/scp/ssh deploy that the platform could do via a git webhook → flag as replaceable.
- Docker registry builds: CI building an image the platform (or another pipeline) also builds → flag.
Then ask a different question: is a robot pulling the trigger?
The workflow can be perfectly configured and still be pure waste — because the thing pushing isn't a person. Mirror, backup, sync and archive repos take automated pushes on a fixed cadence, and every push re-runs the full CI or security scan, forever, on code nobody is developing.
Cheapest tell: the inter-run jitter is negligible relative to the gap. A cron fires on a near-constant interval; a human doesn't. Measure the median gap between distinct pushes — collapse same-commit runs first (CI + CodeQL fire off one push seconds apart; measuring those gaps makes an hourly robot look bursty) — and the mean deviation from it — rel-jitter < 5% is a machine. (Do not bucket by exact minute-of-hour: a backup that drifts across :00/:01/:02 escapes it, and a bursty human repo trips it. And do not trust the commit author — an automated backup usually pushes under a human's name.) Detection command → REFERENCE §2.
Real case: a 300 MB backup repo received an hourly automated push; each one triggered a full CodeQL scan — 24 scans/day of code no one was writing, and it was the only repo in the org actually being billed. The fix wasn't caching. It was stop scanning a backup.
The remedy is rarely "delete the workflow": it's disable it on this repo, or narrow the trigger (on: push → a weekly schedule), or scan the source repo instead of the mirror. ⚠️ If the thing you're switching off is security scanning (CodeQL, dependency review), say so explicitly and confirm the source repo is still covered — see Hard limits.
Phase 3 — Standard optimization checklist
Per workflow, check and report each item (details and canonical diffs → REFERENCE.md §3):
Phase 4 — Report, then wait for approval
Output a table:
| Workflow |
Est. minutes/month |
Finding |
Potential saving |
Severity |
Group recommendations into three buckets, in this order:
- Delete / disable — fully redundant work
- Reduce frequency — trigger changes (paths filters, drop push-on-branch, thin the schedule)
- Optimize — cache, concurrency, runner downgrade
For every recommendation show the exact diff. Never edit workflow files without explicit approval — this skill's output is the report and the diffs, not applied changes.
Hard limits
- Never delete or disable security-scanning or dependency-update workflows (CodeQL, Dependabot,
npm audit, Renovate) without an explicit, separate warning — cost is not the only axis.
- Never assume the CI build is redundant until you've verified the platform actually builds this repo (Phase 2). "There's a
vercel.json" is a lead, not proof.
- Never fabricate minutes. No data → say so, and mark estimates as estimates.
- Public repo → free Actions on standard runners only; confirm that (not just the visibility) before leading with it (Phase 0). A public repo on larger runners bills like a private one.
Red flags — stop
| Thought |
Reality |
| "I'll add caching to every workflow" |
Phase 2 first. The biggest win is a job that stops running. |
| "There's a vercel.json, so the CI build is redundant" |
Verify the platform is connected and building. Then it's CRITICAL. |
| "Roughly 400 minutes/month" (no data) |
Measured, or labeled "estimate based on workflow content only". Never in between. |
| "This nightly workflow looks unused, deleting" |
It might be security scanning. Warn explicitly; the human decides. |
| "I'll just apply the obvious fixes" |
Report + diffs only. Edits happen after explicit approval. |
| "Top consumer: 1,735 minutes — start there" |
Check visibility first. Public on standard runners → those minutes are free; you'd be optimizing $0. (Public on larger runners still bills — check net.) |
| "This repo has the biggest bill — that's the problem" |
net is order-dependent: the included minutes are a shared pool, so it fingers whoever ran after it drained. Rank private/internal repos by gross (SKU-weighted list price, order-independent — not raw minutes, which is SKU-blind); treat net > 0 as an org-level alarm. |
| "This repo runs CI constantly — it must be busy" |
Measure jitter relative to the median gap. Under ~5%? That's a cron pushing to a mirror, not a team. (Don't bucket by minute-of-hour — a cron that drifts across :00/:01 escapes it.) Ask whether it should run at all. |
| "The commits are from a real person, so it's active" |
An automated backup pushes under a human's name. Cadence is the tell; authorship lies. |
1---2name: gha-optimizer3description: Audit and cut GitHub Actions minutes consumption in a repo — measure real usage first, hunt REDUNDANT work before optimizing existing work (does this job need to run at all? at this frequency? only then tune it), detect double-builds against deploy platforms (Vercel/Netlify/Cloudflare Pages/Cloudways), then propose exact diffs grouped by delete/reduce-frequency/optimize. Use whenever the user's intent is to SPEND LESS on CI — reduce GitHub Actions minutes, audit CI cost or billing, ask why Actions usage is high, ask what CI work can be cut or is redundant, request a CI-efficiency review of `.github/workflows/`, or mention "gha-optimizer". Read-only by default — never edits workflows without explicit approval. The dividing line is intent, not subject matter: this skill answers "what is our CI costing us, and what can we delete?" It is the wrong skill for "make this CI work" — writing a new workflow, or fixing a red/failing/misconfigured job — where the user wants MORE working CI, not less CI.4---56# GitHub Actions Optimizer78**The order is the skill.** For every job, ask in this order — and do not skip ahead:9101. **Does this job need to run at all?** (redundant with a deploy platform, dead, obsolete)112. **Does it need to run this often / on these triggers?** (push+PR double-runs, unfiltered paths, stale schedules)123. **Only then — how do we make it faster?** (cache, concurrency, runner choice)1314Jumping straight to caching a job that shouldn't exist optimizes waste. A deleted job saves 100%; a cached one saves 30%.1516## Phase 0 — Is this even a problem?1718Two independent ways a repo can have **$0 GitHub Actions spend** — check both before declaring the exercise moot:19201. **Self-hosted runners are free.** GitHub bills nothing for `runs-on: [self-hosted, …]` — the cost lives in *your own* infra, not GitHub's meter. A repo whose expensive jobs run self-hosted has no GitHub minutes to save no matter how often it runs. Two traps here:21 - **A custom runner *group* (`runs-on: { group: … }`) is NOT a free signal by itself.** Runner groups can contain GitHub-hosted **larger** runners, which *are* billed (even on public repos). Don't declare $0 off the `group:` syntax — check what the group actually contains, or let the billing `net`/`gross` decide.22 - **Don't confuse self-hosted with *larger GitHub-hosted* runners.** `runs-on: ubuntu-latest-4-core` is billed; `runs-on: self-hosted` is not.2324 Where GitHub genuinely bills $0 (true self-hosted), time/queue optimization can still be worth reporting; **dollar** savings are zero.252. **Public repo on standard GitHub-hosted runners.** Actions is free and unlimited there — but *only* there. So for a public repo confirm both:26 - `runs-on` is a standard label (`ubuntu-latest`, `windows-latest`, `macos-latest`) — not a larger-runner label, **and**27 - its billing `net` is 0 (org path → REFERENCE §1).2829Either case true → say it up front: no cost to save (concurrency-cancel and friends are still worth reporting for **queue time**, not money). A public repo on **larger** runners is a **real cost target** — treat it like a private one. When in doubt, let the usage report's `net`/`gross` decide — a repo that bills $0 across a full month has nothing to optimize for money.3031**Auditing a whole org? Two traps, in order.** (Recipe → REFERENCE §1.)32331. **Public repos are listed too, usually at $0.** Ranking by raw *minutes* floats free repos to the top and sends you optimizing work that cannot save a cent. Annotate `visibility` and drop them — **but only the ones billed at zero**. Public is free on **standard** runners only; **larger runners are billed on public repos too**, so a public repo with `net > 0` has real spend and stays in scope.342. **Then rank the private/internal repos by `gross` (list-price cost) — not by `net`, not by raw `minutes`.** `net` is order-dependent: the included minutes are a **shared org pool**, so once it's drained whatever runs next gets billed — `net` fingers whichever repo happened to go last, not the one that ate the pool (observed: the org's **highest bill** was **72 Linux minutes**, while two backup repos burning **1,640 min/month between them** showed a smaller `net`). Raw `minutes` is order-independent but SKU-blind — 200 macOS min outcost 1,000 Linux min. `gross` (pre-discount `pricePerUnit × quantity`) is both SKU-weighted and order-independent, so it names the real culprit; on an all-Linux org it collapses to the minutes ranking. Treat `net > 0` as an **org-level alarm** ("marginal minutes cost money" — the standard-runner pool is drained *or* the org runs always-billed larger runners), never as a per-repo verdict.3536## Phase 1 — Measure, don't guess3738- Read **every** file in `.github/workflows/`.39- If the `gh` CLI (or the GitHub MCP tools) is available, pull real run data and compute per workflow: average duration, run frequency, and the **superseded rate**. Treat raw `cancelled ÷ total` as an **upper bound**, not measured waste — the `cancelled` conclusion also covers manual cancellations and runs an *existing* concurrency group already killed; confirm a newer run on the **same workflow + ref** before counting a cancel as superseded (a failing run that catches a bug is CI *working*, not waste). Exact commands → [REFERENCE.md](REFERENCE.md) §1.40- **Ask the API for billed minutes.** The usage report gives real charged minutes and dollars per repo/SKU — the only source with multipliers and per-job rounding already applied. Per-run duration comes from the **jobs** endpoint (`started_at→completed_at`); the older run-*timing* endpoint is closing down (billing-platform migration, 2025-04-01) — legacy fallback only. Either way the per-job number is **raw job time**: apply the OS multiplier (**Linux ×1, Windows ×2, macOS ≈×10**) and per-job rounding yourself when estimating from it — and real SKUs (`Actions macOS 3-core` at $0.062/min vs Linux $0.006) and larger runners have their own rates. Watch two zero/low cases the ×1 Linux rate hides: **self-hosted runners are free** ($0), and **`ubuntu-slim` (1-core, $0.002) / arm64 ($0.005)** cost less than the $0.006 catch-all — full SKU table in REFERENCE §Multipliers.41- **No `gh` / no billing access? Say so explicitly.** Never invent minute numbers — label every figure "estimate based on workflow content only" and derive it from trigger frequency × step count, clearly marked as such.4243## Phase 2 — Redundancy audit (the highest-value pass)4445Look for work a deploy platform already does:4647- **Vercel / Netlify / Cloudflare Pages** build every push automatically. A workflow that runs `next build` / `npm run build` for deploy purposes is a double-build → **CRITICAL**. Detect via `vercel.json`, `.vercel/`, `netlify.toml`, `wrangler.toml` — and **verify** the platform is actually connected (config file alone isn't proof; check for platform bot comments on PRs, deployment statuses, or ask the user) before declaring the CI build redundant. A build that exists only to *gate* (typecheck/test) is not redundant — but then it shouldn't upload/deploy artifacts.48- **Cloudways / git-pull deployments**: a workflow doing rsync/scp/ssh deploy that the platform could do via a git webhook → flag as replaceable.49- **Docker registry builds**: CI building an image the platform (or another pipeline) also builds → flag.5051**Then ask a different question: is a *robot* pulling the trigger?**5253The workflow can be perfectly configured and still be pure waste — because the thing pushing isn't a person. Mirror, backup, sync and archive repos take automated pushes on a fixed cadence, and **every push re-runs the full CI or security scan**, forever, on code nobody is developing.5455Cheapest tell: **the inter-run jitter is negligible *relative to the gap*.** A cron fires on a near-constant interval; a human doesn't. Measure the median gap between **distinct pushes** — collapse same-commit runs first (CI + CodeQL fire off one push seconds apart; measuring *those* gaps makes an hourly robot look bursty) — and the mean deviation from it — `rel-jitter < 5%` is a machine. (Do **not** bucket by exact minute-of-hour: a backup that drifts across `:00`/`:01`/`:02` escapes it, and a bursty human repo trips it. And do **not** trust the commit author — an automated backup usually pushes under a *human's* name.) Detection command → REFERENCE §2.5657Real case: a 300 MB backup repo received an hourly automated push; each one triggered a full CodeQL scan — **24 scans/day of code no one was writing**, and it was the only repo in the org actually being billed. The fix wasn't caching. It was *stop scanning a backup*.5859The remedy is rarely "delete the workflow": it's **disable it on this repo**, or narrow the trigger (`on: push` → a weekly `schedule`), or scan the **source** repo instead of the mirror. ⚠️ If the thing you're switching off is security scanning (CodeQL, dependency review), say so explicitly and confirm the *source* repo is still covered — see Hard limits.6061## Phase 3 — Standard optimization checklist6263Per workflow, check and report each item (details and canonical diffs → REFERENCE.md §3):6465- [ ] `concurrency` group with `cancel-in-progress: true`66- [ ] `paths` / `paths-ignore` filters on triggers (job-level gating if the check is required — REFERENCE §3)67- [ ] Double-run on `push` + `pull_request` for the same commit68- [ ] `actions/cache` present and keyed correctly (lockfile-hash key + restore-keys)69- [ ] `runs-on`: Windows/macOS without justification (×2 / ×10)70- [ ] Matrix builds — necessary, or trimmable (e.g. one OS × one Node LTS on PRs, full matrix on main/release)71- [ ] `fetch-depth: 0` on checkout without a reason (slow, rarely needed)72- [ ] Long steps that belong in a separate conditional job73- [ ] `on: schedule` workflows — still relevant? Running on a fork/stale repo?7475## Phase 4 — Report, then wait for approval7677Output a table:7879| Workflow | Est. minutes/month | Finding | Potential saving | Severity |80|---|---|---|---|---|8182Group recommendations into three buckets, in this order:83841. **Delete / disable** — fully redundant work852. **Reduce frequency** — trigger changes (paths filters, drop push-on-branch, thin the schedule)863. **Optimize** — cache, concurrency, runner downgrade8788For every recommendation show the **exact diff**. **Never edit workflow files without explicit approval** — this skill's output is the report and the diffs, not applied changes.8990## Hard limits9192- **Never delete or disable** security-scanning or dependency-update workflows (CodeQL, Dependabot, `npm audit`, Renovate) without an explicit, separate warning — cost is not the only axis.93- **Never assume the CI build is redundant** until you've verified the platform actually builds this repo (Phase 2). "There's a `vercel.json`" is a lead, not proof.94- **Never fabricate minutes.** No data → say so, and mark estimates as estimates.95- Public repo → free Actions **on standard runners only**; confirm that (not just the visibility) before leading with it (Phase 0). A public repo on larger runners bills like a private one.9697## Red flags — stop9899| Thought | Reality |100|---|---|101| "I'll add caching to every workflow" | Phase 2 first. The biggest win is a job that stops running. |102| "There's a vercel.json, so the CI build is redundant" | Verify the platform is connected and building. Then it's CRITICAL. |103| "Roughly 400 minutes/month" (no data) | Measured, or labeled "estimate based on workflow content only". Never in between. |104| "This nightly workflow looks unused, deleting" | It might be security scanning. Warn explicitly; the human decides. |105| "I'll just apply the obvious fixes" | Report + diffs only. Edits happen after explicit approval. |106| "Top consumer: 1,735 minutes — start there" | Check `visibility` first. Public **on standard runners** → those minutes are **free**; you'd be optimizing $0. (Public on *larger* runners still bills — check `net`.) |107| "This repo has the biggest bill — that's the problem" | `net` is order-dependent: the included minutes are a **shared pool**, so it fingers whoever ran *after* it drained. Rank **private/internal repos by `gross`** (SKU-weighted list price, order-independent — not raw minutes, which is SKU-blind); treat `net > 0` as an org-level alarm. |108| "This repo runs CI constantly — it must be busy" | Measure jitter **relative to the median gap**. Under ~5%? That's a **cron pushing to a mirror**, not a team. (Don't bucket by minute-of-hour — a cron that drifts across `:00`/`:01` escapes it.) Ask whether it should run at all. |109| "The commits are from a real person, so it's active" | An automated backup pushes under a **human's** name. Cadence is the tell; authorship lies. |