Thuta Learning
AdvancedDevOps & Toolsbeginner

Making Pipelines Fast

What you'll walk away with

  • Explain the core ideas behind Making Pipelines Fast
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

A slow pipeline does not merely waste time; it changes behaviour. Past roughly ten minutes people stop waiting for the result, batch several changes into one push, and start asking for merge rights that skip checks. The pipeline then protects nothing, because it has been routed around. Speed is a correctness property of your process, not a comfort.

Optimise by measuring first. Every run reports per-job and per-step timing; look before you guess, because intuition is usually wrong about where the minutes go. In most repositories the answer is dependency installation, an unincremental build, and one long test suite — and the remedy differs for each.

Caching addresses install and build. `actions/cache` and the built-in `cache:` option of the setup actions restore a directory keyed by a lockfile hash, so an unchanged dependency tree is downloaded once rather than on every run. Cache the right thing: the package manager store is safe, while copying `node_modules` wholesale is fragile across platform or runtime version changes. Key it on content, add `restore-keys` so a near miss still helps, and remember that a cache whose key changes every run saves nothing while still costing upload time.

Parallelism addresses tests. Jobs run concurrently unless `needs:` orders them, so lint, type-check and build have no reason to be sequential. A `strategy.matrix` shard splits one suite across several runners, turning its duration into roughly length divided by shard count plus fixed setup — which is exactly why adding shards eventually stops helping and starts wasting minutes.

Finally, not everything must run on every change. Path filters and conditional jobs keep an expensive end-to-end suite off documentation-only pull requests. The discipline is to scope by risk rather than to delete coverage: whatever you skip on a pull request should still run somewhere before that change reaches production.

text
SEQUENTIAL VERSUS PARALLEL PIPELINE
-----------------------------------
SEQUENTIAL (one job, each step after the last)
  |--install--|--build--|--------unit tests--------|---e2e---|
  start -------------------- wall clock -------------------> T

PARALLEL (fan out after a cached install)
  |--install (cache hit)--|
                          |--lint---------|
                          |--build--------|
                          |--unit shard 1------|
                          |--unit shard 2------|
                          |--unit shard 3------|
                          |--e2e (skipped on docs-only PRs)--|
  start ---> wall clock is now the SLOWEST branch, not the sum

LIMIT
  fixed setup (checkout + install) is paid by every parallel job,
  so beyond a certain shard count you buy minutes, not speed.

Connect it to a real scenario

In this workflow, three jobs start at once because nothing wires them with `needs:`. That is the cheapest win available, and the first thing to look for: most slow pipelines are slow because everything was written into a single job and therefore runs in sequence.

`cache: npm` on setup-node keys npm's download cache to the lockfile hash, so an unchanged dependency tree is not refetched from the network on every run. The separate `actions/cache` step handles the build cache directory, and `restore-keys` means that even when the lockfile does change you get the nearest previous cache instead of rebuilding everything from zero.

The unit job splits the suite across four matrix shards. `fail-fast: false` matters more than it looks: with the default, one failing shard cancels its siblings, so a run reports a single failure, you fix it, and then discover the next one on the following run — a slow serial hunt through problems you could have seen at once.

The e2e job checks changed paths first. Note the subtlety: rather than skipping the whole job, it runs and conditions its steps. A skipped job that is also a required status check can leave a pull request waiting forever, depending on how your branch protection is configured, so keeping the job present and cheap is usually the safer shape.

Try the working example

yaml
name: CI
on:
  pull_request:
    branches:
      - main
permissions:
  contents: read
jobs:
  lint:
    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 lint
  unit:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard:
          - 1
          - 2
          - 3
          - 4
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - uses: actions/cache@v4
        with:
          path: .cache/build
          key: build-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            build-${{ runner.os }}-
      - name: Run one quarter of the unit suite
        run: npm test -- --shard=${{ matrix.shard }}/4
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Decide whether end-to-end tests are needed
        id: filter
        run: ./scripts/paths-changed.sh src/ e2e/
      - uses: actions/setup-node@v4
        if: steps.filter.outputs.changed == 'true'
        with:
          node-version: '20'
          cache: npm
      - if: steps.filter.outputs.changed == 'true'
        run: npm ci
      - name: Run the expensive suite only when code changed
        if: steps.filter.outputs.changed == 'true'
        run: npm run test:e2e
You should see
Opening a pull request starts lint, unit and e2e at the same time, because no job declares `needs:`. The unit job fans out into four shards running concurrently on separate runners, and `fail-fast: false` means one failing shard does not cancel the others, so a single run surfaces every failure rather than only the first. When dependencies have not changed, the setup-node cache makes `npm ci` substantially cheaper, and the build cache can still be restored through `restore-keys` even when the exact key misses. The e2e job always starts, but if the path filter does not report `changed=true` its expensive steps are skipped and the job completes successfully — so a documentation-only pull request still satisfies the required check instead of waiting on a job that never ran.

5-minute try-it

Take the last ten runs of your real pipeline and tabulate per-job duration; pick only the longest job. Split it into two shards and measure the change in wall clock — not in total runner minutes, which will go up. Then try four shards, then eight, and find the point where the wall clock stops improving. That inflection tells you your per-job fixed setup cost, which is the number that decides how much parallelism is worth buying and where caching would help more than sharding.

One important caution

Putting a value that changes every run, such as `${{ github.sha }}`, into the cache key — the cache never hits and you pay upload time for nothing

Skipping an entire job with a path filter while that same job is a required status check, so the pull request waits on a result that will never arrive

GitHub Docs: Caching dependencies to speed up workflowsCI/CD with GitHub Actions

Easy traps

  • Putting a value that changes every run, such as `${{ github.sha }}`, into the cache key — the cache never hits and you pay upload time for nothing
  • Skipping an entire job with a path filter while that same job is a required status check, so the pull request waits on a result that will never arrive
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Take the last ten runs of your real pipeline and tabulate per-job duration; pick only the longest job. Split it into two shards and measure the change in wall clock — not in total runner minutes, which will go up. Then try four shards, then eight, and find the point where the wall clock stops improving. That inflection tells you your per-job fixed setup cost, which is the number that decides how much parallelism is worth buying and where caching would help more than sharding.

You'll know it worked when: Opening a pull request starts lint, unit and e2e at the same time, because no job declares `needs:`. The unit job fans out into four shards running concurrently on separate runners, and `fail-fast: false` means one failing shard does not cancel the others, so a single run surfaces every failure rather than only the first. When dependencies have not changed, the setup-node cache makes `npm ci` substantially cheaper, and the build cache can still be restored through `restore-keys` even when the exact key misses. The e2e job always starts, but if the path filter does not report `changed=true` its expensive steps are skipped and the job completes successfully — so a documentation-only pull request still satisfies the required check instead of waiting on a job that never ran.

Making Pipelines Fast | Thuta Learning