CI/CDIntermediate 15 min Lesson 2 of 8

GitHub Actions

Write a working pipeline: triggers, jobs, service containers, caching, matrix builds and keeping it fast.

CI/CD · Lesson 2 of 8
0/8 done(0%)

What is it? #

GitHub Actions runs workflows defined in YAML files in your repository, triggered by events such as pushes and pull requests.

The structure is three levels: a workflow contains jobs, and each job contains steps. Jobs run in parallel by default on separate machines; steps run sequentially on one.

Two features do most of the work in a real pipeline: caching, which avoids reinstalling dependencies every run, and service containers, which give tests a real database.

Keeping the workflow fast is an ongoing concern, and it is mostly about caching, parallelism and not doing unnecessary work.

Think of it like this #

A recipe card kept with the ingredients rather than in someone's head.

Anyone can see exactly what happens, it happens the same way every time, and changing the process means changing the card in a reviewable way.

Simple example #

A pipeline that lints, type checks and tests a Python application against a real PostgreSQL instance, caches dependencies, and builds a container image only on the main branch.

Code #

YAML
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:                          # cancel superseded runs on the same branch
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip                  # caches the pip download directory
      - run: pip install -r requirements-dev.txt
      - run: ruff check .             # fast: fails in seconds
      - run: ruff format --check .
      - run: mypy src/

  test:
    runs-on: ubuntu-latest
    services:
      postgres:                       # a real database for the tests
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-retries 10
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: pip }
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: pytest -q --cov=src --cov-report=term-missing
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test

  build:
    needs: [quality, test]            # only if both passed
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write                 # least privilege for the token
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>g</mi><mi>i</mi><mi>t</mi><mi>h</mi><mi>u</mi><mi>b</mi><mi mathvariant="normal">.</mi><mi>r</mi><mi>e</mi><mi>p</mi><mi>o</mi><mi>s</mi><mi>i</mi><mi>t</mi><mi>o</mi><mi>r</mi><mi>y</mi></mrow><mo>:</mo><mi>s</mi><mi>h</mi><mi>a</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">{{ github.repository }}:sha-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">u</span><span class="mord mathnormal">b</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">p</span><span class="mord mathnormal">os</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em;">or</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.7778em;vertical-align:-0.0833em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord">−</span></span></span></span>{{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
YAML
# Matrix: run the same job across several versions
strategy:
  fail-fast: false                    # do not cancel the others on one failure
  matrix:
    python: ['3.11', '3.12', '3.13']
steps:
  - uses: actions/setup-python@v5
    with: { python-version: ${{ matrix.python }} }

How it works #

on: defines the triggers. Running on pull requests gives feedback before merging; running on main covers the merge commit itself.

concurrency with cancel-in-progress cancels a superseded run when a new commit arrives on the same branch. On an active repository this saves a substantial amount of runner time.

Jobs run on separate machines in parallel. quality and test run simultaneously, so the pipeline takes as long as the slower one rather than their sum.

needs: creates dependencies. The build job runs only after both earlier jobs pass, and the if: condition restricts it to the main branch so pull requests do not push images.

Service containers start alongside the job. The health options matter: without them the test step can start before PostgreSQL is accepting connections, producing intermittent failures.

Caching is the largest single speed factor. The cache: pip option handles dependency downloads; type=gha caches Docker layers between runs, which can turn a three-minute image build into thirty seconds.

permissions: narrows what the automatically provided token can do. The default is broader than most jobs need, and narrowing it is a cheap security improvement.

The matrix runs the same job across several versions in parallel, with fail-fast: false so one failing version does not cancel the information from the others.

Real-world use #

Most repositories end up with a handful of workflows: CI on every push, deployment on merges to main, scheduled dependency updates and security scans.

Pinning action versions matters. Using a floating major version is convenient; pinning to a commit SHA is what supply-chain-conscious teams do, since actions run with access to your repository.

Runner minutes cost money on private repositories, which makes concurrency cancellation and caching financially as well as practically worthwhile.

Self-hosted runners are used for jobs needing more resources or access to private networks, at the cost of maintaining them.

Reusable workflows and composite actions remove duplication once several repositories share the same pipeline shape.

Common mistakes #

  • No caching, so every run reinstalls all dependencies from scratch.
  • Service containers without health checks, causing intermittent failures.
  • Running every job sequentially when they could run in parallel.
  • Leaving default token permissions instead of narrowing them.
  • No concurrency cancellation, so obsolete runs consume runner time.

Practice #

Write a workflow that runs linting and tests in parallel jobs, uses a service container with a health check for the database, and caches dependencies. Measure the run time with and without caching, then add a build job that only runs on the main branch.

Quick quiz

  1. 1. How do jobs and steps differ?

  2. 2. What does `concurrency` with cancel-in-progress do?

  3. 3. Why do service containers need health options?

  4. 4. What does `needs:` express?

  5. 5. Why narrow the workflow token permissions?

Summary

  • Workflows contain jobs; jobs run in parallel and contain sequential steps.
  • Cache dependencies and build layers — it is the biggest speed factor.
  • Service containers give tests real dependencies, with health checks.
  • Use `needs` and `if` to order stages and restrict them to branches.
  • Narrow token permissions and cancel superseded runs.