Linux DevOps · Lesson 3 · 35 min

CI pipelines: test every push

You can test your code by hand before every commit, and you'll forget exactly once, on the Friday afternoon it matters most. Continuous Integration (CI) makes a server do it for you: every time anyone pushes, a fresh machine checks out the code, lints it, tests it and builds it, then reports ✓ or ✗ to everyone. Broken code gets caught minutes after it's written, not weeks later in production.

You will learn

  • CI, CD and CD: continuous integration, delivery and deployment
  • The anatomy of a GitHub Actions workflow: on, jobs, runs-on, steps, uses, run
  • Reading CI results with gh run list and gh run view --log-failed
  • Linting shell scripts with ShellCheck, locally and in CI
  • GitLab CI, Jenkins, Forgejo and friends, and good pipeline habits

CI, CD and CD

MeansAutomates
Continuous IntegrationEveryone merges small changes often, and every change is checked automaticallylint → test → build
Continuous DeliveryEvery change that passes CI is ready to release. A human presses the button.+ package → upload to a registry → deploy to staging
Continuous DeploymentEvery change that passes goes to production automatically+ deploy to production (with the safety nets from the deploy lesson)

The order of the stages matters: fail fast. Put quick checks (linting, seconds) before slow ones (tests, builds, minutes), so a typo is reported right away.

A workflow, line by line

GitHub runs every YAML file in .github/workflows/. The practice repo will get this one (a copy is in ~/examples/ci.yml):

.github/workflows/ci.yml
name: CI                        # shown in the list of runs

on: [push]                      # WHEN: on every push (also: pull_request, schedule, workflow_dispatch…)

jobs:
  test:                         # a job = one fresh machine
    runs-on: ubuntu-24.04       # WHERE: a GitHub-hosted Ubuntu VM, thrown away afterwards
    steps:                      # WHAT: in order; the first failure stops the job
      - uses: actions/checkout@v4     # a ready-made action: clone the pushed commit

      - name: Lint shell scripts
        run: shellcheck *.sh          # a shell command (bash -e: any error fails the step)

      - name: Run tests
        run: ./test.sh                # exit code 0 = pass, anything else = fail

Both families in one pipeline

A matrix runs the same job several times with different settings. Here it runs your tests inside a Rocky container and an Ubuntu one:

jobs:
  test:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        image: ["rockylinux/rockylinux:9", "ubuntu:24.04"]
    container: ${{ matrix.image }}
    steps:
      - uses: actions/checkout@v4
      - run: ./test.sh

(The practice terminal runs plain steps. Matrices and container: are for the real thing.)

Reading the results from the terminal

GitHub shows runs on the website, and GitHub's own CLI, gh, shows them in the terminal:

gh run list                       # recent runs: ✓ passed, X failed
gh run view                       # the latest run: jobs, steps, and which one failed
gh run view 11493020001 --log-failed    # just the output of the failed step
Rocky / RHEL
sudo dnf install epel-release
sudo dnf install gh ShellCheck

Both come from EPEL. Note the package is ShellCheck, with capitals, and the command is shellcheck.

Ubuntu / Debian
sudo apt install gh shellcheck

In the practice terminal, gh is already installed and wired to a git server in /srv/git that behaves like GitHub Actions. On a real machine you'd first run gh auth login.

ShellCheck: a spell-checker for scripts

ShellCheck finds the bugs that bite shell scripts: unquoted variables, a cd that might fail, bash-only syntax in an sh script. Its most famous warning is SC2086:

echo Hello, $name!
            ^---^ SC2086 (info): Double quote to prevent globbing and word splitting.

Unquoted, $name is split into words and globbed: a name like * would turn into a list of files! echo "Hello, $name!" is the fix. ShellCheck is the first step in almost every pipeline that ships shell scripts, and you can run exactly the same command locally before you push.

Other CI systems

SystemConfig fileNotes
GitHub Actions.github/workflows/*.ymlBuilt into GitHub. Huge library of actions.
GitLab CI/CD.gitlab-ci.ymlBuilt into GitLab, which many companies run on their own servers. Stages and jobs, very similar ideas.
Forgejo / Gitea Actions.forgejo/workflows/ or .gitea/workflows/Self-hosted git with Actions-compatible workflows
JenkinsJenkinsfileThe veteran: self-hosted, endlessly pluggable, still everywhere in big companies

The ideas carry over between all of them: a trigger, jobs on clean machines, steps that pass or fail by exit code, and logs to read when they fail.

Habits of good pipelines

Keep it fast (under 10 minutes, or people stop waiting for it). Keep it green: a red main branch is everyone's top priority. Never ignore a flaky test, fix it or delete it. Protect main: on GitHub and GitLab, branch protection can require CI to pass before a pull request is merged. And never put passwords in the YAML. CI systems have a secrets store (${{ secrets.NAME }}).

Practice: red, green, red, green 🚦

~/greeter is a tiny script with tests, already pushed to the git server. Add CI, watch it catch a real bug, fix it, then watch it catch a new one.

Quick check

1. How does CI decide whether a step passed?

2. Tests pass on your laptop but fail in CI with jq: command not found. Why?

3. Why lint before running the tests?

4. What's the difference between continuous delivery and continuous deployment?

Finished the missions and the quiz? Mark it done to track your progress.