ShellCheck in CI
Lint every script, block the classics.
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.
Here is a small backup script that looks fine and runs fine, right up until the day it does not.
#!/bin/basharchive="/backups/$(date +%F)"cd $1tar czf "$archive.tar.gz" .rm -rf $OLD_BACKUPS/*
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.
# shellcheck disable=SC2029 # $service must expand locally, not on the remote hostssh "$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`.
external-sources=true # let -x follow files pulled in with `source`enable=require-variable-braces # optional: prefer ${var} over $varenable=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.
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.
name: shellcheckon:pull_request:push:branches: [main]jobs:lint:runs-on: ubuntu-24.04steps:- uses: actions/checkout@v4- name: Install pinned ShellCheckrun: |curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz" \| tar -xJsudo install shellcheck-v0.10.0/shellcheck /usr/local/bin/- name: Lint every tracked shell scriptrun: |{git ls-files -z -- '*.sh' '*.bash'git grep -lzE '^#!.*/(env +)?(ba)?sh' -- ':!*.sh' ':!*.bash' || true} | xargs -0 -r shellcheck -S style -x
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`.
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.
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.