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 3 of 12
    Your progress · 0%
    On this page
    1. Expressions and contexts
    2. Matrix builds
    3. Caching dependencies
    4. Artifacts: passing files between jobs
    5. Concurrency: one run per branch
    6. Path filters: run only when it matters
    7. Service containers: a real database for tests
    ↖ Course roadmap
    Lesson 3 · Workflow details

    Workflow details: expressions, matrices, caches and artifacts

    Smarter, faster workflows: expressions and contexts, passing values between steps and jobs, a matrix of versions, dependency caching, and artifacts that carry the build between jobs.

    40 min read · intermediate · hands-on

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

    0 of 7 complete

    Expressions and contexts

    Anything inside ${{ … }} is an expression, evaluated by GitHub before the step runs. Expressions read contexts, objects describing the run:

    ContextHoldsExample
    githubthe event and repositorygithub.ref_name, github.sha, github.actor, github.event_name
    envenvironment variables you set in the workflowenv.NODE_ENV
    matrixthis job's matrix valuesmatrix.node
    stepsoutputs and results of earlier steps (by id)steps.version.outputs.version
    needsoutputs and results of jobs this one needsneeds.build.outputs.version, needs.build.result
    secrets, varssecrets and configuration variablessecrets.DEPLOY_KEY (next lesson)
    runnerthe machinerunner.os, runner.temp

    Expressions also have functions: contains(), startsWith(), format(), toJSON(), hashFiles(), and the status checks below.

    Passing values: steps and jobs

    A step writes name=value to the file in $GITHUB_OUTPUT; later steps read it through the step's id. A job publishes step outputs as job outputs for the jobs that need it:

    build:
      runs-on: ubuntu-latest
      outputs:
        version: ${{ steps.version.outputs.version }}     # 3. publish for other jobs
      steps:
        - id: version                                     # 1. give the step an id
          run: echo "version=1.0.${{ github.run_number }}-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
        - run: echo "Building ${{ steps.version.outputs.version }}"   # 2. read it in a later step
    
    report:
      needs: build
      runs-on: ubuntu-latest
      steps:
        - run: echo "version ${{ needs.build.outputs.version }}"      # 4. read it in another job
    
    $ echo "version=1.0.42-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"   # what step 1 does, run locally
    $ cat "$GITHUB_OUTPUT"
    version=1.0.42-e3b0c44
    
    echo "version=1.0.42-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
    • echoprint text
    • >>append output to a file
    cat "$GITHUB_OUTPUT"
    • catprint a file
    FileEffect
    $GITHUB_OUTPUTa step output, read as steps.<id>.outputs.<name>
    $GITHUB_ENVan environment variable for all later steps in the job
    $GITHUB_PATHa folder added to PATH for later steps
    $GITHUB_STEP_SUMMARYMarkdown shown on the run's summary page

    export VAR=x doesn't survive the step: each run: is its own shell.

    Conditions: if

    - run: ./deploy.sh
      if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    - run: ./notify-team.sh "build failed"
      if: failure()                       # only when an earlier step failed
    - run: ./upload-logs.sh
      if: always()                        # whatever happened
    
    Status functionTrue when
    success()everything before succeeded (the default)
    failure()something before failed
    always()always, even when cancelled
    cancelled()the run was cancelled

    Inside if: the ${{ }} is optional. Use !cancelled() rather than always() for cleanup that shouldn't run when someone cancels a deploy on purpose.

    Matrix builds

    A matrix runs the same job once per combination of values, in parallel. One block tests every Node version you support:

    Matrix: one job definition, many parallel jobs. Cache: faster installs. Artifact: this run's output, handed to the next job.© Animation: DevOps Academy

    Matrix: one job definition, many parallel jobs. Cache: faster installs. Artifact: this run's output, handed to the next job.© Animation: DevOps Academy
    test:
      name: test (node ${{ matrix.node }}, ${{ matrix.os }})
      runs-on: ${{ matrix.os }}
      strategy:
        fail-fast: false
        matrix:
          os: [ubuntu-latest]
          node: [20, 22, 24]
          include:
            - os: windows-latest     # one extra combination
              node: 22
      steps:
        - uses: actions/checkout@v7
        - uses: actions/setup-node@v7
          with:
            node-version: ${{ matrix.node }}
            cache: npm
        - run: npm ci
        - run: npm test
    

    That's four jobs: Node 20, 22 and 24 on Ubuntu, plus Node 22 on Windows. exclude: removes combinations; max-parallel: limits how many run at once.

    fail-fast defaults to true: the first failure cancels the rest. For tests, set it to false so every version reports, and you can see "only Node 20 fails" at a glance. Required status checks name each matrix job, so a clear name: keeps the branch protection settings readable.

    Caching dependencies

    Installing dependencies on every run is the slowest boring part of most pipelines. A cache saves a folder after one run and restores it in the next. For the common package managers, the setup actions do it for you:

    - uses: actions/setup-node@v7
      with:
        node-version: 22
        cache: npm            # caches npm's download folder, keyed on package-lock.json
    - run: npm ci             # installs exactly the lockfile, using the cache
    

    For anything else, use actions/cache with a key you build:

    - uses: actions/cache@v6
      with:
        path: ~/.cache/pip
        key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}
        restore-keys: |
          pip-${{ runner.os }}-
    
    PartDoes
    keyexact match: the lockfile is unchanged, so the cache is exactly right
    restore-keysfallback prefix: restore the newest similar cache, then install only what changed
    hashFiles()a hash of the files: the key changes the moment dependencies change

    Caches are per branch (a pull request can read caches from its base branch), unused ones are evicted after about a week, and repositories have a size limit. So a pipeline must still work with no cache: it's only ever a speed-up. Use npm ci, not npm install, in CI: it installs exactly what the lockfile says and fails if the lockfile and package.json disagree.

    Artifacts: passing files between jobs

    Jobs run on different machines, so the dist/ one job builds isn't there for the next. Upload it as an artifact:

    build:
      steps:
        - run: tar -czf "shop-api-${{ steps.version.outputs.version }}.tgz" -C dist .
        - uses: actions/upload-artifact@v7
          with:
            name: shop-api
            path: shop-api-*.tgz
            retention-days: 7
    
    report:
      needs: build
      steps:
        - uses: actions/download-artifact@v8
          with:
            name: shop-api           # the files land in the working folder
    

    Artifacts also appear on the run's page for anyone with access to download: test reports, coverage, build logs. Each name can be uploaded once per run, so in a matrix give each job its own name, such as coverage-node-${{ matrix.node }}. Retention defaults to 90 days and counts against storage; keep it short for anything you rebuild anyway.

    CacheArtifact
    Forspeeding up future runskeeping or passing this run's output
    Typical contentdependencies, build cachespackages, test reports, coverage
    Guaranteed to exist?no: may be evictedyes, until it expires
    Build once, deploy that

    The artifact the build job uploads is the one you test, stage and deploy (lesson 1's "build once, deploy many"). Never rebuild for production: a second build can pull different dependencies and ship code that was never tested.

    Concurrency: one run per branch

    Push three commits to a pull request in a minute and you get three runs, though only the last one matters. Merge two pull requests close together and two deploys race each other to the same server. A concurrency group fixes both: GitHub allows one running and one pending run per group, and a newer pending run replaces the older one.

    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}                 # one lane per workflow and branch
      cancel-in-progress: ${{ github.event_name == 'pull_request' }}  # PRs: kill the outdated run
    
    Workflowcancel-in-progressWhy
    PR checkstruean older commit's result is useless once a newer one is pushed
    Deployfalsestopping a deploy halfway can leave a server with half a release
    Main branch CIfalseevery commit on main should get a result, for git bisect later

    A deploy workflow uses a fixed group name, such as deploy-production, so deploys from any branch or workflow wait in one queue. concurrency: also works on a single job, when only the deploy job needs the lock.

    Path filters: run only when it matters

    Fixing a typo in the README shouldn't run twenty minutes of tests. paths runs a workflow only when a matching file changed; paths-ignore skips it when only ignored files changed:

    on:
      pull_request:
        paths-ignore:
          - 'docs/**'
          - '**/*.md'
    

    You can't use both on the same event. A deploy workflow that tries:

    name: deploy
    on:
      push:
        branches: [main]
        paths: ['src/**', 'package*.json']
        paths-ignore: ['docs/**']
    

    actionlint catches it:

    $ actionlint
    .github/workflows/deploy.yml:6:5: both "paths" and "paths-ignore" filters cannot be used for the same event "push". note: use '!' to negate patterns [events]
      |
    6 |     paths-ignore: ['docs/**']
      |     ^~~~~~~~~~~~~
    

    The fix is one list with a ! exception: paths: ['src/**', 'package*.json', '!src/**/*.md'].

    One trap: when a path filter skips a workflow, its checks never report, and a pull request that requires them waits forever as Expected. Either don't make path-filtered workflows required, or keep the workflow always running and decide inside a job what to skip (lesson 12 does this for monorepos).

    Service containers: a real database for tests

    Integration tests want a real database, not mocks. services: starts containers next to the job, waits for their health check, and throws them away when the job ends, so every run starts empty:

    integration:
      runs-on: ubuntu-latest
      services:
        postgres:
          image: postgres:16-alpine
          env:
            POSTGRES_USER: shop
            POSTGRES_PASSWORD: test-only        # a throwaway test password, not a secret
            POSTGRES_DB: shop_test
          ports:
            - 5432:5432
          options: >-
            --health-cmd "pg_isready -U shop -d shop_test"
            --health-interval 5s
            --health-timeout 5s
            --health-retries 10
      env:
        DATABASE_URL: postgres://shop:test-only@localhost:5432/shop_test
      steps:
        - uses: actions/checkout@v7
        - uses: actions/setup-node@v7
          with:
            node-version: 22
            cache: npm
        - run: npm ci
        - run: npm run test:integration
    

    That is the same as running the container yourself, which is how to debug it locally:

    $ docker run -d --name ci-postgres -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=test-only -e POSTGRES_DB=shop_test \
        -p 5432:5432 --health-cmd "pg_isready -U shop -d shop_test" --health-interval 5s \
        --health-timeout 5s --health-retries 10 postgres:16-alpine
    $ docker ps --filter name=ci-postgres --format '{{.Names}}\t{{.Status}}'
    ci-postgres	Up 15 seconds (healthy)
    $ docker run --rm --network host postgres:16-alpine psql "postgres://shop:test-only@localhost:5432/shop_test" \
        -c "select current_database(), current_user;"
     current_database | current_user
    ------------------+--------------
     shop_test        | shop
    (1 row)
    $ docker rm -f ci-postgres
    
    docker run -d --name ci-postgres -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=test-only -e POSTGRES_DB=shop_test \
    • dockerbuild and run containers
    • runstart a new container
    • -drun in the background
    • --name ci-postgresname the container
    • -e POSTGRES_USER=shopset an environment variable inside
    • -e POSTGRES_PASSWORD=test-onlyset an environment variable inside
    • -e POSTGRES_DB=shop_testset an environment variable inside
    • \the image to start
    docker ps --filter name=ci-postgres --format '{{.Names}}\t{{.Status}}'
    • pslist running containers
    • --filter name=ci-postgresshow only matching items
    • --format '{{.Names}}\t{{.Status}}'print only these fields (Go template)
    docker run --rm --network host postgres:16-alpine psql "postgres://shop:test-only@localhost:5432/shop_test" \
    • --rmdelete the container when it exits
    • --network hostwhich network to join
    • postgres:16-alpinethe image to start
    • psql "postgres://shop:test-only@localhost:5432/shop_test" \the command run inside, instead of the image’s default
    docker rm -f ci-postgres
    • rmdelete a container
    • -fstop it first if running
    • ci-postgresthe container

    When the job runs directly on the runner (as above), it reaches the service on localhost through the mapped port. When the job itself runs in a container (container: node:22), it reaches the service by its name instead, postgres:5432, with no ports: needed. Service containers need a Linux runner.

    ⌘
    Mini mission

    Test on every Node version, build once, hand it on

    Extend shop-api's CI: run the tests as a matrix on Node 20, 22 and 24 plus Windows, with npm caching and fail-fast off; then a build job that creates a versioned package, uploads it as an artifact and writes a run summary; and a report job that always runs and downloads it.

    Input: The shop-api repository from the previous lessonOutput: A run with 4 test jobs, a build artifact and a summary
    Reveal one safe solution
    $ npm install                      # creates package-lock.json, which npm ci and the cache need
    $ git add package-lock.json
    $ nano .github/workflows/ci.yml    # the test matrix, build and report jobs from this lesson
    $ actionlint && echo "workflow OK"
    workflow OK
    $ git commit -am "ci: test on a matrix, upload the build" && git push
    $ gh run watch                     # follow the run
    $ gh run download --name shop-api  # the artifact, on your machine
    $ tar -tzf shop-api-*.tgz
    ./
    ./package.json
    ./src/
    ./src/server.js
    ./src/app.js
    
    git add package-lock.json
    • gitversion control
    • addstage changes
    actionlint && echo "workflow OK"
    • &&run the next command only if this one worked
    • echoprint text
    git commit -am "ci: test on a matrix, upload the build" && git push
    • commitsave staged changes
    • pushupload commits
    tar -tzf shop-api-*.tgz
    • tarpack or unpack archives

    The run page shows four test jobs side by side, then build, then report. The second run's install step is faster: setup-node prints Cache restored from key: node-cache-…. The summary page shows Built shop-api 1.0.N-<sha>.

    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

    How does one step pass a value to a later step in the same job?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: expressionsGitHub Docs ↗
    • DocsGitHub Docs: contexts referenceGitHub Docs ↗
    • DocsGitHub Docs: running variations of jobs (matrix)GitHub Docs ↗
    • DocsGitHub Docs: dependency caching referenceGitHub Docs ↗
    • Toolactions/upload-artifactGitHub ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousGitHub Actions: your first real pipelineNext lesson →Secrets, environments and OIDC: deploy without leaking keys