ShellCheck in CI: Automated Shell Script Validation and Testing
Shell scripts are everywhere in software engineering — build scripts, deployment automation, CI pipelines, startup scripts, Docker entrypoints. They're also notoriously buggy. Word splitting, unquoted variables, unchecked error codes, and portability issues cause production failures that are difficult to reproduce and debug. ShellCheck catches these issues automatically before they reach production.
What ShellCheck Detects
ShellCheck is a static analysis tool for shell scripts (bash, sh, dash, ksh). It catches:
- Quoting issues: unquoted variables that expand incorrectly with spaces or special characters
- Error handling: missing
set -e, unchecked return codes, ignored failures in pipelines - Portability: bash-specific syntax in
#!/bin/shscripts - Common mistakes:
[ ]vs[[ ]], comparison operators, array handling - Security issues: command injection risks, unsafe temp file creation
Installing ShellCheck
# macOS
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
# Alpine (great for CI containers)
apk add shellcheck
# Via npm (for CI without native install)
npm install -g shellcheckBasic Usage
# Check a script
shellcheck deploy.sh
# Check all shell scripts in directory
shellcheck scripts/*.sh
# Specify shell dialect
shellcheck --shell=bash deploy.sh
# Output as JSON
shellcheck --format json deploy.sh
# Check with specific severity threshold
shellcheck --severity warning scripts/*.shCommon ShellCheck Errors and Fixes
SC2086 — Double-quote variable references:
# Bad — word splitting on spaces, glob expansion
rm -rf $DEPLOY_DIR
# Good
rm -rf "$DEPLOY_DIR"This is the most common ShellCheck error. Unquoted variables split on spaces:
DIR="/path with spaces"
ls $DIR # ls: /path: no such file; ls: with: no such file; ls: spaces: no such file
ls "$DIR" # CorrectSC2155 — Declare and assign separately:
# Bad — hides exit code of subshell
local output=$(dangerous_command)
# Good — captures exit code properly
local output
output=$(dangerous_command)SC2046 — Quote to prevent word splitting:
# Bad
command $(get_args)
# Good
command "$(get_args)"
# Or use an array
mapfile -t args < <(get_args)
command "${args[@]}"SC2164 — Use cd ... || exit:
# Bad — if cd fails, continues in wrong directory
cd /deploy/path
rm -rf * # Dangerous if cd failed!
# Good
cd /deploy/path || exit 1
rm -rf *SC2001 — Use parameter expansion instead of sed:
# Bad
echo "$str" | sed 's/foo/bar/'
# Better
echo "${str/foo/bar}"SC2069 — Redirect order matters:
# Bad — redirects stderr to stdout, then stdout to /dev/null
command 2>&1 >/dev/null
# Good — redirects stdout to /dev/null, then stderr to stdout
command >/dev/null 2>&1SC2010 — Don't use ls | grep:
# Bad — fragile, breaks with spaces
if ls /dir | grep -q "pattern"; then
# Good
if compgen -G "/dir/pattern*" > /dev/null; thenWriting Shell Scripts That Pass ShellCheck
Start scripts with safety flags:
#!/bin/bash
set -euo pipefail
# -e: exit on error
# -u: error on undefined variable
# -o pipefail: pipe fails if any component fails
IFS=$'\n\t' # Safer word splittingUse arrays instead of space-separated strings:
# Bad — spaces in arguments break this
FILES="file1.txt file2.txt file with spaces.txt"
process $FILES
# Good
FILES=("file1.txt" "file2.txt" "file with spaces.txt")
process "${FILES[@]}"Check return values:
# Bad — silent failure
curl https://api.example.com/data > output.json
# Good
if ! curl -f https://api.example.com/data > output.json; then
echo "Failed to fetch data" >&2
exit 1
fiGitHub Actions Integration
# .github/workflows/shellcheck.yml
name: ShellCheck
on:
push:
paths:
- '**/*.sh'
- '**/scripts/**'
pull_request:
paths:
- '**/*.sh'
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
severity: warning
scandir: './scripts'
format: gccThe gcc format produces output that many CI tools parse for inline annotations.
For manual installation:
- name: Install ShellCheck
run: sudo apt-get install -y shellcheck
- name: Find and check shell scripts
run: |
find . -name "*.sh" \
-not -path "*/node_modules/*" \
-not -path "*/.git/*" \
-exec shellcheck --severity warning {} \;SARIF Output for GitHub Security Integration
- name: ShellCheck
run: |
shellcheck --format json scripts/*.sh | \
python3 -c "
import json, sys
results = json.load(sys.stdin)
sarif = {
'version': '2.1.0',
'runs': [{
'tool': {'driver': {'name': 'ShellCheck'}},
'results': [
{
'ruleId': str(r['code']),
'message': {'text': r['message']},
'locations': [{
'physicalLocation': {
'artifactLocation': {'uri': r['file']},
'region': {'startLine': r['line'], 'startColumn': r['column']}
}
}]
} for r in results
]
}]
}
print(json.dumps(sarif))
" > shellcheck.sarif || true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: shellcheck.sarifUsing ShellCheck Directives
Suppress specific warnings inline when you know better:
# shellcheck disable=SC2086
# Intentionally unquoted — we want word splitting here
for file in $GLOB_PATTERN; do
process "$file"
done
# Or suppress for a specific line
echo $version # shellcheck disable=SC2086Prefer targeted disables over blanket file-level suppresses. File-level suppression hides real bugs:
# Don't do this (hides all warnings)
# shellcheck disable=all
# Do this (documents why this specific rule is suppressed)
# shellcheck disable=SC2016 # Single quotes intentional — not a variable
echo '${LITERAL_DOLLAR_VAR}'Testing Shell Scripts with BATS
ShellCheck finds static issues. For behavioral testing of shell scripts, use BATS (Bash Automated Testing System):
# Install BATS
npm install -g bats
# or
brew install bats-core
# tests/deploy.bats
@test "deploy creates target directory" {
TARGET_DIR=$(mktemp -d)
run bash deploy.sh --target "$TARGET_DIR"
[ "$status" -eq 0 ]
[ -d "$TARGET_DIR/app" ]
rm -rf "$TARGET_DIR"
}
@test "deploy fails gracefully on missing config" {
run bash deploy.sh --config /nonexistent/config.yaml
[ "$status" -ne 0 ]
[[ "$output" == *"Config file not found"* ]]
}Combine ShellCheck (static analysis) with BATS (behavioral testing) in CI:
- name: ShellCheck
run: shellcheck scripts/*.sh
- name: BATS tests
run: bats tests/*.batsShellCheck in Pre-commit Hooks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
args: [--severity=warning]Connecting Shell Script Quality to System Testing
ShellCheck validates your deployment and automation scripts. But knowing scripts are syntactically correct doesn't tell you if your deployment actually worked — if the service started, if it's responding to requests, if the health checks pass.
HelpMeTest provides post-deployment monitoring that picks up where script validation leaves off. After your validated shell scripts deploy your service, HelpMeTest verifies the deployment succeeded and the service is healthy.
Summary
- ShellCheck's most common finding is SC2086 — always quote variable references
set -euo pipefailat the top of every script is a ShellCheck best practice and safe default- SC2155 is easy to miss — declare
localand assign separately to capture exit codes - SARIF format integrates ShellCheck findings into GitHub Security tab
- Combine with BATS for behavioral testing — static analysis + runtime behavior
- Inline
# shellcheck disable=SCdirectives are preferred over file-level suppression