Ci performance
Make a CI/CD pipeline faster. Use when asked to speed up CI, cut build/test wall-clock, fix a slow pipeline, or decide whether bigger runners vs sharding vs caching will help. A measure-the-critical-path mental model plus a stop-when-it-stops-paying ladder (cache deps -> parallel jobs -> shard tests -> isolate per-shard resources -> right-size runners -> build cache -> test selection -> merge queue).From its SKILL.md
npx -y skills add stonegiantstudio/skills --skill ci-performanceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.9 KB, as published. Nobody here has run it
CI performance
The whole game: CI wall-clock is the critical path (the slowest dependent chain), not the sum of the steps. So you either do less (cache what is unchanged; later, run only the tests a change can affect) or do it concurrently (parallel jobs; shard the suite). Hardware only helps if the work actually parallelizes.
Always measure first
Do not optimize from a guess. Pull the per-step durations of the slowest job and find the real long pole. Teams routinely "fix" the wrong thing.
- GitHub Actions:
gh api --paginate 'repos/{owner}/{repo}/actions/runs/{id}/jobs?per_page=100' --jq '.jobs[] | {name, steps: [.steps[] | {name, s: .started_at, e: .completed_at}]}', or compute each job'sstarted_at -> completed_at. Paginate -- the jobs API returns 30 per page by default, so a matrix-heavy run silently drops jobs past the first page and you measure the wrong long pole. - Distinguish execution time from queue/dependency wait. A job that "takes 2m" may be 40s of work behind 1m20s of waiting on its
needs:. You can't optimize the wait if the dependency is correct (e.g. don't deploy before tests pass). - Separate code-gate feedback (is my change sound? -> typecheck/lint/test) from deploy-prep (migrate/seed a staging env). Speeding the gates helps iteration; speeding deploy-prep only helps "all green" time.
The mental model
- Critical path, not sum. Independent branches in parallel collapse to
max(branch). Splitting a serial job into parallel jobs is the CI form of this. - Amdahl's law caps it. If fraction
fof work is serial, max speedup from P workers is1/(f + (1-f)/P), ceiling1/f. Adding parallelism never beats the slowest single branch -- shorten that. - This is how Bazel/Buck2 think: model the build as a DAG, run independent actions concurrently, cache identical ones. Buck2 "calculates the critical path and gets out of the way."
The ladder (work top-down, stop where it stops paying)
Rungs 1-2 are universal near-free wins worth taking regardless; from rung 3 on, aim each move at the measured critical path and stop where the next rung stops paying.
- Cache dependencies, keyed on the lockfile hash. Cache the package-manager store (not
node_modules), withrestore-keysfor partial hits. Cheapest universal win; also the mitigation for fan-out's repeated installs. Warm the cache on the default branch: a PR-branch hit depends onmainhaving already populated the key, so the high hit rates everyone quotes assume a cache the trunk push fills first. - Cancel superseded runs, and fail fast. A
concurrency:group keyed on the ref withcancel-in-progress: truekills the runs a rapid re-push made obsolete, and matrixfail-faststops the other legs once one goes red. Near-zero effort. This frees the pool rather than shortening your single run's critical path -- but on a contended branch the freed pool is your queue time. - Remove false ordering. Jobs with no real dependency should run in parallel (GitHub Actions runs them in parallel unless
needs:forces order). A monolithiclint -> typecheck -> testjob split into parallel jobs drops tomax(). - Split out the long pole. Targeting matters: isolate the slowest serial step (often
tsc) so it runs beside the fast ones, not in front. Splitting already-fast steps apart buys little. When several jobs need the same build, build once and fan out viaupload-artifact/download-artifactrather than rebuilding per job (distinct from dep caching -- it moves a produced artifact, not restores a store). - Shard the test suite once it is the long pole (rule of thumb: > ~5 min).
vitest --shard=i/N/jest --shard/playwright --shard/pytest-xdist/ Bazelshard_count. Prefer timing-based splitting (CircleCI--split-by=timings, Buildkite bin-packing) over file-count -- N shards only approach Nx if balanced. Merge reports in a follow-up job. - Isolate per-shard stateful resources. When tests share a DB/filesystem and cannot run concurrently, give each parallel unit its own resource keyed by the runner index (
TEST_SHARD_INDEX,PYTEST_XDIST_WORKER/worker_id,CI_NODE_INDEX) and keep tests serial within the unit. This is the industry-standard pattern (Bazel hermeticity +TEST_TMPDIR; xdist per-worker DB; shared-state collisions between parallel units are a leading cause of flaky failures). - Right-size the runner -- but only if the work parallelizes. A serially-pinned suite (e.g. vitest
fileParallelism: falsefor shared-DB tests) gets zero benefit from more cores; parallel jobs are the only lever. Bigger machines buy latency, not savings (per-minute scales with size). Default to Linux: Windows roughly ~1.7x the per-minute cost (and often slower I/O for JS/Node CI), macOS ~10x. Check current rates before quoting absolutes -- hosted pricing and the self-hosted economics shift (third-party runners like Blacksmith/WarpBuild advertise ~2x single-core speed; GitHub's 2026 pricing changed both hosted rates and the self-hosted fee). - Cache build output (content-addressable). Bazel CAS, Gradle build cache, Turborepo, Nx -- hash all declared inputs, key a store, replay on hit, share across machines. 50-90% reductions at high hit rates. Correctness hazard: a stale cache returning a wrong result. Defense: content-address on every input that affects the output. Over-key (extra miss, safe) rather than under-key (wrong result, dangerous). The only real failure mode is an undeclared input.
- Cache Docker layers -- whenever a Dockerfile is on the path, often the dominant build-time lever. Order the Dockerfile manifests-first (copy the lockfile and install before
COPY . .) so a source edit does not bust the dependency layer, and back it with a remote layer cache (docker buildx --cache-to/--cache-from type=gha,mode=max, orRUN --mount=type=cachefor the package store). Routinely 70%+ off. Same undeclared-input hazard as rung 8. - Skip re-running work whose inputs did not change (the same idea, applied to your own steps). E.g. re-seeding a shared staging DB on every PR is a no-op unless the seed/migration sources changed -- gate it on
git diff <base>...HEADtouching those paths (always run it on the trunk push so the env stays current). - Test selection (the scalable lever for large suites). Sharding makes one run faster but still runs every test on every change; cost grows as
changes x tests. Run only affected tests: graph-based first (Bazelrdeps, Nxaffected, Turborepoturbo run --filter, Jest--changedSince-- deterministic, free once you have a graph; in a JS monoreponx affected/turbo --filteris often the single biggest win), ML predictive only at extreme scale (Meta runs ~1/3 of dependent tests, catches >99.9% of regressions, ~2x cost cut). Graduate here when full-suite cost outgrows your ability to throw runners at it. - Throughput, not latency (many devs on one branch): merge queues / trains (speculative batching -- Uber SubmitQueue took mainline from 52% green to always-green; GitHub Merge Queue, Shopify) + flaky-test quarantine (Google auto-quarantines; Slack's auto-suppress moved main stability 20% -> 96%). A flaky required check stalls the whole queue, so pair them. Quarantine needs a detector: a rolling pass-rate dashboard (flag tests under ~98%) feeds the quarantine list; quarantine without detection rots into manual triage.
Caching a stateful artifact (DB, etc.) without poisoning it
A plain cache action saves at post-job, which is after tests mutate the file -- it would cache a polluted artifact. Use restore at the start + explicit save before the test step, so the cached copy is the clean built state. Verify the artifact is self-contained at save time (e.g. SQLite with no open WAL: the build process must have exited/checkpointed). On a cold key, parallel shards race to save the same key -- first wins, the rest no-op with a warning (not a failure).
Diagnostic questions for any pipeline
- What is the critical path in wall-clock? Optimize that, nothing else.
- Is the slow job CPU-bound and parallelizable, or pinned serial? Only the first benefits from more cores; the second needs more jobs.
- Is each parallel unit isolated, or fighting over shared state and going flaky?
- Are the cache keys complete -- every input that affects the output? An undeclared input is the one real cache hazard.
- Is this step's work a no-op when its inputs did not change? If so, gate it on a diff.
- Are obsolete runs from superseded pushes still burning the pool? Add
cancel-in-progress. Is a poorly-ordered Dockerfile on the critical path? Reorder manifests-first and cache layers.
Reference
reference.md (alongside this file) holds the primary sources behind every
claim above: Google "Taming Google-Scale Continuous Testing", Meta Predictive
Test Selection, Uber SubmitQueue, Shopify, Bazel/Buck2, GitHub Actions docs, and
runner pricing.
A measured application of this ladder on a real Vitest + SQLite pipeline:
parallel gate jobs + a 3-way vitest --shard (each shard its own freshly
seeded local.db) cut code-gate feedback from ~3.4 min to ~1.5 min (~2.4x). A
seeded-DB cache (restore + save-before-tests) and a diff-gated staging re-seed
trimmed the tail further. Bigger runners were explicitly rejected -- the suite
is fileParallelism: false, so extra cores sat idle.
This skill is CI-system-agnostic in principle; the concrete examples lean GitHub Actions + the JS/bun/vitest stack where the ladder was first proven.