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.

  • nameci-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

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.

~/.claude/skills/ci-pipeline-speed/SKILL.md

--- name: ci-pipeline-speed description: Makes CI pipelines faster by measuring first and then attacking the critical path - per-job and per-step timing, queue delay, dependency… license: MIT ---

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

WasteFix
Docs-only or unrelated changes run everythingPath filters per workflow/job (keep a required summary job always running)
Pushes to a PR stack up runsConcurrency groups with cancel-in-progress for PRs only
Same build in many jobsBuild once, pass artifacts (or cached outputs) to downstream jobs
npm ci/pip install repeated in every jobCache the package store; or one setup job with artifacts if install is slow
Running push and pull_request for the same commitTrigger PR CI on pull_request; push only for main
Full E2E on every commit to a PRE2E on merge queue / main, smoke subset on PR (with user agreement)
Long timeouts hiding hung jobsSet job timeouts close to 2x the median

3. Cache

LayerApproachKey
Package manager storesetup-node/python/go/java built-in cache, GitLab cache:lockfile hash + OS
Build tool cacheGradle build cache, Turborepo/Nx remote cache, Bazel remote cache, ccache/sccachecontent hashes (tool-managed)
Compiled outputsactions/cache on .next/cache, target/ (Rust: Swatinem/rust-cache)lockfile + source hash with restore-keys fallback
Docker layersBuildKit cache-from/cache-to (type=gha, type=registry), dependency-first Dockerfileper branch with main fallback
Tool binariesCache downloaded CLIs, or bake them into a custom runner imagetool version
Test fixtures / browsersCache Playwright browsers keyed on Playwright versionversion

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:
RunnerSharding
Playwrightnpx playwright test --shard=${{ matrix.shard }}/4 then merge blob reports
Jest / Vitest--shard=1/4 (Vitest, Jest 28+)
pytestpytest-split with stored .test_durations (--splits 4 --group N), or pytest-xdist -n auto within a job
Gogo test ./... already parallel by package; split packages across jobs by timing
RSpec / othersKnapsack 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)

ToolAffected-only command
Nxnx affected -t lint test build --base=origin/main --head=HEAD
Turborepoturbo run test --filter='...[origin/main]' plus remote cache
BazelRemote cache makes unchanged targets free; bazel test //... with --remote_cache
Pantspants --changed-since=origin/main --changed-dependents=transitive test
Plain reposPath-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: uv instead of pip, pnpm instead of npm, esbuild/swc-based transpilation for tests, ruff instead 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=max Docker 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, Turborepo globalDependencies).

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 134 lines · 4.5 KB
#!/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.

esc