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 listandgh 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
| Means | Automates | |
|---|---|---|
| Continuous Integration | Everyone merges small changes often, and every change is checked automatically | lint → test → build |
| Continuous Delivery | Every change that passes CI is ready to release. A human presses the button. | + package → upload to a registry → deploy to staging |
| Continuous Deployment | Every 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):
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
- Exit codes are everything. CI knows nothing about “tests”. A step passes if its command exits with 0 and fails otherwise. That's why your test script must
exit 1when something is wrong. - Every run starts clean. Nothing you installed or changed on your own machine exists on the runner. If the tests need a package, the workflow must install it. This is how CI catches “works on my machine”.
uses:pulls in a reusable action (checkout, set up Python, log in to a registry…). Pin a version (@v4) so it doesn't change under you.
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
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.
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
| System | Config file | Notes |
|---|---|---|
| GitHub Actions | .github/workflows/*.yml | Built into GitHub. Huge library of actions. |
| GitLab CI/CD | .gitlab-ci.yml | Built 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 |
| Jenkins | Jenkinsfile | The 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.
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?
✓ That's why test scripts end with exit 1 on failure.
2. Tests pass on your laptop but fail in CI with jq: command not found. Why?
✓ CI just found a hidden dependency. That's exactly its job.
3. Why lint before running the tests?
✓ Quick checks first, slow ones later.
4. What's the difference between continuous delivery and continuous deployment?
✓ Both need solid CI first.