Education › DevOps › Stage 2: Build & ship

CI/CD pipelines

Build, test, and deploy on every commit with GitHub Actions; caching, matrices, environments.

Beginner–Intermediate ~35 min read Module 6 of 17

A pipeline is the only road to production worth having: every commit is built, tested and packaged by the same automated steps, so releasing becomes routine instead of an event. This module explains what continuous integration and continuous delivery really require, then builds a complete GitHub Actions pipeline with caching, a test matrix, a container image build, and a production deployment guarded by an approval.

After this module you can
  • Distinguish continuous integration, continuous delivery and continuous deployment
  • Read and write a GitHub Actions workflow: triggers, jobs, steps, runners, and needs
  • Speed pipelines up with dependency caching, matrices and parallel jobs
  • Handle secrets, permissions and environments with approval gates safely
  • Design a pipeline that builds an artifact once and promotes the same artifact through environments

CI, CD and the other CD

Continuous integration (CI) means every developer merges to the main branch at least daily, and every push is automatically built and tested. Its purpose is fast feedback: you learn that you broke something within minutes, while the change is still in your head.

Continuous delivery means every commit that passes the pipeline is a release candidate that could go to production at the push of a button. Continuous deployment removes the button: every passing commit goes live automatically. Both share the abbreviation CD; the difference is whether a human makes the final decision.

  • Keep it fast. If the pipeline takes forty minutes, people batch changes and stop waiting for it. Aim for feedback on a pull request in about ten minutes.
  • Fail early. Run the cheapest checks first: lint and unit tests before integration tests before anything that deploys.
  • Keep main green. A red main branch blocks everyone. Fixing it, or reverting, takes priority over new work.
  • Build once. The artifact that was tested is the artifact that ships. Rebuilding per environment means you deploy something nobody tested.

Anatomy of a workflow

GitHub Actions reads YAML files in .github/workflows/. A workflow is triggered by an event (on:). It contains jobs, which run in parallel by default, each on a fresh virtual machine called a runner. A job is a list of steps, run in order on that one machine. A step either runs shell commands (run:) or calls a reusable action (uses:).

.github/workflows/ci.yml
yaml
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt
      - run: ruff check .
      - run: pytest --maxfail=1

A step fails when its command exits non-zero, which is the exit-code convention from the scripting module doing its job. A failed step fails the job, and later steps are skipped. Because each job gets a brand-new machine, nothing carries over between jobs unless you pass it explicitly as an artifact, a cache entry or a job output.

Note

Action versions move on. The major versions shown here (@v4, @v5) are illustrative; check each action's repository for its current release when you write a real workflow.

Faster: caching, matrices, dependencies between jobs

Downloading the same dependencies on every run is the most common waste. The setup-* actions have a built-in cache: option, and the general actions/cache action stores any directory under a key. Derive the key from a hash of your lockfile, so the cache is reused until your dependencies actually change.

A matrix runs the same job once for each combination of values, in parallel: several language versions, several operating systems. needs: makes one job wait for another, which turns the default parallel jobs into an ordered pipeline.

yaml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test

  build:
    needs: test              # runs only if every matrix leg passed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

npm ci installs exactly what the lockfile says and fails if it is out of date, which is what you want in CI. The concurrency key cancels superseded runs so that pushing three commits in a row does not queue three full pipelines.

yaml
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

Secrets, permissions and untrusted code

Credentials are stored as encrypted secrets at repository, organisation or environment level and read with ${{ secrets.NAME }}. They are masked in logs. Non-sensitive settings go in variables (${{ vars.NAME }}). Every run also receives an automatic, short-lived GITHUB_TOKEN; restrict what it can do with a permissions: block and grant only what each job needs.

yaml
permissions:
  contents: read            # default for every job in this workflow

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write       # only this job may push images
    steps:
      - uses: actions/checkout@v4
      - name: Log in to the container registry
        run: echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin
        env:
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ACTOR: ${{ github.actor }}
Watch out

Never interpolate untrusted text such as a pull request title or branch name directly into a run: script with ${{ ... }}. The value is pasted into the script before the shell runs, so a crafted title becomes shell code. Pass it through env: and reference it as a quoted shell variable, as the example above does.

  • Workflows triggered by pull_request from a fork do not receive your secrets. That is deliberate: a stranger's code must not be able to read them.
  • A third-party action is code you run with access to your token. Prefer well-known publishers, and for sensitive pipelines pin to a full commit SHA instead of a movable tag.
  • Prefer short-lived cloud credentials obtained through OIDC over long-lived access keys stored as secrets. The secrets module covers this.

Build once, promote everywhere

The delivery half of the pipeline packages the application as an immutable artifact, usually a container image, tags it with something traceable such as the commit SHA, and then moves that same image through staging and production. Differences between environments live in configuration, never in the build.

An environment in GitHub Actions is a named deployment target with its own secrets and protection rules. Add required reviewers to production and the job pauses until someone approves it, which is continuous delivery's "push of a button".

CONTINUOUS INTEGRATIONCONTINUOUS DELIVERY: SAME IMAGEtriggersgreendocker pushneeds: buildthenpassedapprovedPush to maincommit 7f3a9c1Lint + testfail fastBuild + pushexactly onceRegistryimage:7f3a9c1Deploy stagingimage:7f3a9c1Smoke testearns promotionApprovalrequired reviewerDeploy prodimage:7f3a9c1
Build once, promote everywhere: the image is built and pushed a single time, tagged with the commit SHA, and that same image is deployed to staging, smoke-tested, and then to production after an approval.
.github/workflows/deliver.yml
yaml
name: Deliver
on:
  push:
    branches: [main]

