---
title: "Shorten the GitHub Actions Critical Path | StarSling"
description: "Measure the GitHub Actions job graph's critical path from run history, find the long pole, and apply the fix that matches its shape."
url: https://starsling.dev/github-actions/optimizations/reduce-workflow-critical-path
canonicalUrl: https://starsling.dev/github-actions/optimizations/reduce-workflow-critical-path
---

# Find and shorten the GitHub Actions critical path

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Find and shorten the GitHub Actions critical path

- [How StarSling works](https://starsling.dev/)

Rule: ci.parallel.critical-path. Detection mode: runtime. Last updated: 2026-08-21

Total wall-clock for a GitHub Actions run is set by its critical path - the longest chain of jobs connected by `needs:` - not by the workflow's total job-minutes; shortening a run means finding that chain's slowest job (the long pole) and fixing the specific thing making it slow.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Compute the critical path from job timings, not job-minutes](#computing-the-critical-path)
- [Classify the long pole's shape before picking a fix](#classify-before-fixing)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

A workflow can have twenty jobs and still take as long as its single slowest dependency chain, because parallel jobs off that chain finish for free while the run waits on the pole. Adding more parallel jobs, caching a job nobody is waiting on, or shaving seconds off a fast job does nothing to total wall-clock if none of it sits on the critical path. The long pole is also not one shape: sometimes it is dominated by setup work that dwarfs the actual test or scan, sometimes the same expensive step is duplicated across every job in the slow cluster so fixing it once lowers all of them, and sometimes it really is one addressable step - a build, a test suite, a scan - that just needs the matching lever. Reading workflow YAML cannot tell you which of these you have; only measured run history can, because the critical path is a property of how long jobs actually ran against each other, not of how the jobs are declared.

_.github/workflows/ci.yml_

```yaml
name: CI
on: pull_request

jobs:
  # install/build/lint/test each run standalone (no needs: chain forcing
  # them to wait on each other), and each pays the SAME install + build
  # step - so that shared step is cached identically in every job instead
  # of being serialized through one upstream job.
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build --filter=...[HEAD^1] --cache-dir=.turbo
      - run: pnpm lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build --filter=...[HEAD^1] --cache-dir=.turbo
      - run: pnpm test
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
name: CI
on: pull_request

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile

  build:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build

  lint:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build
      - run: pnpm lint

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build
      - run: pnpm test
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Pull recent run history for the workflow: `gh api repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs --jq '.workflow_runs[] | {id, status, conclusion}'` (or `GET /repos/{owner}/{repo}/actions/runs?event=push` for runs across all workflows). Take several runs on the default branch so one slow outlier does not skew the picture.
2. For each run, pull per-job timing: `gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs --jq '.jobs[] | {name, started_at, completed_at, steps}'` (or `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs`). This gives each job's wall-clock start and end and, inside `steps`, each step's own `started_at`/`completed_at`.
3. Reconstruct the job graph's `needs:` edges from the workflow YAML, then walk the graph using the measured job start/end times: at each job, its finish time is its own duration plus the latest finish time among the jobs it needs. The chain that produces the run's overall finish time is the critical path; the single slowest job on that chain is the long pole.
4. Decompose the long pole into its step timings from the same jobs payload. Classify each step (checkout, install, build, test, scan, package, setup) and compute each category's share of the job's total duration.
5. Compute `(checkout + install + build + setup step time) / (test + scan + package step time)` for the long pole. A ratio over roughly 2x, where the larger side is itself a build or setup step, means the pole is spending most of its time getting ready to work rather than doing the work (OPT72's shape).
6. Separately, look at every job within striking distance of the long pole (the run's slowest cluster, not just the single pole) and normalize their step names (strip matrix arguments, lowercase, group by category). If the same step category - `pnpm install`, `setup toolchain`, `build base image` - appears with material duration in two or more of those jobs, fixing it once lowers the floor for the whole cluster (OPT73's shape), which is worth doing even if it does not move the pole by itself.
7. If neither applies, the pole's time is concentrated in one dominant step category with no cluster-wide duplication and no setup/build skew - that step itself is the addressable target (OPT75's shape), and which lever fits depends on which category dominates: install/checkout/setup routes to caching or a shallow fetch, build routes to a warm build cache or narrower scope, test routes to sharding, and scan/package routes to caching the scan database or moving the check advisory-async if it is not required to merge.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- Removing a `needs:` edge to run jobs in parallel only helps if the jobs do not actually depend on each other's output; a lint or test job that reads a build artifact the build job produces still needs it, and dropping the edge without also fetching that artifact breaks the job rather than speeding it up.
- The long pole moves. Fixing today's slowest chain can promote a different job to long pole next run, especially with a shared runner pool where queue time varies; re-measure after the fix instead of assuming one pass is final.
- A ratio-based diagnosis (setup-heavy vs. shared-step vs. single-step) is a starting hypothesis from a handful of runs, not a certainty; confirm the same shape holds across several runs before restructuring jobs, since one slow run can be a runner-contention outlier rather than the workflow's real profile.
- Caching each job's setup independently (the good example) trades a small amount of duplicated cache-restore cost in every job for removing a serial gate; on a workflow with many downstream jobs, restoring the same cache in each of them is still cheaper than one upstream job blocking all of them, but it is not free.

<a id="computing-the-critical-path"></a>

## Compute the critical path from job timings, not job-minutes

A run's total wall-clock is not the sum of its jobs' durations - independent jobs run at the same time. It is the longest path through the `needs:` graph, where each job's earliest finish is its own duration plus the latest finish among the jobs it depends on. Two runs can spend the same total job-minutes and finish at very different wall-clock times depending on how that time is distributed across the graph, which is why the fix has to target the chain that actually gates the run, not the job with the biggest number next to it.

_critical-path.py_

```bash
# Given jobs.json from GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs
# and the needs: edges parsed from the workflow YAML, walk the graph:
#
# finish[job] = duration[job] + max(finish[dep] for dep in needs[job], default=0)
#
# The job with the largest finish[] value sits on the critical path; walk
# its needs: chain backward to list the full path. That job is the long pole.
gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs \
  --jq '.jobs[] | {name, started_at, completed_at, needs: .name}'
```

<a id="classify-before-fixing"></a>

## Classify the long pole's shape before picking a fix

The three shapes point at different fixes, and applying the wrong one wastes the change: a setup-dominant pole wants a cache or a narrower build scope, not sharding; a step duplicated across the cluster wants that step made cheap everywhere it runs, not consolidated into one upstream gate (which trades wall-clock for job-minutes and can make the run slower); and a single dominant step with no duplication wants the lever matched to its own category. Measuring which shape you have is the point of pulling run history at all - the workflow file alone cannot distinguish them.

<a id="verify"></a>

## 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:

```
Measure this repository's GitHub Actions critical path from real run history and fix the long pole. Do not guess the bottleneck from the workflow YAML alone - the finding requires measured run data.

1. Read the upstream docs this page links (workflow runs API, workflow jobs API, needs:) before writing any code.
2. Pick a workflow and pull its recent runs on the default branch: `gh api
   repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs --jq '.workflow_runs[] | {id,
   conclusion}'`. Use at least 3-5 runs, not one.
3. For each run, pull per-job timing: `gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs`. Record each job's started_at/completed_at and its steps' started_at/completed_at.
4. Parse the workflow YAML's needs: edges and reconstruct the job graph. Using the
   measured start/end times, compute each job's earliest possible finish as its own duration
   plus the latest finish among the jobs it needs. Identify the critical path (the chain
   producing the run's overall finish time) and the long pole (the slowest job on it).
5. Decompose the long pole's steps by category
   (checkout/install/build/test/scan/package/setup) and compute the setup-or-build vs.
   payload time ratio. Separately check whether any step category recurs with material
   duration across the other jobs in the slow cluster.
6. Classify the finding: setup-dominant (fix: scope the build to what the payload needs,
   or warm-cache the setup so the redundant work is a restore instead of a rebuild),
   shared-step-across-the-cluster (fix: make that step cheap in every job that runs it - a
   cache all of them hit, a prebuilt image they all pull - rather than moving it into one
   upstream job those jobs needs:, which trades wall-clock for job-minutes), or
   single-addressable-step (fix: match the lever to the dominant category -
   cache/shallow-fetch for install-checkout-setup, warm cache or narrower scope for build,
   sharding for test, caching or moving advisory-async for scan/package).
7. Apply the smallest change that fixes the classified shape. Do not restructure needs: edges between jobs that genuinely depend on each other's output.
8. Re-pull run history after the change on several new runs, recompute the critical path the same way, and confirm total wall-clock on that chain dropped.
9. Show the full diff and open a pull request rather than applying changes blindly. In the
   PR body, name the long pole, the classification (setup-heavy / shared-step / single-step),
   the before/after critical-path duration across the runs you measured, and how you
   re-verified it.
```

Confirm the change landed:

1. Re-pull run history after the change with the same `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` call, reconstruct the critical path the same way, and confirm the previously-slowest chain's total finish time has actually dropped - not just that the individual job you edited got faster.
2. Confirm the long pole itself changed shape: if the fix targeted a setup-heavy ratio, recompute the setup/payload ratio on the new run and check it dropped; if the fix targeted a shared step, confirm that step's duration fell in every cluster job that had it, not only the one you edited.
3. Compare several runs before and after, not one; a single before/after pair does not separate the fix's effect from ordinary run-to-run variance in queue time and runner performance.

<a id="related-pages"></a>

## Related pages

- [Run independent GitHub Actions jobs in parallel](https://starsling.dev/github-actions/optimizations/parallelize-independent-jobs)
- [Split long GitHub Actions jobs into parallel work](https://starsling.dev/github-actions/optimizations/split-long-running-jobs)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [Keep non-required checks off the GitHub Actions critical path](https://starsling.dev/best-practices/github-actions/keep-advisory-checks-non-blocking)

<a id="sources"></a>

## Sources

- [GitHub REST API: workflow runs](https://docs.github.com/en/rest/actions/workflow-runs)
- [GitHub REST API: workflow jobs](https://docs.github.com/en/rest/actions/workflow-jobs)
- [GitHub Actions: using jobs in a workflow (needs)](https://docs.github.com/en/actions/using-jobs/using-jobs-in-a-workflow)
- [GitHub CLI: gh run view](https://cli.github.com/manual/gh_run_view)
