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.
*.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.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.
*.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?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?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.
Try this
Work through “What it can't do, and what breaks at scale” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: the .sh blind spot. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.