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:
- Python Style Guide - Python coding conventions
- Tag Vocabulary - Documentation standards
Quick Reference
| Standard | Value | Validation |
|---|---|---|
| Shell | Bash 4.0+ | bash --version |
| Shebang | #!/usr/bin/env bash | First line of script |
| Flags | set -eEuo pipefail | Line 2 or 3 |
| Linter | shellcheck | .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 errorset -E- ERR trap is inherited by shell functionsset -u- Exit on undefined variableset -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
| Issue | Fix |
|---|---|
| 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
| Symptom | Cause | Fix |
|---|---|---|
unbound variable | Using unset variable | Add default: ${VAR:-default} |
command not found | Missing dependency | Add to check_dependencies() |
syntax error near unexpected token | Missing quote or semicolon | Check quoting, line endings |
| Script works locally, fails in CI | Different bash version | Check bash --version, use portable syntax |
permission denied | Script not executable | chmod +x script.sh |
bad substitution | Using bash-only syntax in sh | Use #!/usr/bin/env bash |
| Word splitting issues | Unquoted variable | Quote: "${var}" |
No such file or directory | Wrong path, spaces in path | Quote paths, check existence |
Anti-Patterns
| Name | Pattern | Why Bad | Instead |
|---|---|---|---|
| Parsing ls Output | for f in $(ls) | Breaks on spaces, special chars | for f in * or find |
| Cat Abuse | cat file | grep | Useless use of cat | grep pattern file |
| Backticks | `command` | Hard to nest, escape | $(command) |
| No Error Handling | Missing set -e | Silent failures | Add set -eEuo pipefail |
| Secrets in Arguments | --password="secret" | Visible in ps aux | Use stdin or temp file |
| Hardcoded Paths | /home/user/file | Breaks on other machines | Use variables, $HOME |
| No Shellcheck | Skipping linting | Bugs, security issues | Run shellcheck always |
| eval Abuse | eval "$user_input" | Command injection | Avoid eval, validate input |
AI Agent Guidelines
When AI agents write shell scripts for this repo:
| Guideline | Rationale |
|---|---|
ALWAYS add set -eEuo pipefail | Fail-fast on errors |
ALWAYS run shellcheck before committing | Catches bugs and security issues |
| ALWAYS quote variables | Prevents word splitting, globbing |
| ALWAYS document exit codes in header | Users know what errors mean |
NEVER use eval with user input | Command injection risk |
| NEVER pass secrets as CLI arguments | Visible in process list |
PREFER [[ ]] over [ ] | Safer, more features |
PREFER $(command) over backticks | Nestable, readable |
| PREFER functions over inline code | Testable, reusable |
Summary
Key Takeaways:
- Bash 4.0+ with
set -eEuo pipefail - All scripts must pass shellcheck
- Quote all variables:
"${var}" - Use logging functions:
log,warn,err,die - Add ERR trap for debug context
- Never pass secrets as CLI arguments
- Use
jqfor JSON construction - Validate all user input
- Document exit codes in script headers
- Check Common Errors table for troubleshooting
- Avoid named Anti-Patterns