Shell Script Standards

February 22, 2026 ยท View on GitHub

Purpose: Standardized shell scripting conventions for this repository.

Scope

This document covers: bash compatibility, shellcheck integration, error handling, input validation, logging, and security patterns.

Related:


Quick Reference

StandardValueValidation
ShellBash 4.0+bash --version
Shebang#!/usr/bin/env bashFirst line of script
Flagsset -eEuo pipefailLine 2 or 3
Lintershellcheck.shellcheckrc at repo root

Required Patterns

Shebang and Flags

Every shell script MUST start with:

#!/usr/bin/env bash
set -eEuo pipefail

Why:

  • #!/usr/bin/env bash - Finds bash in PATH (works on macOS and Linux)
  • set -e - Exit on error
  • set -E - ERR trap is inherited by shell functions
  • set -u - Exit on undefined variable
  • set -o pipefail - Fail if any command in a pipe fails

Variable Quoting

# GOOD - Quoted variables, safe defaults
namespace="${NAMESPACE:-default}"
kubectl get pods -n "${namespace}"

# BAD - Unquoted variables (word splitting, globbing risks)
kubectl get pods -n $namespace

Shellcheck Integration

All scripts must pass shellcheck validation:

shellcheck scripts/*.sh

Repository Configuration

Create .shellcheckrc at repo root:

# .shellcheckrc
# Can't follow non-constant source
disable=SC1090
# Not following sourced files
disable=SC1091
# Consider invoking separately (pipefail handles this)
disable=SC2312

Common Shellcheck Fixes

IssueFix
SC2086 (word splitting)Quote variables: "$var"
SC2164 (cd can fail)`cd /path
SC2046 (word splitting in $())Quote: "$(command)"
SC2181 (checking $?)Use if command; then directly

Disable Rules Sparingly

Only disable when truly necessary:

# shellcheck disable=SC2086
# Reason: Word splitting is intentional for flag array
$tool_cmd $flags_array "$input_file"

Error Handling

ERR Trap for Debug Context

Add an ERR trap to provide context on failure:

#!/usr/bin/env bash
set -eEuo pipefail

on_error() {
    local exit_code=$?
    echo "ERROR: Script failed on line $LINENO with exit code $exit_code" >&2
    exit "$exit_code"
}
trap on_error ERR

Exit Code Documentation

Document exit codes in script headers:

#!/usr/bin/env bash
# Usage: ./scripts/my_script.sh <config>
#
# Exit Codes:
#   0 - Success
#   1 - Argument error
#   2 - Missing dependency
#   3 - Configuration error
#   4 - Validation failed
#   5 - User cancelled

set -eEuo pipefail

Cleanup Pattern

#!/usr/bin/env bash
set -eEuo pipefail

# Create temp directory
TMPDIR=$(mktemp -d)

cleanup() {
    rm -rf "$TMPDIR"
}
trap cleanup EXIT

# Your code here - cleanup runs on exit or error

Checking Command Success

# GOOD - Direct conditional
if kubectl get namespace "$ns" &>/dev/null; then
    echo "Namespace exists"
else
    echo "Creating namespace"
    kubectl create namespace "$ns"
fi

# BAD - Capturing exit code (unnecessary)
kubectl get namespace "$ns"
result=$?
if [[ $result -eq 0 ]]; then
    ...
fi

Logging Functions

Use consistent logging functions across scripts:

# Logging functions
log()  { echo "[$(date '+%H:%M:%S')] $*"; }
warn() { echo "[$(date '+%H:%M:%S')] WARNING: $*" >&2; }
err()  { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; }
die()  { err "$*"; exit 1; }

# Usage
log "Processing namespace: ${NAMESPACE}"
warn "Timeout exceeded, retrying..."
die "Required tool 'kubectl' not found"

Colored Output (Optional)

# Color codes (use sparingly)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'  # No Color

log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
log_warning() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
log_error()   { echo -e "${RED}[ERR]${NC} $*" >&2; }

Script Organization

Template Structure

#!/usr/bin/env bash
# ===================================================================
# Script: <name>
# Purpose: <one-line description>
# Usage: ./<script> [args]
#
# Exit Codes:
#   0 - Success
#   1 - Argument error
#   2 - Missing dependency
# ===================================================================

set -eEuo pipefail

# -------------------------------------------------------------------
# Configuration
# -------------------------------------------------------------------

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAMESPACE="${NAMESPACE:-default}"
DEFAULT_TIMEOUT=300

# -------------------------------------------------------------------
# Functions
# -------------------------------------------------------------------

log()  { echo "[$(date '+%H:%M:%S')] $*"; }
err()  { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; }
die()  { err "$*"; exit 1; }

on_error() {
    local exit_code=$?
    err "Script failed on line $LINENO with exit code $exit_code"
    exit "$exit_code"
}
trap on_error ERR

cleanup() {
    rm -rf "$TMPDIR" 2>/dev/null || true
}

validate_args() {
    if [[ $# -lt 1 ]]; then
        echo "Usage: \$0 <required-arg>" >&2
        exit 1
    fi
}

check_dependencies() {
    local missing=()
    for cmd in kubectl jq; do
        if ! command -v "$cmd" &>/dev/null; then
            missing+=("$cmd")
        fi
    done
    if [[ ${#missing[@]} -gt 0 ]]; then
        die "Missing dependencies: ${missing[*]}"
    fi
}

# -------------------------------------------------------------------
# Main
# -------------------------------------------------------------------

main() {
    validate_args "$@"
    check_dependencies

    log "Starting with namespace: $NAMESPACE"

    # Main logic here
}

# Setup
TMPDIR=$(mktemp -d)
trap cleanup EXIT

# Run
main "$@"

Security

Secret Handling

Never pass secrets as CLI arguments - they're visible in ps aux:

# BAD - Secrets visible in process list
kubectl create secret generic my-secret --from-literal=token="$TOKEN"

# GOOD - Pass via stdin
echo "$TOKEN" | kubectl create secret generic my-secret --from-literal=token=-

# GOOD - Use file-based approach
echo "$SECRET" > "$TMPDIR/secret"
chmod 600 "$TMPDIR/secret"
kubectl create secret generic my-secret --from-file=token="$TMPDIR/secret"

Input Validation

Kubernetes Resource Names (RFC 1123)

validate_namespace() {
    local ns="\$1"
    if [[ ! "$ns" =~ ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ ]] && \
       [[ ! "$ns" =~ ^[a-z0-9]$ ]]; then
        die "Invalid namespace format: $ns (must be RFC 1123 label)"
    fi
}

Path Traversal Prevention

validate_path() {
    local path="\$1"
    case "$path" in
        *..*)
            die "Path traversal detected: $path"
            ;;
    esac
}

Sed Injection Prevention

User input in sed replacement strings can have special meaning:

# BAD - Injection possible with special characters
NAME="test&id"  # & has special meaning in sed
sed "s/{{NAME}}/$NAME/g" template.txt  # Inserts command result!

# GOOD - Escape special characters
escape_sed_replacement() {
    printf '%s' "\$1" | sed -e 's/[&/\]/\\&/g'
}

escaped_name=$(escape_sed_replacement "$NAME")
sed "s/{{NAME}}/$escaped_name/g" template.txt

JSON Construction

Use jq for safe JSON construction:

# BAD - String interpolation (injection risk)
json="{\"name\": \"$NAME\", \"value\": \"$VALUE\"}"

# GOOD - Use jq for proper escaping
json=$(jq -n --arg name "$NAME" --arg value "$VALUE" \
    '{name: $name, value: $value}')

Common Patterns

Polling with Timeout

wait_for_condition() {
    local timeout=${1:-300}
    local interval=${2:-10}
    local condition_cmd="${3}"

    local elapsed=0
    while ! eval "$condition_cmd" &>/dev/null; do
        if [[ $elapsed -ge $timeout ]]; then
            err "Timeout waiting for condition after ${timeout}s"
            return 1
        fi
        log "Waiting... (${elapsed}s/${timeout}s)"
        sleep "$interval"
        elapsed=$((elapsed + interval))
    done
    return 0
}

# Usage
wait_for_condition 300 10 "kubectl get pod my-pod -o jsonpath='{.status.phase}' | grep -q Running"

Parallel Execution with Background Jobs

run_parallel() {
    local pids=()
    local failures=()

    for item in "$@"; do
        process_item "$item" &
        pids+=($!)
    done

    for pid in "${pids[@]}"; do
        if ! wait "$pid"; then
            failures+=("$pid")
        fi
    done

    if [[ ${#failures[@]} -gt 0 ]]; then
        err "Failed jobs: ${#failures[@]}"
        return 1
    fi
}

Kubernetes Resource Checks

# Check if resource exists
resource_exists() {
    local kind="\$1"
    local name="\$2"
    local ns="${3:-}"

    local ns_flag=""
    [[ -n "$ns" ]] && ns_flag="-n $ns"

    # shellcheck disable=SC2086
    kubectl get "$kind" "$name" $ns_flag &>/dev/null
}

# Usage
if resource_exists deployment my-app my-namespace; then
    log "Deployment exists"
fi

Testing

Manual Testing

# Test with shellcheck
shellcheck ./script.sh

# Test with bash
bash ./script.sh --help

# Test with set -x for debugging
bash -x ./script.sh

BATS Framework (Optional)

For complex scripts, use BATS for automated testing:

# test/test_script.bats
#!/usr/bin/env bats

@test "script requires argument" {
    run ./script.sh
    [ "$status" -eq 1 ]
    [[ "$output" =~ "Usage:" ]]
}

@test "script validates namespace format" {
    run ./script.sh --namespace "INVALID_NS"
    [ "$status" -eq 1 ]
    [[ "$output" =~ "Invalid namespace" ]]
}

Common Errors

SymptomCauseFix
unbound variableUsing unset variableAdd default: ${VAR:-default}
command not foundMissing dependencyAdd to check_dependencies()
syntax error near unexpected tokenMissing quote or semicolonCheck quoting, line endings
Script works locally, fails in CIDifferent bash versionCheck bash --version, use portable syntax
permission deniedScript not executablechmod +x script.sh
bad substitutionUsing bash-only syntax in shUse #!/usr/bin/env bash
Word splitting issuesUnquoted variableQuote: "${var}"
No such file or directoryWrong path, spaces in pathQuote paths, check existence

Anti-Patterns

NamePatternWhy BadInstead
Parsing ls Outputfor f in $(ls)Breaks on spaces, special charsfor f in * or find
Cat Abusecat file | grepUseless use of catgrep pattern file
Backticks`command`Hard to nest, escape$(command)
No Error HandlingMissing set -eSilent failuresAdd set -eEuo pipefail
Secrets in Arguments--password="secret"Visible in ps auxUse stdin or temp file
Hardcoded Paths/home/user/fileBreaks on other machinesUse variables, $HOME
No ShellcheckSkipping lintingBugs, security issuesRun shellcheck always
eval Abuseeval "$user_input"Command injectionAvoid eval, validate input

AI Agent Guidelines

When AI agents write shell scripts for this repo:

GuidelineRationale
ALWAYS add set -eEuo pipefailFail-fast on errors
ALWAYS run shellcheck before committingCatches bugs and security issues
ALWAYS quote variablesPrevents word splitting, globbing
ALWAYS document exit codes in headerUsers know what errors mean
NEVER use eval with user inputCommand injection risk
NEVER pass secrets as CLI argumentsVisible in process list
PREFER [[ ]] over [ ]Safer, more features
PREFER $(command) over backticksNestable, readable
PREFER functions over inline codeTestable, reusable

Summary

Key Takeaways:

  1. Bash 4.0+ with set -eEuo pipefail
  2. All scripts must pass shellcheck
  3. Quote all variables: "${var}"
  4. Use logging functions: log, warn, err, die
  5. Add ERR trap for debug context
  6. Never pass secrets as CLI arguments
  7. Use jq for JSON construction
  8. Validate all user input
  9. Document exit codes in script headers
  10. Check Common Errors table for troubleshooting
  11. Avoid named Anti-Patterns