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

<span class="hljs-comment"># Ubuntu/Debian
<span class="hljs-built_in">sudo apt install shellcheck

<span class="hljs-comment"># Alpine (great for CI containers)
apk add shellcheck

<span class="hljs-comment"># Via npm (for CI without native install)
npm install -g shellcheck

Basic Usage

# Check a script
shellcheck deploy.sh

<span class="hljs-comment"># Check all shell scripts in directory
shellcheck scripts/*.sh

<span class="hljs-comment"># Specify shell dialect
shellcheck --shell=bash deploy.sh

<span class="hljs-comment"># Output as JSON
shellcheck --format json deploy.sh

<span class="hljs-comment"># 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
<span class="hljs-built_in">rm -rf <span class="hljs-variable">$DEPLOY_DIR

<span class="hljs-comment"># Good
<span class="hljs-built_in">rm -rf <span class="hljs-string">"$DEPLOY_DIR"

This is the most common ShellCheck error. Unquoted variables split on spaces:

DIR="/path with spaces"
<span class="hljs-built_in">ls <span class="hljs-variable">$DIR      <span class="hljs-comment"># ls: /path: no such file; ls: with: no such file; ls: spaces: no such file
<span class="hljs-built_in">ls <span class="hljs-string">"$DIR"    <span class="hljs-comment"># Correct

SC2155 — Declare and assign separately:

# Bad — hides exit code of subshell
<span class="hljs-built_in">local output=$(dangerous_command)

<span class="hljs-comment"># Good — captures exit code properly
<span class="hljs-built_in">local output
output=$(dangerous_command)

SC2046 — Quote to prevent word splitting:

# Bad
<span class="hljs-built_in">command $(get_args)

<span class="hljs-comment"># Good
<span class="hljs-built_in">command <span class="hljs-string">"$(get_args)"

<span class="hljs-comment"># Or use an array
<span class="hljs-built_in">mapfile -t args < <(get_args)
<span class="hljs-built_in">command <span class="hljs-string">"${args[@]}"

SC2164 — Use cd ... || exit:

# Bad — if cd fails, continues in wrong directory
<span class="hljs-built_in">cd /deploy/path
<span class="hljs-built_in">rm -rf *  <span class="hljs-comment"># Dangerous if cd failed!

<span class="hljs-comment"># Good
<span class="hljs-built_in">cd /deploy/path <span class="hljs-pipe">|| <span class="hljs-built_in">exit 1
<span class="hljs-built_in">rm -rf *

SC2001 — Use parameter expansion instead of sed:

# Bad
<span class="hljs-built_in">echo <span class="hljs-string">"$str" <span class="hljs-pipe">| sed <span class="hljs-string">'s/foo/bar/'

<span class="hljs-comment"># Better
<span class="hljs-built_in">echo <span class="hljs-string">"${str/foo/bar}"

SC2069 — Redirect order matters:

# Bad — redirects stderr to stdout, then stdout to /dev/null
<span class="hljs-built_in">command 2>&1 >/dev/null

<span class="hljs-comment"># Good — redirects stdout to /dev/null, then stderr to stdout
<span class="hljs-built_in">command >/dev/null 2>&1

SC2010 — Don't use ls | grep:

# Bad — fragile, breaks with spaces
<span class="hljs-keyword">if <span class="hljs-built_in">ls /dir <span class="hljs-pipe">| grep -q <span class="hljs-string">"pattern"; <span class="hljs-keyword">then

<span class="hljs-comment"># Good
<span class="hljs-keyword">if compgen -G <span class="hljs-string">"/dir/pattern*" > /dev/null; <span class="hljs-keyword">then

Writing Shell Scripts That Pass ShellCheck

Start scripts with safety flags:

#!/bin/bash
<span class="hljs-built_in">set -euo pipefail
<span class="hljs-comment"># -e: exit on error
<span class="hljs-comment"># -u: error on undefined variable
<span class="hljs-comment"># -o pipefail: pipe fails if any component fails

IFS=$<span class="hljs-string">'\n\t'  <span class="hljs-comment"># Safer word splitting

Use arrays instead of space-separated strings:

# Bad — spaces in arguments break this
FILES=<span class="hljs-string">"file1.txt file2.txt file with spaces.txt"
process <span class="hljs-variable">$FILES

<span class="hljs-comment"># Good
FILES=(<span class="hljs-string">"file1.txt" <span class="hljs-string">"file2.txt" <span class="hljs-string">"file with spaces.txt")
process <span class="hljs-string">"${FILES[@]}"

Check return values:

# Bad — silent failure
curl https://api.example.com/data > output.json

<span class="hljs-comment"># Good
<span class="hljs-keyword">if ! curl -f https://api.example.com/data > output.json; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"Failed to fetch data" >&2
    <span class="hljs-built_in">exit 1
<span class="hljs-keyword">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
<span class="hljs-comment"># Intentionally unquoted — we want word splitting here
<span class="hljs-keyword">for file <span class="hljs-keyword">in <span class="hljs-variable">$GLOB_PATTERN; <span class="hljs-keyword">do
    process <span class="hljs-string">"$file"
<span class="hljs-keyword">done

<span class="hljs-comment"># Or suppress for a specific line
<span class="hljs-built_in">echo <span class="hljs-variable">$version  <span class="hljs-comment"># shellcheck disable=SC2086

Prefer targeted disables over blanket file-level suppresses. File-level suppression hides real bugs:

# Don't do this (hides all warnings)
<span class="hljs-comment"># shellcheck disable=all

<span class="hljs-comment"># Do this (documents why this specific rule is suppressed)
<span class="hljs-comment"># shellcheck disable=SC2016  # Single quotes intentional — not a variable
<span class="hljs-built_in">echo <span class="hljs-string">'${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
<span class="hljs-comment"># or
brew install bats-core

<span class="hljs-comment"># tests/deploy.bats
@<span class="hljs-built_in">test <span class="hljs-string">"deploy creates target directory" {
    TARGET_DIR=$(<span class="hljs-built_in">mktemp -d)
    run bash deploy.sh --target <span class="hljs-string">"$TARGET_DIR"
    [ <span class="hljs-string">"$status" -eq 0 ]
    [ -d <span class="hljs-string">"$TARGET_DIR/app" ]
    <span class="hljs-built_in">rm -rf <span class="hljs-string">"$TARGET_DIR"
}

@<span class="hljs-built_in">test <span class="hljs-string">"deploy fails gracefully on missing config" {
    run bash deploy.sh --config /nonexistent/config.yaml
    [ <span class="hljs-string">"$status" -ne 0 ]
    [[ <span class="hljs-string">"$output" == *<span class="hljs-string">"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