env:
  IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to the registry
        run: echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin
        env:
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ACTOR: ${{ github.actor }}
      - run: docker build -t "$IMAGE" .
      - run: docker push "$IMAGE"

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh staging "$IMAGE"
      - run: ./scripts/smoke-test.sh https://staging.example.com

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production      # waits for required reviewers
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh production "$IMAGE"

Tagging with the commit SHA gives you traceability in both directions: from a running container you can find the exact source, and rolling back is simply deploying an earlier SHA that already exists in the registry. The smoke test after staging is what earns the right to promote.

When the pipeline itself is the problem

  • Flaky tests that pass on a re-run teach the team to ignore red builds. Quarantine them, fix them, or delete them; do not normalise the retry button.
  • "Works locally, fails in CI" usually means an undeclared dependency: a tool installed on your laptop, a different runtime version, a file ignored by Git, or a test that depends on timezone or ordering.
  • Slow pipelines are fixed by measuring. Look at per-step timings, cache the dependency install, split the test suite across parallel jobs, and move rarely failing slow suites to run after merge.
  • Debugging is easier when the logic lives in scripts in your repository (./scripts/test.sh) that the workflow merely calls, because you can run the same script locally. It also keeps you portable across CI systems.

The concepts here are not specific to GitHub. GitLab CI, Jenkins, CircleCI and others all have triggers, jobs, runners, caches, artifacts, secrets and environments under slightly different names.

Hands-on practice

Build a real pipeline for the app you containerised

  1. In the repository from the Docker module, add .github/workflows/ci.yml that checks out the code, sets up the language with dependency caching, runs a linter and runs the tests on every pull request.
  2. Open a pull request with a deliberately failing test. Confirm the check goes red, then turn on branch protection so main requires that check to pass before merging.
  3. Add a matrix over two runtime versions. Compare the run time of the first run with the second, and find the cache hit in the logs.
  4. Add a second workflow on push to main that builds the image, tags it with the commit SHA, and pushes it to GitHub Container Registry using GITHUB_TOKEN with packages: write.
  5. Create staging and production environments in the repository settings. Add yourself as a required reviewer on production, add two deploy jobs that for now just echo the image tag, and watch the run pause for approval.
  6. Add a concurrency block, push two commits quickly, and confirm the first run is cancelled.
Cheat sheet

CI/CD pipelines — at a glance

Main things to focus on

  • CI is merging and testing constantly. Continuous delivery means always releasable; continuous deployment means released automatically.
  • Jobs run in parallel on separate fresh machines; steps run in order on one machine. needs: creates ordering.
  • A step fails when its command exits non-zero.
  • Cache dependencies keyed on the lockfile hash; use matrices and parallel jobs for speed.
  • Least privilege: a permissions: block on every workflow, secrets only where needed, no secrets for fork PRs.
  • Never paste untrusted ${{ }} values into run:. Pass them through env:.
  • Build the artifact once, tag it with the commit SHA, promote the same artifact through environments.

Workflow skeleton

on: [push, pull_request]Events that trigger the workflow
on: { push: { branches: [main], paths: ['src/**'] } }Filter by branch and changed paths
on: workflow_dispatchManual "Run workflow" button
on: { schedule: [{ cron: '0 6 * * 1' }] }Scheduled run (UTC)
runs-on: ubuntu-latestRunner type for the job
needs: [build, test]Wait for other jobs to succeed
uses: OWNER/REPO@REFCall a reusable action at a tag or commit SHA
run: |Multi-line shell script step

Control flow

if: github.ref == 'refs/heads/main'Run a job or step only on main
if: failure()Run only when an earlier step failed (e.g. upload logs)
if: always()Run regardless of earlier failures (cleanup)
continue-on-error: trueDo not fail the job if this step fails
timeout-minutes: 15Kill a hung job
strategy: { matrix: { node: [20, 22] } }One job per matrix value
concurrency: { group: NAME, cancel-in-progress: true }Cancel superseded runs

Contexts and data

${{ github.sha }}Commit SHA that triggered the run
${{ github.ref_name }}Branch or tag name
${{ secrets.NAME }}Encrypted secret
${{ vars.NAME }}Non-secret configuration variable
${{ matrix.node }}Current matrix value
echo "key=value" >> "$GITHUB_OUTPUT"Set a step output
echo "KEY=value" >> "$GITHUB_ENV"Set an env var for later steps in the job

Security and delivery

permissions: { contents: read }Restrict the automatic GITHUB_TOKEN
permissions: { id-token: write }Allow the job to request an OIDC token for cloud login
environment: productionUse that environment's secrets and protection rules
actions/upload-artifact / download-artifactPass files between jobs
actions/cache with key: deps-${{ hashFiles('**/package-lock.json') }}Cache keyed on the lockfile

Common pitfalls

  • Rebuilding the application separately for each environment, so production runs a binary that was never tested.
  • Leaving the default token permissions broad, so a compromised action can write to the repository.
  • Interpolating ${{ github.event.pull_request.title }} into a shell script, which allows script injection.
  • Tolerating flaky tests until nobody trusts a red build any more.
  • Expecting files from one job to exist in another without uploading an artifact.
  • Tagging images only as latest, which makes rollback and traceability impossible.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →