ShellCheck in CI

Lint every script, block the classics.

Beginner15 min · lesson 13 of 14

A good proofreader reads your letter before you drop it in the mailbox. They catch the missing word, the wrong name, the sentence that says the opposite of what you meant, all while the envelope is still open and nothing has gone out. ShellCheck is that proofreader for shell scripts. It is a *linter* (a program that reads your code and points out likely mistakes) that works by *static analysis* (reading the script without ever running it). Nothing executes, so it is perfectly safe to point at code you do not trust.

Run that proofreader by hand and it is advice. Wire it into *CI* (continuous integration, the automation that checks every change pushed to a shared repository) and it becomes law. That combination is this lesson's payoff: a robot that reads every shell script on every push and refuses to let known-bad patterns merge. Earlier lessons handed you rules. Quote every expansion. Check that `cd` succeeded. Clean up with traps. Make temp files safely. Rules kept in human memory fail exactly when the stakes are highest: the Friday hotfix, the new hire's first week, the 400-line script no one has opened in two years.

The failure has a famous name. In 2015 the Steam client for Linux shipped an installer that ran `rm -rf "$STEAMROOT/"*`. When `$STEAMROOT` came back empty, that line expanded to `rm -rf /*` and started deleting users' home directories, in some cases every mounted drive they owned. ShellCheck ships a check aimed at exactly that pattern (SC2115). A linter turns your team's discipline from a habit one tired person can forget into a property the machine enforces on everyone.

What ShellCheck actually does

ShellCheck is a program written in Haskell with its own parser for `sh`, `bash`, `dash`, `ksh`, and BusyBox `sh`. It reads your script into a *syntax tree* (a structured map of every command, expansion, and quote, the way a sentence diagram shows subject, verb, and object) and then walks that tree with several hundred checks. It tracks which variables you assigned, which expansions you quoted, and which exit codes you actually looked at. Every finding carries a stable code. SC1xxx are parse problems, SC2xxx are logic bugs, and SC3xxx are portability issues where your code uses a bash feature that plain POSIX `sh` does not have.

It picks which dialect to check by reading the *shebang* (the `#!` line at the top of the file that names the interpreter). A file that starts `#!/bin/sh` gets the stricter portability rules; `#!/usr/bin/env bash` gets the full bash vocabulary. No shebang, or you want to override the one that is there? Force the dialect with `-s bash`. Every finding also carries a severity, one of **error**, **warning**, **info**, or **style**, in falling order of how badly it can bite you.

~/secopslog — bash
$ sudo apt-get install -y shellcheck # Debian/Ubuntu; 'brew install shellcheck' on macOS shellcheck --version # you want 0.10.0+; if apt ships older, use the pinned install below
ShellCheck - shell script analysis tool version: 0.10.0 license: GNU General Public License, version 3 website: https://www.shellcheck.net

Here is a small backup script that looks fine and runs fine, right up until the day it does not.

backup.sh
#!/bin/bash
archive="/backups/$(date +%F)"
cd $1
tar czf "$archive.tar.gz" .
rm -rf $OLD_BACKUPS/*
~/secopslog — bash
$ shellcheck backup.sh echo "exit status: $?"
In backup.sh line 3: cd $1 ^---^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails. ^-- SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: cd "$1" || exit In backup.sh line 5: rm -rf $OLD_BACKUPS/* ^------------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* . ^----------^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: rm -rf "$OLD_BACKUPS"/* For more information: https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure this nev... https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |... https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... exit status: 1

Read the anatomy of one finding. You get the offending line, a caret pointing at the exact span, the code, the severity in parentheses, and a `Did you mean:` block with a concrete rewrite you can paste back in. Every code has a wiki page (`shellcheck.net/wiki/SC2115`) that explains the bug it prevents and shows a fixed version. Those pages are some of the clearest shell documentation anywhere; when a finding puzzles you, open the link.

For CI, one thing matters more than the pretty output: the *exit status* (the number a program hands back when it finishes, where 0 means success). ShellCheck returns `0` when a file is clean, `1` when it found problems, `2` when it could not read a file, and `3` or `4` when you called it with a bad flag. Any nonzero value fails a pipeline job. That single number is the whole enforcement mechanism. Everything else exists to help a human fix what the number flagged.

Fix, silence, or configure, in that order

The right response to a finding, almost always, is to fix it. Now and then the flagged behavior is what you meant. SC2029 warns that a variable inside an `ssh` (secure shell, the tool for running commands on a remote machine) command expands on your laptop before the command is sent, not on the far end. Sometimes that is precisely what you want. For those cases you write a *directive* (a magic comment that turns off one check for the one line below it) and you say why. A bare `disable` with no reason is a note to your future self that reads 'trust me,' which is worth nothing at 2 a.m. during an incident.

deploy.sh
# shellcheck disable=SC2029 # $service must expand locally, not on the remote host
ssh "$host" "systemctl restart $service"

Policy that should apply to the whole repository goes in a `.shellcheckrc` file at the repo root. That is where you opt into the optional checks (list them with `shellcheck --list-optional`) and tell the linter it is allowed to follow the files your script pulls in with `source`.

.shellcheckrc
external-sources=true # let -x follow files pulled in with `source`
enable=require-variable-braces # optional: prefer ${var} over $var
enable=quote-safe-variables # optional: quote even "known safe" expansions

ShellCheck can also write the mechanical fixes for you. Ask for its suggestions as a *diff* (a patch describing exact line changes) and pipe that straight into `git apply`. It only rewrites the findings it knows a safe fix for; the rest still need your eyes.

~/secopslog — bash
$ shellcheck -f diff backup.sh | git apply git diff --stat
backup.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)

The CI gate

Running the linter on your laptop helps you. Running it in CI protects everyone, because a gate in the pipeline survives a misconfigured laptop, a brand-new hire, and a deadline. Building that gate means solving two quiet problems.

The first is *discovery*: which files are even shell scripts? Matching `*.sh` is not enough. The scripts that do the scariest things (your deploy entry point, the cron job that rotates logs, where a cron job is a task the system runs on a schedule) are usually extensionless executables sitting in a `bin/` directory. So you also search your tracked files for a shell shebang and lint whatever you find, extension or not.

The second is *pinning*: nailing the tool to one exact version. Every ShellCheck release adds checks. An unpinned linter can turn every branch in the company red overnight over code nobody touched, only because a new check shipped. Pin the version, and upgrade it on purpose, in its own reviewed commit, so the new findings all land in one place you can read.

.github/workflows/shellcheck.yml
name: shellcheck
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install pinned ShellCheck
run: |
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz" \
| tar -xJ
sudo install shellcheck-v0.10.0/shellcheck /usr/local/bin/
- name: Lint every tracked shell script
run: |
{
git ls-files -z -- '*.sh' '*.bash'
git grep -lzE '^#!.*/(env +)?(ba)?sh' -- ':!*.sh' ':!*.bash' || true
} | xargs -0 -r shellcheck -S style -x
output
In scripts/rotate-logs line 9:
rm -rf $ARCHIVE_DIR/*
^------------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .
^----------^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm -rf "$ARCHIVE_DIR"/*
For more information:
https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure this nev...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
Error: Process completed with exit code 1.

On GitLab the same idea is a job that runs on the `koalaman/shellcheck-alpine:v0.10.0` image with that identical two-line discovery pipe. For feedback before you even push, wire the same command into a *pre-commit hook* (a script git runs while you are recording a commit, one that can block it). Keep CI as the source of truth, though. Hooks are opt-in, and anyone can skip one with `git commit --no-verify`.

The .sh blind spot
Linting only `*.sh` files quietly skips the scripts most likely to hurt you: the extensionless executables in `bin/`, plus the shell buried inside CI YAML (a text format used for config files) `run:` blocks and Dockerfile `RUN` lines. The team sees a green check and believes it is covered while the real deploy entry point has never been linted once. Discover by shebang as well as by extension. For the embedded fragments, add `actionlint` (for GitHub workflow files) and `hadolint` (for Dockerfiles); both run ShellCheck on the shell code inside those files for you.
How the gate decides
1Push / open PR
a change hits the shared repo
2Discover scripts
*.sh, *.bash, plus shebang matches
3Run pinned ShellCheck
static analysis, nothing executes
4Read the exit code
0 clean, nonzero means findings
5Block or merge
nonzero fails the job; 0 lets it through

What it can't do, and what breaks at scale

Static analysis has a hard ceiling, and knowing where it sits keeps you honest. ShellCheck never sees runtime values, so it will tell you that you used `$REGION` unsafely but never that `$REGION` holds the wrong region. It follows your code across files only through `source`. It throws the occasional false positive. And it cannot test whether your logic is correct. It sits alongside the strict mode, quoting, and traps from earlier lessons; it does not replace any of them.

The problem at scale is social, not technical. The tool reads thousands of lines a second, but aim it at 300 old scripts and you get a wall of findings no one will ever work through. So you *ratchet*: tighten the bar one notch at a time. Start the gate at `-S error` so only the worst class blocks a build. Burn the backlog down with `-f diff`. Then tighten the threshold to `warning`, and later to `style`, as the numbers come down. Never ratchet by scattering global `disable` lines. That freezes the debt exactly where it is and hides every new instance of the same bug behind an all-clear.

Quick check
01A team's ShellCheck CI job finds files by globbing `*.sh`, every pull request shows a green check, yet their main production deploy script has never once been linted. What is the most likely reason?
Correct — Ops entry points are usually extensionless; discover by shebang too, not only by extension.
Incorrect — ShellCheck parses any shell it can read; calling other programs is normal and fine.
Incorrect — Exit 1 fails the job; a green check means exit 0. The file was never handed to the linter at all.
Incorrect — Nothing here points to a disable directive; the file was never discovered in the first place.
02The CI workflow installs one exact ShellCheck version (v0.10.0) instead of 'whatever is latest.' Why does the lesson insist on pinning?
Incorrect — pinning is about controlling change, not because old versions fail to parse bash.
Correct — pinning stops a surprise release from reddening untouched code and moves new findings into one reviewed commit.
Incorrect — pinning is about reproducible findings, not runtime speed.
Incorrect — version choice does not disable a whole class of checks; unpinning just accumulates new ones over time.
03ShellCheck flags `ssh "$host" "systemctl restart $service"` with SC2029, warning that `$service` expands on your machine before the command is sent. Here that local expansion is exactly what you intend. What is the right response?
Correct — a per-line directive with a stated reason silences the one intended case without hiding it anywhere else.
Incorrect — the variable still expands locally, and dropping the quotes only invites word-splitting bugs.
Incorrect — a global disable also hides every genuine future instance of the same mistake.
Incorrect — contorting real logic just to quiet a linter is never the right fix.

The same move works past bash. Point `hadolint` at your Dockerfiles, `actionlint` at your workflow files, and `tflint` or `checkov` at your infrastructure code, then gate each one on its exit status the way you gated ShellCheck. A rule you can write down is a rule a machine can enforce, and a rule the machine enforces on every push is one your team stops having to remember.

Related