Scaling pipelines: reuse, your own runners, speed and cost
When one pipeline becomes fifty: reusable workflows and composite actions, self-hosted runners run safely, and faster, cheaper pipelines through measurement, not guesses.
By the end of this lesson you'll be able to
0 of 7 completeFrom one pipeline to fifty
One repository with one ci.yml is easy. Fifty services with fifty copies of it isn't: a fix to one has
to be made fifty times, and they drift apart. Two tools share pipeline code, at two sizes:
| Reusable workflow | Composite action | |
|---|---|---|
| Shares | whole jobs, with their runners and permissions | a sequence of steps |
| Defined by | on: workflow_call in .github/workflows/ | runs: using: composite in action.yml |
| Called as | a job: jobs.x.uses: | a step: steps[].uses: |
| Secrets | passed explicitly, or secrets: inherit | only through inputs |
| Use for | "the standard Node pipeline" | "set up Node and install deps" |
Reusable workflows: share a whole pipeline
A reusable workflow is a normal workflow whose trigger is workflow_call, with typed inputs and outputs:
# .github/workflows/node-ci.yml name: Node CI (reusable) on: workflow_call: inputs: node-version: type: string default: '22' run-integration: type: boolean default: true outputs: coverage-ok: description: Whether the coverage threshold passed value: ${{ jobs.unit.outputs.coverage-ok }} permissions: contents: read jobs: lint: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-shop with: node-version: ${{ inputs.node-version }} - run: npm run lint unit: runs-on: ubuntu-latest timeout-minutes: 10 outputs: coverage-ok: ${{ steps.cov.outcome == 'success' }} steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-shop with: node-version: ${{ inputs.node-version }} - id: cov run: npm run test:coverage integration: if: inputs.run-integration needs: [lint, unit] runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-shop with: node-version: ${{ inputs.node-version }} - run: npm run test:integration
The caller shrinks to a few lines:
# .github/workflows/ci.yml jobs: node: uses: ./.github/workflows/node-ci.yml # same repository with: node-version: '22' # from a shared repository, pinned to a tag or SHA, like any action: # node: # uses: your-org/ci-templates/.github/workflows/node-ci.yml@v1 # with: { run-integration: false } # secrets: inherit
The checks appear as node / lint, node / unit, node / integration; use those names in required
status checks. Keep shared workflows in one ci-templates repository, version them with tags (the Git
course's Releases lesson), and callers upgrade deliberately. For a private shared repository, allow access
under its Settings → Actions → General → Access.
Composite actions: share steps
When several jobs start with the same three steps, make them one:
# .github/actions/setup-shop/action.yml name: Set up shop-api description: Install Node and the npm dependencies, with caching. inputs: node-version: description: Node.js version to install default: '22' runs: using: composite steps: - uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} cache: npm - run: npm ci shell: bash
steps: - uses: actions/checkout@v7 # local actions need the code checked out first - uses: ./.github/actions/setup-shop with: node-version: '22'
Self-hosted runners
GitHub-hosted runners suit most work. Run jobs on your own machines when you need something they can't give: hardware (GPUs, ARM, lots of memory), access inside a private network (a database behind a firewall), or very high volume where your own machines are cheaper.
runs-on: [self-hosted, linux, x64, gpu] # labels you gave your runner
| Do | Why |
|---|---|
| Use them for private repositories only | a public repo's pull request runs a stranger's code on your machine |
| Make them ephemeral: one job, then destroyed | a persistent runner keeps what the last job left behind: files, credentials, processes |
| Run them with Actions Runner Controller on Kubernetes, or autoscaling VMs | fresh runners on demand, scaled to the queue |
| Give the machine least access | it has whatever network and cloud rights you grant it |
| Keep them patched | you now own the OS, the tools and their CVEs |
The setup itself is short (Settings → Actions → Runners → New self-hosted runner prints the commands); the work is running them safely afterwards.
Speed and cost
A slow pipeline is paid twice: in runner minutes, and in developers waiting. Measure first: the run summary shows each job's duration, and expanding a job shows each step's. Then fix the biggest cause:
| Cause | Fix |
|---|---|
| Dependencies installed from scratch each run | cache them (lesson 3); npm ci with cache: npm |
| Runs that don't need to happen | paths-ignore for docs; concurrency with cancel-in-progress on PRs |
| Everything in one long job | split into parallel jobs; run slow jobs only after fast ones pass (needs) |
| A slow test suite | run tests in parallel or shard them across a matrix |
| Rebuilding images from zero | Docker layer caching (lesson 6) |
| Hung jobs | timeout-minutes on every job (the default is 6 hours) |
| Artifacts kept for 90 days | retention-days you actually need |
| Expensive runners for cheap work | Linux for anything that doesn't need Windows or macOS |
Add one change at a time and compare durations over a few runs: runner speed varies from run to run, so one fast run proves nothing.
Monorepo pipelines: build only what changed
A monorepo keeps many services in one repository: one pull request can change the API and the shared library it uses, and everything is versioned together. The cost lands on CI. If every pull request builds and tests every service, a one-line fix in one service waits for all of them.
The fix is to ask git what changed and build only the services it affects. A small repository with two services and a shared library:
services/api/ services/web/ libs/shared/ docs/
#!/usr/bin/env bash # affected.sh: print the services a change touches. A change in libs/shared touches every service. base=${1:-main} changed=$(git diff --name-only "$base"...HEAD) if grep -q '^libs/shared/' <<<"$changed"; then ls services; exit; fi grep -o '^services/[^/]*' <<<"$changed" | cut -d/ -f2 | sort -u
$ git diff --name-only main...HEAD # branch fix-api services/api/index.js $ ./affected.sh api $ git diff --name-only main...HEAD # branch shared-change libs/shared/index.js $ ./affected.sh api web $ ./affected.sh # branch docs-only: nothing to build $
git diff --name-only main...HEADgitversion control
The three dots matter: main...HEAD compares against the point where the branch left main, so commits that
landed on main since then don't count as your changes.
In GitHub Actions, one small job turns that list into JSON, and a matrix fans out over it:
jobs: changes: runs-on: ubuntu-latest outputs: services: ${{ steps.affected.outputs.services }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # the full history, so the diff has a merge base - id: affected env: BASE: origin/${{ github.base_ref }} run: echo "services=$(./affected.sh "$BASE" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')" >> "$GITHUB_OUTPUT" test: needs: changes if: needs.changes.outputs.services != '[]' runs-on: ubuntu-latest strategy: matrix: service: ${{ fromJSON(needs.changes.outputs.services) }} steps: - uses: actions/checkout@v7 - run: echo "testing services/${{ matrix.service }}"
$ ./affected.sh | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))' ["api", "web"] $ actionlint && echo clean clean
./affected.sh | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))'|pipe: send output to the next command
actionlint && echo clean&&run the next command only if this one workedechoprint text
This is also the answer to the path-filter trap from lesson 3: the changes job always runs, so a required
check always reports, and the decision happens inside the pipeline. Once dependencies between packages get
complicated, build tools that know the dependency graph do the same job better: Nx and Turborepo for
JavaScript, Bazel or Pants across languages, with remote caching so a package that didn't change is never
rebuilt anywhere.
DORA metrics: is delivery getting better?
"The pipeline is faster" is a feeling. The DORA research program found four numbers that separate teams that deliver well from teams that don't, two for speed and two for stability:
| Metric | Question | Measured from |
|---|---|---|
| Deployment frequency | how often do we deploy to production? | deploy events |
| Lead time for changes | how long from a commit to that commit running in production? | commit time → deploy time |
| Change failure rate | what share of deploys cause a failure that needs fixing? | deploys followed by a fix |
| Time to restore | when a deploy breaks things, how fast is it fixed? | failed deploy → fix deployed |
The point of measuring all four together: speed bought by skipping tests shows up straight away as a worse failure rate.
You don't need a product to start. If every production deploy creates a tag (deploy-001, deploy-002, …)
and fixes start with hotfix:, git already holds the data. A short script over two weeks of a test repository:
# dora.py: deploy-* tags are deploys; a "hotfix:" commit marks the deploy before it as failed. import statistics, subprocess from datetime import datetime def git(*args): return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.split("\n") tags = [l.split() for l in git("for-each-ref", "--sort=creatordate", "--format=%(refname:short) %(creatordate:iso-strict)", "refs/tags/deploy-*") if l] deploys = [(name, datetime.fromisoformat(when)) for name, when in tags] lead_hours, failed, restore_hours, prev = [], 0, [], None for i, (name, at) in enumerate(deploys): span = f"{prev}..{name}" if prev else name for line in filter(None, git("log", "--format=%cI %s", span)): when, subject = line.split(" ", 1) lead_hours.append((at - datetime.fromisoformat(when)).total_seconds() / 3600) if subject.startswith("hotfix:"): failed += 1 restore_hours.append((at - deploys[i - 1][1]).total_seconds() / 3600) prev = name days = (deploys[-1][1] - deploys[0][1]).days + 1 print(f"deploys: {len(deploys)} in {days} days ({len(deploys) / days * 7:.1f} a week)") print(f"lead time for changes: median {statistics.median(lead_hours):.1f} h") print(f"change failure rate: {failed}/{len(deploys) - failed} = {failed / (len(deploys) - failed):.0%}") print(f"time to restore: median {statistics.median(restore_hours):.1f} h")
$ python3 dora.py deploys: 9 in 11 days (5.7 a week) lead time for changes: median 3.0 h change failure rate: 2/7 = 29% time to restore: median 1.4 h
Two of the seven feature deploys needed a hotfix, and both were fixed within two hours. That's the trade-off the metrics show: deploying small and often makes each fix quick. Look at the trend month to month, not one number, and never set the metrics as individual targets: people learn to game deploy counts quickly. In real teams the deploy events come from the pipeline or Argo CD, and failures from incidents. GitHub, GitLab and tools like Sleuth or Faros collect them for you.
Turn shop-api's pipeline into a template
Refactor shop-api's CI: move the repeated setup steps into a composite action, move lint, unit and integration into a reusable workflow with inputs and an output, and call it from a ten-line ci.yml. Prove it behaves exactly as before.
ci.yml calling node-ci.yml, with the same required checks passingReveal one safe solution
$ mkdir -p .github/actions/setup-shop $ nano .github/actions/setup-shop/action.yml # the composite action from this lesson $ nano .github/workflows/node-ci.yml # the reusable workflow $ nano .github/workflows/ci.yml # on: pull_request/push, one job: uses node-ci.yml $ actionlint && echo "workflows OK" workflows OK $ git switch -c ci/reusable && git add .github && git commit -m "ci: reusable workflow and composite action" $ git push -u origin ci/reusable && gh pr create --fill && gh pr checks --watch
mkdir -p .github/actions/setup-shopmkdirmake a directory-pcreate missing parents, no error if it exists
actionlint && echo "workflows OK"&&run the next command only if this one workedechoprint text
git switch -c ci/reusable && git add .github && git commit -m "ci: reusable workflow and composite action"gitversion controladdstage changescommitsave staged changes
git push -u origin ci/reusable && gh pr create --fill && gh pr checks --watchpushupload commits
The pull request shows node / lint, node / unit and node / integration. Update the ruleset's required
checks to those new names before merging, or the old names wait forever as Expected. The setup steps now
live in one file: change the Node version there and every job follows.
Try it yourself
0 of 7 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
What is a reusable workflow?
Official reading for this lesson
Or get every quiz answer right and it completes itself.