DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 12 of 12
    Your progress · 0%
    On this page
    1. From one pipeline to fifty
    2. Reusable workflows: share a whole pipeline
    3. Composite actions: share steps
    4. Self-hosted runners
    5. Speed and cost
    6. Monorepo pipelines: build only what changed
    7. DORA metrics: is delivery getting better?
    ↖ Course roadmap
    Lesson 12 · Scaling pipelines

    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.

    35 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 7 complete

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

    A reusable workflow shares whole jobs. A composite action shares a few steps inside a job. Big setups use both.© Diagram: DevOps Academy

    A reusable workflow shares whole jobs. A composite action shares a few steps inside a job. Big setups use both.© Diagram: DevOps Academy
    Reusable workflowComposite action
    Shareswhole jobs, with their runners and permissionsa sequence of steps
    Defined byon: workflow_call in .github/workflows/runs: using: composite in action.yml
    Called asa job: jobs.x.uses:a step: steps[].uses:
    Secretspassed explicitly, or secrets: inheritonly 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'
    
    Composite run steps need a shell

    A workflow's run: steps default to bash; a composite action's don't, so each run: needs shell: bash. actionlint doesn't check this inside action.yml, so the mistake shows up only as a failed run. Composite actions also can't read secrets directly: pass what they need as inputs.

    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
    
    DoWhy
    Use them for private repositories onlya public repo's pull request runs a stranger's code on your machine
    Make them ephemeral: one job, then destroyeda persistent runner keeps what the last job left behind: files, credentials, processes
    Run them with Actions Runner Controller on Kubernetes, or autoscaling VMsfresh runners on demand, scaled to the queue
    Give the machine least accessit has whatever network and cloud rights you grant it
    Keep them patchedyou 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:

    CauseFix
    Dependencies installed from scratch each runcache them (lesson 3); npm ci with cache: npm
    Runs that don't need to happenpaths-ignore for docs; concurrency with cancel-in-progress on PRs
    Everything in one long jobsplit into parallel jobs; run slow jobs only after fast ones pass (needs)
    A slow test suiterun tests in parallel or shard them across a matrix
    Rebuilding images from zeroDocker layer caching (lesson 6)
    Hung jobstimeout-minutes on every job (the default is 6 hours)
    Artifacts kept for 90 daysretention-days you actually need
    Expensive runners for cheap workLinux 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...HEAD
    • gitversion 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 worked
    • echoprint 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:

    MetricQuestionMeasured from
    Deployment frequencyhow often do we deploy to production?deploy events
    Lead time for changeshow long from a commit to that commit running in production?commit time → deploy time
    Change failure ratewhat share of deploys cause a failure that needs fixing?deploys followed by a fix
    Time to restorewhen 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.

    Treat pipelines like a product

    A platform team that owns ci-templates gives every service the same tested, secure pipeline: pinned actions, read-only tokens, the standard checks. Services call it and add only what's special about them. Version it, write a changelog, and announce breaking changes, like any library.

    ⌘
    Mini mission

    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.

    Input: The shop-api repository from the earlier lessons, actionlintOutput: ci.yml calling node-ci.yml, with the same required checks passing
    Reveal 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-shop
    • mkdirmake a directory
    • -pcreate missing parents, no error if it exists
    actionlint && echo "workflows OK"
    • &&run the next command only if this one worked
    • echoprint text
    git switch -c ci/reusable && git add .github && git commit -m "ci: reusable workflow and composite action"
    • gitversion control
    • addstage changes
    • commitsave staged changes
    git push -u origin ci/reusable && gh pr create --fill && gh pr checks --watch
    • pushupload 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 7

    What is a reusable workflow?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: reusing workflowsGitHub Docs ↗
    • DocsGitHub Docs: creating a composite actionGitHub Docs ↗
    • DocsGitHub Docs: self-hosted runnersGitHub Docs ↗
    • ToolActions Runner Controller (ARC)GitHub ↗
    • DocsGitHub Docs: about billing for GitHub ActionsGitHub Docs ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousPipeline security: your CI is a production systemUp nextBack to the course roadmap