Running Shell Tests in CI Pipelines
Wire ShellCheck and Bats into GitHub Actions to gate every shell change on green checks.
Running Shell Tests in CI Pipelines is a free DevOps Bootcamp lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why CI Matters for Shell Scripts
Shell scripts are code — and like all code, they deserve automated quality gates. Without CI, a typo in a deploy script can silently reach production and cause an outage at 3 AM.
A solid CI pipeline for Bash projects enforces two things on every pull request:
- Static analysis via
ShellCheck— catches syntax errors, unsafe patterns, and POSIX portability issues before the script ever runs. - Unit/integration tests via
Bats(Bash Automated Testing System) — executes your functions and asserts correct behaviour.
Together they form a safety net that makes refactoring fearless and onboarding faster. This lesson wires both tools into GitHub Actions, the most common free CI platform for open-source and small-team projects.
GitHub Actions Primer for Shell Projects
GitHub Actions is event-driven CI/CD built into GitHub. A workflow is a YAML file stored under .github/workflows/. It triggers on events (push, pull_request, etc.) and runs jobs on hosted runners.
Key concepts you need:
on:— the trigger (e.g.push,pull_request)jobs:— parallel units of work, each on a fresh VMsteps:— sequential shell commands or reusable actions within a jobruns-on:— the runner image (we useubuntu-latest)
Workflow files must be committed to the repository. GitHub detects them automatically — no external setup required.
# Minimal skeleton — .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
shell-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Steps go here"Installing ShellCheck in a Workflow
ShellCheck is pre-installed on ubuntu-latest runners, so in most cases you need zero installation steps. However, the pre-installed version may lag behind the latest release. For reproducible builds, pin a specific version.
Two installation strategies:
- Use the pre-installed binary — simplest, good enough for most projects.
- Install a pinned version via the official GitHub release tarball — guarantees the same linter version locally and in CI.
The step below shows the pinned approach using a fixed version string stored as an environment variable, making upgrades a one-line change.
# .github/workflows/ci.yml — ShellCheck install step
- name: Install ShellCheck
env:
SC_VERSION: v0.10.0
run: |
curl -sSfL \
"https://github.com/koalaman/shellcheck/releases/download/${SC_VERSION}/shellcheck-${SC_VERSION}.linux.x86_64.tar.xz" \
| tar -xJf - --strip-components=1 -C /usr/local/bin shellcheck-${SC_VERSION}/shellcheck
shellcheck --versionRunning ShellCheck on Every Script
After installation, you need a step that discovers and lints all shell scripts in the repository. Use find to locate files, then pipe them to shellcheck.
Important flags to know:
-e SC2034— exclude a specific rule (use sparingly and with a comment).--severity=warning— fail only on warnings and above (ignoring style suggestions).-x— followsourcedirectives to lint sourced files too.
If shellcheck finds any issue it exits non-zero, which automatically fails the CI step — no extra logic needed.
# .github/workflows/ci.yml — ShellCheck lint step
- name: Lint shell scripts
run: |
# Find all .sh files and files with a bash/sh shebang
mapfile -t scripts < <(
find . -type f -name '*.sh' -not -path './.git/*'
)
if [[ ${#scripts[@]} -eq 0 ]]; then
echo 'No shell scripts found — skipping.'
exit 0
fi
echo "Linting ${#scripts[@]} file(s)..."
shellcheck --severity=warning -x "${scripts[@]}"What Is Bats and How Does It Work?
Bats (Bash Automated Testing System) is a TAP-compliant testing framework for Bash. Each test file is a .bats file containing @test blocks.
A test passes when its body exits 0 and fails when it exits non-zero. Bats provides helper variables and functions:
$status— exit code of the lastruncommand.$output— combined stdout+stderr of the lastruncommand.$lines— array of output lines.run <cmd>— execute a command without failing the test on non-zero exit.
The run helper is essential — without it, a failing command would abort the test before you can inspect $status.
#!/usr/bin/env bats
# tests/greet.bats
setup() {
# Runs before every @test block
source "${BATS_TEST_DIRNAME}/../lib/greet.sh"
}
@test "greet outputs hello with the given name" {
run greet "Alice"
[ "$status" -eq 0 ]
[ "$output" = "Hello, Alice!" ]
}
@test "greet fails when no argument is provided" {
run greet
[ "$status" -eq 1 ]
[[ "$output" == *"Usage"* ]]
}Installing Bats-Core via Git Submodule
The canonical way to add Bats to a project is as a Git submodule. This pins a specific commit, keeps the runner version identical to local dev, and avoids relying on package managers.
Run these commands once locally, then commit the result:
git submodule add https://github.com/bats-core/bats-core test/batsgit submodule add https://github.com/bats-core/bats-support test/test_helper/bats-supportgit submodule add https://github.com/bats-core/bats-assert test/test_helper/bats-assert
In CI, restore submodules with actions/checkout@v4 and the submodules: recursive option. The step below shows the complete checkout configuration.
# .github/workflows/ci.yml — checkout with submodules
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive # restores bats-core + helpersRunning Bats Tests in CI
Once Bats is available (via submodule or package install), running the tests is a single command. Point it at a directory and Bats discovers every .bats file recursively with the --recursive flag.
The --formatter tap flag outputs TAP (Test Anything Protocol) format, which many CI systems parse for test reporting. The default pretty formatter is better for human reading in raw logs.
Use --timing to surface slow tests early — a test taking over 5 seconds usually signals an unwanted network call or missing mock.
# .github/workflows/ci.yml — Bats test step
- name: Run Bats tests
run: |
# If installed as a submodule:
./test/bats/bin/bats \
--recursive \
--timing \
tests/
# If installed via apt or brew (alternative):
# bats --recursive --timing tests/A Complete Workflow: ShellCheck + Bats
Now combine everything into one production-ready workflow file. Best practices applied here:
- Two separate jobs (
lintandtest) run in parallel, giving faster feedback. - The
testjob declaresneeds: lintso tests only run after linting passes — avoids wasting runner minutes on obviously broken code. - Pinned action versions (
@v4) prevent surprise breakage from upstream updates. - A
permissions:block restricts the workflow token to the minimum required.
# .github/workflows/ci.yml
name: Shell CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
lint:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
run: |
mapfile -t scripts < <(find . -name '*.sh' -not -path './.git/*')
[[ ${#scripts[@]} -gt 0 ]] && shellcheck --severity=warning -x "${scripts[@]}"
test:
name: Bats Tests
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Run tests
run: ./test/bats/bin/bats --recursive --timing tests/Caching Dependencies for Faster Runs
When Bats helpers or other tools are installed via a package manager inside the workflow, caching speeds up subsequent runs dramatically. GitHub Actions provides the actions/cache action for this.
Key points for effective caching:
- Use a cache key that includes the OS, tool name, and a lockfile hash — so the cache invalidates automatically when dependencies change.
- A
restore-keysfallback lets the workflow use a stale cache rather than starting from scratch on a cache miss. - For Git submodules, caching is rarely needed because submodule checkout is fast. Cache is most valuable for
npm,pip, or compiled tool installations.
# .github/workflows/ci.yml — cache step example
- name: Cache Bats npm helpers
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-bats-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-bats-
- name: Install helpers
run: npm ci # uses cache when availableBranch Protection: Enforcing Green Checks
A CI workflow that does not block merges is advisory at best. GitHub Branch Protection rules turn your checks into hard gates.
To configure them: Settings → Branches → Add rule for main, then enable:
- Require status checks to pass before merging — select ShellCheck and Bats Tests by name.
- Require branches to be up to date before merging — prevents a PR that passed checks on a stale base from landing broken code.
- Do not allow bypassing the above settings — applies the rules even to repository admins.
With these rules in place, the only path to merge is a PR with all CI jobs green — exactly the safety net you want.
Debugging Failing CI Steps Locally
When a CI run fails, the fastest fix cycle is to reproduce the failure locally before pushing another commit. Two techniques:
- Run the exact commands from the failing step in your terminal — CI runs plain shell, so the commands are copy-paste reproducible.
- Use
act— a tool that runs GitHub Actions workflows locally inside Docker, giving the closest possible match to the hosted runner environment.
A common source of CI-only failures is a tool version mismatch between your Mac (e.g. BSD find on macOS vs GNU find on Ubuntu). Always test with --posix flags or use act to run the Ubuntu image locally.
#!/usr/bin/env bash
# run_ci_locally.sh — mimic the CI lint step on your machine
set -euo pipefail
echo '=== ShellCheck ==='
mapfile -t scripts < <(find . -name '*.sh' -not -path './.git/*')
if [[ ${#scripts[@]} -eq 0 ]]; then
echo 'No .sh files found.'
else
shellcheck --severity=warning -x "${scripts[@]}"
echo "Linted ${#scripts[@]} file(s) — OK"
fi
echo '=== Bats ==='
./test/bats/bin/bats --recursive --timing tests/Knowledge Check: CI Pipeline Concepts
Test your understanding of wiring ShellCheck and Bats into GitHub Actions.
Recap: Shell CI with ShellCheck and Bats
In this lesson you built a complete CI pipeline for Bash projects using GitHub Actions. Here is what you covered:
- GitHub Actions basics — workflow YAML lives in
.github/workflows/, triggers on push and pull_request, and runs jobs onubuntu-latestrunners. - ShellCheck — pre-installed on Ubuntu runners; use
findto discover scripts and--severity=warning -xfor a practical lint gate. - Bats via submodule — pin bats-core and helpers as Git submodules; restore them in CI with
submodules: recursiveon the checkout action. - Job ordering — use
needs:so tests run only after linting passes, keeping fast feedback and avoiding wasted compute. - Branch protection — enforce status checks in GitHub settings so no PR can land without green CI.
- Local reproduction — copy CI commands directly to your terminal or use
actto debug failures without extra commits.
With this pipeline in place, every shell change is automatically validated before it touches your main branch.
Frequently asked questions
Is the “Running Shell Tests in CI Pipelines” lesson free?
Yes — the full text of “Running Shell Tests in CI Pipelines” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Running Shell Tests in CI Pipelines”?
Wire ShellCheck and Bats into GitHub Actions to gate every shell change on green checks. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Running Shell Tests in CI Pipelines” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Unit Testing Functions with Bats-core
- Mocking Commands and Stubbing External Tools
- Fixtures, Temp Environments, and Coverage
- Running Shell Tests in CI Pipelines