CI Speedup skill for Codex
Measure the critical path, then cut it. Codex starts by timing the slowest required checks and reading JUnit reports, then applies the fix for the real bottleneck: lockfile-keyed package caches, BuildKit layer caching, timing-balanced shards for Playwright, Vitest, Jest or pytest, and Nx or Turborepo affected builds. A bundled script finds slow tests and proposes shards, and every change is proven with before and after numbers and equal test counts.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ci-speedup Installs to ~/.agents/skills/ci-speedup/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ci-speedup The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ci-speedup Installs to .agents/skills/ci-speedup/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ci-speedup.zipunzip -o ci-speedup.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ci-speedup PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ci-speedup, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Cuts CI wall-clock time by measuring the critical path first, then applying the fix that matches the bottleneck - dependency and Docker layer caching, job parallelism, timing-based test sharding for pytest, Jest, Vitest and Playwright, affected-only builds with Nx, Turborepo or path filters, cancelling superseded runs, and bigger runners. Use when CI takes too long, PR feedback is slow, a test suite needs splitting across machines, caches miss, or a monorepo rebuilds everything on every change.
Use it when
- PR feedback takes longer than about 10 minutes
- One test job dominates the pipeline duration
- Dependency installs or Docker builds start cold every run
- A monorepo rebuilds and tests everything on each change
Use something else when
- CI is slow because tests are flaky and retried: use flaky-test-triage
- You are setting up CI for the first time: use github-actions-workflows
How to check the result
Required-check p50 drops on the same commit across repeated runs, the second run reports cache hits, and JUnit totals show the same number of tests executed.
The mistake it prevents
Sharding or filtering tests and silently running fewer of them, so the pipeline looks faster because it checks less.
Fast CI is measured, not guessed: you know the critical path of the slowest required run, each change targets the biggest segment on it, and you prove the gain with before and after numbers on the same commit. Target for PR feedback: required checks green in under 10 minutes at p50; under 5 is excellent.
Measure before changing anything
- Read
AGENTS.mdand the CI config to find the required checks (the ones that block merge). - Pull recent run durations (network,
ghauthenticated):
gh run list --workflow ci.yml --branch main --limit 20 --json databaseId,conclusion,createdAt,updatedAt
gh run view <run-id> --json jobs --jq '.jobs[] | [.name, .startedAt, .completedAt] | @tsv'
On GitLab, use the pipeline’s job durations in the UI or glab ci view.
3. Draw the critical path: queue wait, setup (checkout, toolchain, install), build, test,
upload. Write the numbers down; this is the baseline table for the final report.
4. Find the slowest tests from JUnit XML:
python3 scripts/junit_timings.py reports/ --top 15 --shards 4 (offline, standard library).
It prints the slowest tests and files and a timing-balanced split into N shards.
Pick the fix for the bottleneck
| Bottleneck on the critical path | First fix |
|---|---|
| Queue wait before jobs start | More or bigger runners, fewer jobs per run, cancel superseded runs |
Dependency install (npm ci, pip install) | Cache the package manager’s download cache keyed on the lockfile |
| Docker build | BuildKit layer cache (cache-from/cache-to), reorder Dockerfile so deps install before source copy |
| Single long test job | Shard by timing across N parallel jobs |
| Every job rebuilds the same thing | Build once, pass artifacts, or use a remote build cache |
| Monorepo runs everything | Affected-only: Nx, Turborepo, Bazel, or path filters |
| Many short jobs each paying setup cost | Merge them; setup can exceed the work |
| Slow single tests | Fix the test (sleeps, real network, per-test DB setup) |
Change one thing at a time and re-measure; caches and sharding interact.
Caching
- Cache the package manager store, not
node_modules: restoringnode_modulesbreaks on native modules and different OS images.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # keyed on package-lock.json automatically
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- Docker with Buildx on GitHub Actions:
- uses: docker/build-push-action@v6
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
- Key caches on lockfiles plus OS and tool version; include a restore fallback prefix so a lockfile change still gets a warm partial cache.
- GitHub Actions cache is 10 GB per repo by default with LRU eviction; a dozen large caches
evict each other and hit rate collapses. Check usage with
gh cache list --sort size_in_bytes. - A cache restore slower than the install it replaces is a loss. Measure both.
Parallelism and test sharding
Shard by recorded timing, not by file count; file-count splits leave one shard running 3x longer than the others.
| Runner | Built-in sharding |
|---|---|
| Playwright | npx playwright test --shard=2/4, merge with npx playwright merge-reports |
| Vitest | vitest run --shard=2/4 |
| Jest | jest --shard=2/4 (Jest 28+) |
| pytest | pytest-xdist (-n auto) within a machine; pytest-split with stored durations across machines |
| Go | go test parallelizes packages; split packages across jobs with go list ./... |
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx vitest run --shard=${{ matrix.shard }}/4 --reporter=junit --outputFile=junit-${{ matrix.shard }}.xml
- Within one machine, use all cores first (
-n auto, Jest workers) before adding machines; each extra machine pays the full setup cost. - Stop adding shards when the setup time is more than about a third of the shard duration.
- Tests that share a database or port fail when parallelized; give each worker its own
schema or port via the worker index (
PYTEST_XDIST_WORKER,VITEST_POOL_ID). - For a static split from timings, use the script’s
--shards N --jsonoutput to write a file list per shard and commit it; refresh it monthly.
Affected-only work
| Repo shape | Tool |
|---|---|
| JS/TS monorepo, Nx | npx nx affected -t lint test build --base=origin/main --head=HEAD |
| JS/TS monorepo, Turborepo | npx turbo run test --filter="...[origin/main]" plus remote cache |
| Polyglot, large | Bazel or Pants with remote cache |
| Few independent folders | Path filters, with an always-running aggregator job for the required check |
- Affected detection needs history:
actions/checkoutwithfetch-depth: 0, or at least the merge base. - On
main, run everything (or rely on a remote cache) so a missed dependency edge is caught before release. - Required checks plus path filters strand PRs (the check never reports). Put the filter
inside the job, or add a final job with
if: always()that every PR runs and that fails when any needed job failed.
Cheap wins
- Cancel superseded PR runs:
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }(never onmainor deploy workflows). - Shallow checkout (
fetch-depth: 1) when history is not needed. - Skip CI for docs-only changes with filters that still satisfy required checks.
- Run lint and type-check in parallel with tests, not as a prerequisite stage.
- Bigger runners: a 4-core runner often halves test time for less than double the cost; check the user’s billing plan before switching.
timeout-minuteson every job so a hung test cannot hold a runner for 6 hours.
Verification
- Re-run the same commit before and after (or compare 10 runs each) and report p50 and worst-case duration of the required checks.
- Confirm the same number of tests executed: sharding and affected-only filters can drop tests silently. Compare test counts in JUnit totals.
- Confirm cache hit on the second run (
cache-hitoutput or log line). - If a change does not save at least 10% on the critical path, revert it; complexity is a cost.
Output template
## CI speedup: <repo>
| Segment | Before | After | Change |
|---|---|---|---|
| Install | 2m40s | 35s | npm cache on lockfile |
| Test | 11m10s | 3m20s | 4 timing-balanced shards |
| Required checks p50 | 16m | 6m | |
Tests executed: 2,431 before / 2,431 after
Cost impact: <runner minutes per run before/after>
Follow-ups: <slow tests to fix, remote cache to evaluate>
Gotchas
- Caching build outputs keyed only on the branch serves stale artifacts; key on source hashes.
- Parallel shards each upload coverage; merge reports or coverage thresholds fail per shard.
fail-fast: true(the matrix default) cancels sibling shards and hides how many failed.- Retrying flaky tests makes CI look faster on average and slower at p95; fix flakes
separately (see
flaky-test-triage).
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CI Speedup"
short_description: "Measure the critical path, then cut it"
default_prompt: "$ci-speedup Our CI takes 25 minutes per PR, find where the time goes and cut it" scripts/junit_timings.py
#!/usr/bin/env python3
"""Summarise JUnit XML timings and propose timing-balanced test shards.
Usage:
python3 junit_timings.py <file-or-dir> [--top 15] [--shards 4] [--json]
Reads every *.xml under the path, aggregates testcase time per test and per file
(uses the 'file' attribute, else the classname), prints the slowest entries and a
greedy longest-first split of files into N shards. Exits 1 if no testcases found.
"""
import argparse
import json
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
def load(paths):
tests = []
for p in paths:
try:
root = ET.parse(p).getroot()
except ET.ParseError as exc:
print(f"warn: skipping {p}: {exc}", file=sys.stderr)
continue
for tc in root.iter("testcase"):
try:
t = float(tc.get("time") or 0)
except ValueError:
t = 0.0
group = tc.get("file") or tc.get("classname") or "(unknown)"
tests.append((f"{group}::{tc.get('name', '?')}", group, t))
return tests
def split(files, n):
shards = [[0.0, []] for _ in range(n)]
for name, secs in sorted(files.items(), key=lambda kv: -kv[1]):
target = min(shards, key=lambda s: s[0])
target[0] += secs
target[1].append(name)
return shards
def main():
ap = argparse.ArgumentParser()
ap.add_argument("path")
ap.add_argument("--top", type=int, default=15)
ap.add_argument("--shards", type=int, default=0)
ap.add_argument("--json", action="store_true", help="print shard file lists as JSON")
a = ap.parse_args()
root = Path(a.path)
paths = [root] if root.is_file() else sorted(root.rglob("*.xml"))
tests = load(paths)
if not tests:
print(f"error: no testcases found under {root}", file=sys.stderr)
return 1
per_file = defaultdict(float)
for _, group, t in tests:
per_file[group] += t
total = sum(t for *_, t in tests)
if a.json and a.shards:
out = [{"shard": i + 1, "seconds": round(s[0], 2), "files": s[1]}
for i, s in enumerate(split(per_file, a.shards))]
print(json.dumps(out, indent=2))
return 0
print(f"{len(tests)} tests in {len(per_file)} files, {total:.1f}s total ({len(paths)} report files)\n")
print(f"Slowest {a.top} tests:")
for name, _, t in sorted(tests, key=lambda x: -x[2])[: a.top]:
print(f" {t:8.2f}s {name}")
print(f"\nSlowest {a.top} files:")
for name, t in sorted(per_file.items(), key=lambda kv: -kv[1])[: a.top]:
print(f" {t:8.2f}s {100 * t / total if total else 0:5.1f}% {name}")
if a.shards:
shards = split(per_file, a.shards)
longest = max(s[0] for s in shards)
print(f"\nBalanced split into {a.shards} shards (critical path {longest:.1f}s vs {total:.1f}s serial):")
for i, (secs, names) in enumerate(shards, 1):
print(f" shard {i}: {secs:7.1f}s {len(names)} files")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.