ShellCheck in CI: Automated Shell Script Validation and Testing

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/sh scripts
  • 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 shellcheck

Basic 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/*.sh

Common 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"    # Correct

SC2155 — 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>&1

SC2010 — 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; then

Writing 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 splitting

Use 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
fi

GitHub 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: gcc

The 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.sarif

Using 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=SC2086

Prefer 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/*.bats

ShellCheck 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 pipefail at the top of every script is a ShellCheck best practice and safe default
  • SC2155 is easy to miss — declare local and 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=SC directives are preferred over file-level suppression

Read more

Start now free