Split long GitHub Actions jobs into parallel work
A job's total duration hides which step is actually slow; pulling per-step timing from several runs' history separates a step that is reliably slow from one that is fast most of the time and occasionally stalls, and each has a different fix.
ci.parallel.long-running-jobsruntime · needs run historyAI agents open the PR
StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.
Do this
A job duration on the Actions UI is a single number, but a job is a sequence of steps and the slowdown is usually concentrated in one or two of them, not spread evenly. Two distinct problems hide behind the same symptom of 'this job takes too long'. The first is a setup step - checkout, dependency install, toolchain provisioning, cache restore - whose median duration stays high across runs, which is a caching or pinning gap. The second is a step whose duration swings widely from run to run, which is a contention or flakiness signal (a cold cache on some runners, a flaky external call, a shared resource under load) rather than a fixed cost, and the honest fix targets the tail, not the average. A third variant is the job's own Post steps - cache save, artifact upload, container teardown - which run after the visible work and are commonly left out of any mental model of 'why is this job slow' even though GitHub counts their time as part of the job. None of these three are visible from the workflow YAML; they only show up in the timing of actual runs, which is why finding them means pulling run history rather than reading a file.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
# Measured as a high-variance step in run history: e2e was fast most runs
# and occasionally stalled on a shared test database. Splitting it into its
# own job keeps the stall from blocking unit and integration results.
test:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
suite: [unit, integration]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:${{ matrix.suite }}
test-e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:e2e
# Post step for cache/save now scoped to only what this job needs,
# instead of the whole repo's .cache directory.
- uses: actions/cache/save@v4
with:
path: .cache/e2e
key: e2e-cache-${{ github.sha }}Avoid this
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run build
- run: npm run test:unit
- run: npm run test:integration
- run: npm run test:e2e
- uses: actions/cache/save@v4
with:
path: .cache
key: full-cache-${{ github.sha }}How to detect it
Pull the job list for recent runs of the workflow:
gh run list --workflow <workflow-file> --limit 20 --json databaseId,conclusionto get a sample of run ids to measure across.For each run, list its jobs and per-step timing:
gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs --paginatereturns each job'ssteps[]array withname,status, andstarted_at/completed_atfor every step, including steps whose name is prefixedPost(the cleanup phase).Compute each step's duration as
completed_at - started_atper run, then group by step name across the sampled runs to get median, mean, and standard deviation.Flag a setup step (checkout, install, cache-restore, toolchain setup) whose median duration stays above about 60 seconds - that is dead time before any real work starts.
Flag any step, setup or not, whose stddev-to-mean ratio is high (roughly above 0.5) with a mean above about 10 seconds - a step that is fast on most runs and slow on a tail is a contention or flakiness signal, not a fixed cost, and the P95/max samples are the runs to open and compare against a fast run step-by-step.
Separately, sum the duration of every step whose name starts with
Postper job and flag jobs where that total exceeds roughly 30 seconds - this time is real, it is included in the job's total duration on the Actions UI, and it is invisible unless you look at the steps array directly.Cross-reference the flagged step against the job's own critical-path standing: a slow step in a job other jobs are waiting on is worth splitting out; the same step in a job nothing depends on is a runner-minute cost, not a wall-clock one.
Tradeoffs and safety
Splitting a job into more jobs adds queue and setup overhead per job (checkout, toolchain install); on a repo with limited concurrent runners this can trade wall-clock time for queue time, so check runner availability before splitting a job that already runs alone.
A high-variance step is a symptom, not a diagnosis - do not add a matrix shard or a retry before opening the slow (P95/max) runs and comparing them step-by-step against a fast run; sharding a step that is slow because of a genuinely flaky external dependency just runs the same flake more times in parallel.
Moving a slow step to its own job changes what other jobs can depend on with
needs:- confirm nothing downstream expects an artifact or output that only existed because it ran inside the original job.Trimming a Post step's cache scope (case 2 above) changes what gets restored on the next run; narrowing
path:too aggressively can turn a slow cache save into a fast save that stops helping the next run's cache hit rate.
A slow step and a high-variance step are different bugs
A setup step with a consistently high median (checkout, install, cache-restore, toolchain provisioning) is dead time before real work starts, and the fix is a caching or pinning change: the warm-cache run is the floor you are trying to reach every time. A step whose duration swings from run to run is a different problem entirely - it is fast most of the time and slow on a tail, which points at something contending for a shared resource (network, an external service, a cache that misses on some runners) rather than a fixed cost to shave. The saving you can honestly claim is the tail excess: how much the slow runs inflate the average, realized only on the runs that hit it, never the whole standard deviation and never more than that step's own slice of the job's critical path.
Post steps are counted, and usually invisible
Every action that registers a cleanup routine - cache save, artifact upload, container or service teardown - runs it as a Post <name> step after the job's visible steps finish, and GitHub includes that time in the job's total duration. A job that looks like it 'just runs long' after every visible step already returned quickly is often paying for a large cache upload or a broad artifact upload in its Post phase. The jobs REST API returns Post steps in the same steps[] array as everything else, which is the only place to see this; the workflow YAML shows you the action was used, never how long its cleanup took on a given run.
Verify it worked
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR.
Find the slow or high-variance step inside this repository's longest-running GitHub Actions job, using run history, and split it out.
1. Pick the workflow and job that runs longest. List recent runs: `gh run list --workflow <workflow-file> --limit 20 --json databaseId`.
2. For each run id, pull per-step timing: `gh api
repos/{owner}/{repo}/actions/runs/<run_id>/jobs --paginate`. Extract each step's
`name`, `started_at`, and `completed_at`, including any step whose name starts with
`Post `.
3. Group step durations by step name across the sampled runs. Compute median and the
stddev-to-mean ratio for each step. Flag: a setup step
(checkout/install/cache-restore/toolchain) with median above about 60 seconds, any step
with stddev/mean above about 0.5 and mean above about 10 seconds, or Post steps summing
above about 30 seconds in a job.
4. For a flagged high-variance step, do not guess at a fix - open the slowest sampled
runs' logs (`gh run view <run_id> --log`) and compare a slow run against a fast run for
that same step to find the actual cause (cold cache, flaky call, contention).
5. Read the GitHub Actions REST API and jobs/matrix docs linked on this page before editing the workflow.
6. Propose the split that matches the finding: matrix-shard a step that does independent
repeatable work, move a slow step to its own job that other jobs do not block on, or
narrow what a slow Post step saves. Show the full diff and open a pull request rather than
applying changes blindly. In the PR body, name the step, the run ids you measured, and the
before/after median or P95 you expect to verify once the new workflow has run a few times.Confirm the change landed
After splitting, pull the same per-step job timing (
gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs --paginate) across a fresh sample of runs and confirm the previously flagged step's median or variance has actually dropped, not just moved to a different job name.For a fixed setup step, compare the step's median duration before and after across at least five runs; a caching or pinning fix should show a consistent, not one-off, improvement.
For a fixed high-variance step, compare the P95 and max duration before and after, not just the mean - the goal is a shorter tail, and a lower mean with an unchanged tail means the underlying contention or flakiness is still there.
Confirm the workflow's overall wall-clock time (from trigger to completion) improved only if the split step was actually on the critical path; a fix to a step in a job nothing else waits on saves runner-minutes, not wall-clock time, and both are worth reporting as what they are.
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand OPT49 / OPT50 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (these 2 plus 71 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
Sources
1GitHub Actions: workflow run jobs REST API (opens in new tab)
2GitHub Actions: using jobs in a workflow (opens in new tab)
3GitHub Actions: running variations of jobs in a matrix (opens in new tab)
Last updated 2026-08-21