CI Pipeline Speed skill for Claude Code
Shorten the critical path, prove it with medians. Claude baselines several recent runs with a bundled analyzer that finds the critical path, queue delay and slowest steps, then works on that path only: removing duplicate work, adding correctly keyed caches, sharding tests by timing data, and running affected-only builds in monorepos. Results are reported as before-and-after medians with test counts unchanged.
- name
ci-pipeline-speed - size171 lines · ≈2,100 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Makes CI pipelines faster by measuring first and then attacking the critical path - per-job and per-step timing, queue delay, dependency and build caches, Docker layer and remote build caches, parallel jobs and test sharding by timing data, selective runs with path filters and affected-graph tools (Nx, Turborepo, Bazel, Pants), fail-fast ordering, and right-sized runners - on GitHub Actions, GitLab CI, CircleCI or Buildkite, with a bundled run-timing analyzer. Use when CI is slow, PR feedback takes too long, the user asks to cut pipeline minutes or cost, or wants test splitting or monorepo affected-only builds. Try asking
- Our PR pipeline takes 25 minutes. Find the critical path and cut it in half
- Split our Playwright suite across parallel jobs based on test timings
- Only build and test the packages affected by a change in our Turborepo monorepo
Use it when
- CI feedback on pull requests is too slow
- Pipeline minutes or runner costs are climbing
- You want test sharding, remote build caches or affected-only runs
- Jobs repeat installs or builds that could be shared
Skip it when
- You need GitHub Actions syntax, security or OIDC setup — use github-actions
- Tests fail intermittently rather than run slowly — use flaky-test-triage
How to check the result
Median and p90 wall time across at least five warm runs drop compared with the baseline from scripts/ci_timing.py, with the same number of tests executed.
The common mistake it prevents
Speeding up a job that runs in parallel with a longer one, which burns effort and changes nothing because it was never on the critical path.
A pipeline is faster when the median and p90 wall time from push to green dropped on warm runs of the same event type, measured over several runs, with no loss of test coverage and no new flakiness. One lucky run is not a result.
Ground rules
- Measure before changing anything: collect 5-10 recent runs, compute median wall time, queue delay, and the critical path. Optimize the critical path only; speeding up a job that runs in parallel with a longer one changes nothing.
- Never skip or delete tests to get speed without the user’s explicit agreement. Moving slow suites to a later stage (merge queue, nightly) is a decision to surface, not to make silently.
- Caches must be keyed on lockfile hashes and OS/arch. A wrong cache is worse than no cache.
- Every speedup is verified by comparing medians, not one run.
Workflow
- [ ] 1. Baseline: wall time, queue, critical path, slowest steps (5-10 runs)
- [ ] 2. Remove waste: skip irrelevant runs, cancel superseded, drop duplicate work
- [ ] 3. Cache: dependencies, build outputs, Docker layers, tool binaries
- [ ] 4. Parallelize: split jobs, shard tests by timing, restructure the DAG
- [ ] 5. Run less: affected-only builds and tests in monorepos
- [ ] 6. Make each step cheaper: runner size, faster tools, fail-fast ordering
- [ ] 7. Re-measure medians; report before/after
1. Baseline
GitHub Actions:
gh run list --workflow ci.yml --branch main --status success --limit 10 --json databaseId \
-q '.[].databaseId' | while read id; do gh run view "$id" --json createdAt,updatedAt,jobs > "run-$id.json"; done
python3 scripts/ci_timing.py run-*.json --top 10
scripts/ci_timing.py prints median wall time, queue delay before the first job, job
and step medians, the inferred critical path (chain of jobs ending last), and a
parallelism ratio. Low parallelism (near 1x) with many jobs means a serial DAG; a large
queue delay means runner capacity, not pipeline design.
Other systems: GitLab GET /projects/:id/pipelines/:pipeline_id/jobs (started_at,
finished_at, duration, queued_duration); CircleCI Insights API; Buildkite build JSON.
Map them to the same fields (jobs[].name/startedAt/completedAt/steps[]) to reuse the
script.
Record: median wall time, p90, queue delay, critical-path jobs, top 5 steps.
2. Remove waste
| Waste | Fix |
|---|---|
| Docs-only or unrelated changes run everything | Path filters per workflow/job (keep a required summary job always running) |
| Pushes to a PR stack up runs | Concurrency groups with cancel-in-progress for PRs only |
| Same build in many jobs | Build once, pass artifacts (or cached outputs) to downstream jobs |
npm ci/pip install repeated in every job | Cache the package store; or one setup job with artifacts if install is slow |
| Running push and pull_request for the same commit | Trigger PR CI on pull_request; push only for main |
| Full E2E on every commit to a PR | E2E on merge queue / main, smoke subset on PR (with user agreement) |
| Long timeouts hiding hung jobs | Set job timeouts close to 2x the median |
3. Cache
| Layer | Approach | Key |
|---|---|---|
| Package manager store | setup-node/python/go/java built-in cache, GitLab cache: | lockfile hash + OS |
| Build tool cache | Gradle build cache, Turborepo/Nx remote cache, Bazel remote cache, ccache/sccache | content hashes (tool-managed) |
| Compiled outputs | actions/cache on .next/cache, target/ (Rust: Swatinem/rust-cache) | lockfile + source hash with restore-keys fallback |
| Docker layers | BuildKit cache-from/cache-to (type=gha, type=registry), dependency-first Dockerfile | per branch with main fallback |
| Tool binaries | Cache downloaded CLIs, or bake them into a custom runner image | tool version |
| Test fixtures / browsers | Cache Playwright browsers keyed on Playwright version | version |
Check hit rate in logs (“Cache restored from key”). A cache that takes longer to
restore than the work it saves (very large node_modules tarballs, many small files)
should be removed or narrowed.
Docker builds (see dockerfile-optimization for the Dockerfile itself):
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/acme/api:${{ github.sha }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
4. Parallelize
- Split independent work into parallel jobs: lint, typecheck, unit tests, build.
- Restructure the DAG with
needs:so jobs start as soon as their real dependencies finish (GitLab:needs:instead of stage ordering). - Shard tests by historical timing, not by file count:
| Runner | Sharding |
|---|---|
| Playwright | npx playwright test --shard=${{ matrix.shard }}/4 then merge blob reports |
| Jest / Vitest | --shard=1/4 (Vitest, Jest 28+) |
| pytest | pytest-split with stored .test_durations (--splits 4 --group N), or pytest-xdist -n auto within a job |
| Go | go test ./... already parallel by package; split packages across jobs by timing |
| RSpec / others | Knapsack Pro, CircleCI circleci tests split --split-by=timings, Buildkite test splitting |
Rule of thumb: shard count = ceil(suite time / target time); stop adding shards when per-shard setup (checkout, install, boot) exceeds ~30% of shard time.
5. Run less (monorepos)
| Tool | Affected-only command |
|---|---|
| Nx | nx affected -t lint test build --base=origin/main --head=HEAD |
| Turborepo | turbo run test --filter='...[origin/main]' plus remote cache |
| Bazel | Remote cache makes unchanged targets free; bazel test //... with --remote_cache |
| Pants | pants --changed-since=origin/main --changed-dependents=transitive test |
| Plain repos | Path-filter jobs per package; a “changes” job outputs which packages changed |
Fetch enough history for the base comparison (fetch-depth: 0 or fetch the base ref).
Run the full suite on main or nightly to catch graph mistakes.
6. Make steps cheaper
- Order fast, likely-to-fail checks first (lint, typecheck, unit) and gate expensive jobs on them, so broken PRs fail in 2 minutes instead of 20.
- Faster tools:
uvinstead of pip,pnpminstead of npm, esbuild/swc-based transpilation for tests,ruffinstead of flake8+isort+black, Go build cache. - Larger runners: CPU-bound steps that parallelize (compilation, xdist tests) scale with cores; measure cost per run, since a 4x runner finishing in 1/3 the time is cheaper in developer wait and similar in minutes.
- Services: start databases as service containers once per job, use tmpfs for test databases, and avoid sleeping for readiness (use health checks).
- Flaky retries add time; fix them (see flaky-test-triage) rather than raising retries.
7. Re-measure and report
Collect the same number of warm runs after the change, same event type and branch type, and compare medians and p90.
Gotchas
- First runs after cache key changes are cold; exclude them or report separately.
- GitHub Actions cache storage is limited per repository (oldest entries evicted);
mode=maxDocker caches for many branches can evict your dependency caches. - Caches created on feature branches are not readable from other feature branches on GitHub Actions (only from the default branch); warm caches on main.
- Path filters on required checks leave PRs blocked when the workflow is skipped; use a job that always runs and reports.
- Sharding multiplies setup cost and runner usage; it improves wall time, not minutes.
- Test-timing files go stale; regenerate them periodically or let the splitter record timings automatically.
- Affected-graph tools miss changes to non-declared inputs (env vars, root config
files); declare global inputs (Nx
namedInputs, TurborepoglobalDependencies).
Output
**Baseline (10 warm PR runs):** median 14m50s, p90 18m10s, queue 20s; critical path build -> e2e
**Changes:** Docker gha cache (build 4m40s -> 1m50s); Playwright 4 shards (e2e 9m35s -> 3m05s);
lint/typecheck gate before e2e; docs paths skipped
**After (10 warm PR runs):** median 6m10s, p90 7m40s; critical path build -> e2e shard 3
**Coverage:** same test count (1,842); no retries added
**Cost:** runner minutes +12% (shards) for -58% wall time Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/ci_timing.py
#!/usr/bin/env python3
"""Find where CI time goes from GitHub Actions run JSON.
Usage:
gh run view <run-id> --json createdAt,updatedAt,jobs > run1.json
python3 scripts/ci_timing.py run1.json [run2.json ...] [--top 8]
With several runs (same workflow), durations are reported as medians.
Prints: wall time, queue delay, per-job durations, the inferred critical path
(chain of jobs that ends last, each starting after its predecessor finished),
the slowest steps, and the parallelism ratio (sum of job time / wall time).
Exit 0 on success, 2 on unusable input.
"""
import argparse
import json
import statistics
import sys
from collections import defaultdict
from datetime import datetime
def ts(value):
if not value or value.startswith("0001-"):
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def secs(a, b):
return (b - a).total_seconds() if a and b else None
def fmt(s):
if s is None:
return "-"
s = int(round(s))
return f"{s // 60}m{s % 60:02d}s"
def critical_path(jobs):
"""Greedy back-walk: last-finishing job, then the latest job that finished before it started."""
timed = [j for j in jobs if j["start"] and j["end"]]
if not timed:
return []
path = [max(timed, key=lambda j: j["end"])]
while True:
cur = path[-1]
preds = [j for j in timed if j["end"] <= cur["start"] and j is not cur]
if not preds:
break
prev = max(preds, key=lambda j: j["end"])
# only treat as dependency if it ended within 60s of cur starting (queue gap)
if secs(prev["end"], cur["start"]) > 60:
break
path.append(prev)
return list(reversed(path))
def analyze(path):
data = json.load(open(path, encoding="utf-8"))
jobs = []
for j in data.get("jobs", []):
jobs.append({
"name": j.get("name", "?"),
"start": ts(j.get("startedAt")),
"end": ts(j.get("completedAt")),
"conclusion": j.get("conclusion", ""),
"steps": [
(s.get("name", "?"), secs(ts(s.get("startedAt")), ts(s.get("completedAt"))))
for s in j.get("steps", [])
],
})
if not jobs:
raise ValueError(f"{path}: no jobs found (did you pass --json jobs?)")
starts = [j["start"] for j in jobs if j["start"]]
ends = [j["end"] for j in jobs if j["end"]]
created = ts(data.get("createdAt")) or (min(starts) if starts else None)
wall = secs(created, max(ends)) if ends else None
queue = secs(created, min(starts)) if starts else None
return jobs, wall, queue
def main():
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
ap.add_argument("--top", type=int, default=8)
a = ap.parse_args()
walls, queues, busy_ratios = [], [], []
job_d = defaultdict(list)
step_d = defaultdict(list)
path_counts = defaultdict(int)
for f in a.files:
try:
jobs, wall, queue = analyze(f)
except (ValueError, json.JSONDecodeError, OSError) as e:
print(f"error: {e}")
return 2
if wall:
walls.append(wall)
total = sum(secs(j["start"], j["end"]) or 0 for j in jobs)
busy_ratios.append(total / wall if wall else 0)
if queue is not None:
queues.append(queue)
for j in jobs:
d = secs(j["start"], j["end"])
if d is not None:
job_d[j["name"]].append(d)
for name, sd in j["steps"]:
if sd is not None:
step_d[(j["name"], name)].append(sd)
path_counts[" -> ".join(j["name"] for j in critical_path(jobs))] += 1
med = lambda v: statistics.median(v) if v else None
print(f"runs analyzed: {len(a.files)}")
print(f"wall time (median): {fmt(med(walls))} first-job queue delay (median): {fmt(med(queues))}")
if busy_ratios:
print(f"parallelism (job-seconds / wall-seconds, median): {med(busy_ratios):.1f}x")
print("\njobs by median duration:")
for name, v in sorted(job_d.items(), key=lambda kv: -med(kv[1]))[: a.top]:
print(f" {fmt(med(v)):>8} {name}")
print("\ninferred critical path (most common):")
best = max(path_counts.items(), key=lambda kv: kv[1])
print(f" {best[0]} ({best[1]}/{len(a.files)} runs)")
print("\nslowest steps (median):")
for (job, step), v in sorted(step_d.items(), key=lambda kv: -med(kv[1]))[: a.top]:
print(f" {fmt(med(v)):>8} {job} / {step}")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.