# CI Audit

> Audit GitHub Actions workflows for what costs wall clock, costs money, or lets a pipeline pass without checking anything. Builds the job graph, then runs a catalog covering gating correctness, the critical path, caching effectiveness, and spend. Reads and reports only; never edits a workflow.

- Skill: `edloidas/ci-audit` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add edloidas/ci-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/edloidas/ci-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: edloidas (https://skillmd.com/u/edloidas)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/edloidas/ci-audit

---


# CI Audit (GitHub Actions)

Audit the workflows in `.github/workflows/` for wall clock, spend, and whether the gating actually
gates. This skill **reads and reports**; it never edits a workflow.

Scoped to GitHub Actions deliberately. The checks below are about Actions' own semantics — skipped
results counting as passing, matrix legs in check names, `concurrency` groups, artifact retention —
and none of it transfers to another CI system. On a repository with no `.github/workflows/`, say so
and stop rather than guessing at a Jenkinsfile or a `.gitlab-ci.yml`.

## What this owns, and what it does not

`security-audit` has its own GitHub Actions auditor and the two overlap on the same files. The split
is by **consequence**, and it is stated in both skills:

| This skill | `security-audit` |
| ---------- | ---------------- |
| Performance, spend, and gating correctness | The security surface |
| Cache **hit rate** — key derivation, fallbacks | Cache **poisoning** and cross-branch scope |
| Whether a required check can fail the run | Whether a workflow can be made to run attacker code |
| Job graph, matrix design, artifact flow | `permissions:`, `persist-credentials`, `pull_request_target`, OIDC |
| — | Action pinning: SHA vs tag vs branch, with severities |

Two consequences worth stating plainly. **Do not report action pinning** — it looks like a
maintenance finding and is a supply-chain one, `security-audit` grades it, and duplicating it here
produces two different severities for one line of YAML. And when a required check name matches no
workflow, report the observation and hand it over: reconciling rulesets against triggers needs the
`gh api` calls that skill already makes.

## When to use

Trigger phrases: `ci audit`, `github actions`, `workflow performance`, `actions minutes`, `ci slow`,
`parallelize ci`, `ci caching`, `required check`. "Why did that merge when the tests failed" is
family A — gating correctness.

## Phase 1: Inventory

Glob `.github/workflows/*.yml` and `.github/workflows/*.yaml`. An empty result — the directory is
missing, or holds no YAML — means print `No GitHub Actions workflows found.` and stop.

Use globbing rather than shelling out. `fd` is not guaranteed present, and it exits non-zero with
`Search path is not a directory` when `.github/workflows/` is absent, so a bare `fd` returns an error
where the stop condition expects an empty result. Where a shell is preferred anyway, guard it:

```bash
[ -d .github/workflows ] && ls .github/workflows/*.y*ml 2>/dev/null
```

For each workflow, extract the shape before reading any step. The job graph is what most findings are
about, and it is not legible by reading top to bottom:

```bash
yq -r '.jobs | to_entries | map(.key + " <- " + ((.value.needs // []) | tostring)) | .[]' <file>
yq -r '[.on | keys | join(",")] | join("")' <file>
yq -r '.concurrency // "none"' <file>
```

Record per workflow: triggers, the `concurrency` block, the `needs` graph, which jobs carry a
`strategy.matrix`, which have `timeout-minutes`, and which upload or download artifacts.

Then state the **critical path** — the longest chain through the graph — because that is the number
any parallelization finding has to move. Splitting a job that is not on it changes nothing.

Where the run history is available, get real numbers rather than guessing which job is slow:

```bash
gh run list --workflow <file> --limit 20 --json databaseId,conclusion,createdAt,updatedAt
gh run view <id> --json jobs --jq '.jobs[] | "\(.name) \(.startedAt) \(.completedAt) \(.conclusion)"'
```

Say whether timings are measured or estimated. An estimated saving stated as a measured one is the
fastest way to lose an audit's credibility.

Close the phase with one line carrying the counts:
`Inventory: 4 workflows, 11 jobs, critical path lint -> build -> e2e -> gate, ~9m measured over 20 runs.`

## Phase 2: Run the catalog

Read `references/checks.md` and work the four families. Each check there carries what to look for,
the cost when it is wrong, and the shape that fixes it.

| Family | Covers |
| ------ | ------ |
| **A. Gating correctness** | Matrix jobs with no stable fan-in gate, jobs that cannot fail the run, gates on noisy signals, required names nothing produces |
| **B. Critical path** | Serialized independent steps, no cheap head gate, expensive jobs on every event, setup repeated instead of artifacts consumed, push and pull-request double runs |
| **C. Caching** | Keys not derived from the lockfile, missing `restore-keys`, redundant or broken auto-caches, caching what is cheaper to rebuild |
| **D. Spend and hygiene** | Missing `timeout-minutes`, default artifact retention, diagnostics uploaded unconditionally, no `concurrency` group, `cancel-in-progress` on irreversible work, needless full history, fixed sleeps |

**Family A first.** A pipeline that is fast and gates nothing is worse than a slow one, and these
findings fail green — nobody notices them from the run list.

Two rules on severity:

- **Rate by consequence, not by how odd the YAML looks.** A missing `timeout-minutes` on a job that
  reliably finishes in 40 seconds is a low finding; the same gap on a job that can hang on a dev
  server is the one that burns six hours.
- **A finding needs the number it moves.** "Split these jobs" is not a finding. "These three steps
  are independent and sit on the critical path; splitting them removes ~90s from every run" is.
  Where the number cannot be established, say it is an estimate.

Do not recommend splitting a unified check command until you have established that the toolchain does
not already parallelize internally — `vitest`, `turbo`, and `biome check` all do, and splitting them
adds runner startup for no gain.

Recognise what is already right. A workflow doing the non-obvious things well — a fan-in gate with
`if: always()`, `cancel-in-progress: false` on the release, an unprivileged job producing the
artifact a privileged one consumes — should be told so, in one line each. It is how the report earns
the right to be believed about the rest.

Close the phase with one line carrying the counts:
`Catalog: families A-D run over 4 workflows, 7 findings (2 gating, 3 critical path, 1 caching, 1 spend), 2 checks not run.`

## Phase 3: Report

```
## CI audit: <N> workflows, <J> jobs · critical path <T> (measured|estimated)

### Gating correctness
<finding: what is wrong, what it lets through, the shape that fixes it>

### Critical path
<finding, with the time it moves>

### Caching
<finding>

### Spend and hygiene
<finding>

### Already right
- <one line per non-obvious thing the workflows get right>

### Handed to security-audit
- <required-check reconciliation, or anything touching the security surface>

### Not checked
- <check or family> — <why it could not run>
```

Drop any finding section with no findings. Do not pad a clean result — a workflow set with nothing
wrong is a real outcome, and `Already right` carries it.

`Not checked` is the one section that is never dropped while anything is in it, and every check
that did not run goes in it — an unparsed workflow, a callee outside this repository, `yq` absent so
the graph was read by hand, `gh` absent or no run history so timings are estimates, a family skipped
for lack of input. A partial audit that omits them reads as a clean one.

A filled report:

```
## CI audit: 3 workflows, 9 jobs · critical path 8m40s (measured, 20 runs)

### Gating correctness
`ci.yml` — `test` is a `node: [20, 22]` matrix and protection requires `test (20)` / `test (22)`. A
failing leg leaves the `ci-ok` dependent *skipped*, which protection reads as passing. Add a fan-in
job with a stable name and `if: always()`, require that instead of the legs.

### Critical path
`ci.yml` — `lint`, `typecheck`, `build` are three sequential steps of one job on the critical path
and share no output. Splitting them into parallel jobs removes ~95s of the 8m40s (measured).

### Caching
`e2e.yml` — the Playwright cache keys on `runner.os` alone, so a browser bump is served the stale
entry and the install re-downloads anyway. Key on `hashFiles('pnpm-lock.yaml')` plus `restore-keys`.

### Spend and hygiene
`e2e.yml` — no `timeout-minutes` on `e2e`, which waits on a dev server. A hung run bills the full 6h
default. Set `timeout-minutes: 20`.

### Already right
- `release.yml` sets `cancel-in-progress: false` — a publish is never cancelled mid-run.
- `ci.yml` builds once in an unprivileged job and passes the artifact to `e2e`.

### Handed to security-audit
- Required check `security/codeql` matches no workflow here; reconciling it against the ruleset
  needs `gh api` calls that audit makes.

### Not checked
- `deploy.yml` family B — `uses: org/.github/.github/workflows/deploy.yml@v2`, a reusable workflow
  outside this repository; only the caller's graph was audited.
- `nightly.yml` timings — `gh run list` returned no runs; its findings are static.
```

Then stop. Report the findings; do not edit a workflow, do not open a PR, and do not run a second
pass over the same catalog.

`references/ci-template.yaml` is an optimized pnpm workflow with parallel jobs, path filters and
concurrency control; `references/ci-template-vp.yaml` is the Vite+ (`voidzero-dev/setup-vp`) variant.
Offer them when a repository is starting from nothing, not as a target to converge every pipeline on.

## Error handling

| Situation | Action |
| --------- | ------ |
| `.github/workflows/` holds no YAML, or is absent | Phase 1's stop condition. A directory with only a README is not a pipeline |
| Workflows exist but only `workflow_dispatch` | Audit them, and say nothing runs automatically |
| A workflow fails to parse | Report the parse error as the first finding and audit the rest |
| `yq` unavailable | Read the YAML directly, and say the job graph was derived by reading |
| `gh` unavailable or no run history | Audit statically, and mark every timing an estimate |
| A reusable workflow (`uses:` at job level) | Audit the caller's graph; say the callee was not read unless it is in this repository |
| A composite action in the repository | Read it — its steps are on the critical path too |
| Only one job, doing everything | Still audit families A, C and D. A single job is not automatically wrong |

Every row above that fires puts a line in `Not checked`, naming the check and the reason.

