shell scripts

This guide establishes standards for shell scripts in the lintro project to ensure consistency, reliability, and maintainability.

Shell Script Style Guide

This guide establishes standards for shell scripts in the lintro project to ensure consistency, reliability, and maintainability.

Standard Preamble

Every shell script must start with this preamble:

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

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../utils/utils.sh"  # Adjust path as needed

Explanation

  • #!/usr/bin/env bash: Portable shebang that finds bash in PATH
  • set -e: Exit immediately if a command fails
  • set -u: Treat unset variables as errors
  • set -o pipefail: Pipeline fails if any command fails
  • SCRIPT_DIR: Reliable way to get the script’s directory
  • source utils.sh: Import shared logging and utility functions

Help Message Pattern

All scripts should support --help and -h flags:

if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
    cat <<'EOF'
Brief description of what the script does.

Usage: script-name.sh <required-arg> [optional-arg]

Arguments:
  required-arg    Description of required argument
  optional-arg    Description of optional argument (default: value)

Environment:
  SOME_VAR        Description of environment variable

Examples:
  script-name.sh foo
  script-name.sh foo bar
EOF
    exit 0
fi

Or use the show_help function from utils.sh:

if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
    show_help "script-name.sh" "Brief description" "<required-arg> [optional-arg]"
    exit 0
fi

Logging Standards

Use the logging functions from utils.sh:

log_info "Starting process..."      # Blue info message
log_success "Operation completed"   # Green success message
log_warning "Something unexpected"  # Yellow warning message
log_error "Operation failed"        # Red error message
log_verbose "Debug details"         # Only shown when VERBOSE=1

When to Use Each Level

LevelUse CaseExample
log_infoProgress updates, status“Processing 50 files…”
log_successCompleted operations“Build completed successfully”
log_warningNon-fatal issues“File not found, using default”
log_errorFailures (usually before exit)“Required tool not installed”
log_verboseDebug info (VERBOSE=1)“Checking file: /path/to/file”

Variable Handling

Required Variables

Use parameter expansion with error messages:

REQUIRED_VAR="${1:?Usage: script.sh <required-arg>}"

Optional Variables with Defaults

OPTIONAL_VAR="${2:-default_value}"

Environment Variables

Document and provide defaults:

# Configuration (can be overridden via environment)
MAX_RETRIES="${MAX_RETRIES:-3}"
TIMEOUT="${TIMEOUT:-30}"

Error Handling

Check Command Success

if ! some_command; then
    log_error "some_command failed"
    exit 1
fi

Cleanup on Exit

Use traps for cleanup:

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

TEMP_DIR=$(mktemp -d)

Or use the create_temp_dir function:

TEMP_DIR=$(create_temp_dir)  # Auto-cleanup on exit

Validate Prerequisites

Check for required tools early:

for cmd in git curl jq; do
    if ! command -v "$cmd" &> /dev/null; then
        log_error "Required command not found: $cmd"
        exit 1
    fi
done

GitHub Actions Integration

Setting Outputs

Use the helper function:

set_github_output "key" "value"

Or directly:

if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
    echo "key=value" >> "$GITHUB_OUTPUT"
fi

Setting Environment Variables

set_github_env "MY_VAR" "my_value"

Multiline Values

{
    echo "key<<EOF"
    echo "line 1"
    echo "line 2"
    echo "EOF"
} >> "$GITHUB_OUTPUT"

Git Configuration for CI

configure_git_ci_user  # Sets github-actions[bot] identity

File Operations

Check File Existence

if [[ -f "$file" ]]; then
    log_info "Processing $file"
else
    log_warning "File not found: $file"
fi

Or use the helper:

check_file_exists "$file" "Configuration file"

Safe File Writing

# Write to temp file first, then move
tmp_file=$(mktemp)
echo "content" > "$tmp_file"
mv "$tmp_file" "$target_file"

Quoting Rules

  1. Always quote variables: "$var" not $var
  2. Quote command substitutions: "$(command)"
  3. Arrays need special handling: "${array[@]}"
# Good
file_path="$HOME/documents/my file.txt"
result="$(some_command "$file_path")"

# Bad
file_path=$HOME/documents/my file.txt
result=$(some_command $file_path)

Conditionals

String Comparisons

if [[ "$var" == "value" ]]; then
    # ...
fi

Numeric Comparisons

if [[ "$count" -gt 10 ]]; then
    # ...
fi

File Tests

[[ -f "$file" ]]  # File exists and is regular file
[[ -d "$dir" ]]   # Directory exists
[[ -x "$cmd" ]]   # File is executable
[[ -n "$var" ]]   # Variable is non-empty
[[ -z "$var" ]]   # Variable is empty

Loops

Iterate Over Files

for file in *.txt; do
    [[ -f "$file" ]] || continue  # Skip if no matches
    process "$file"
done

Read Lines from File

while IFS= read -r line; do
    process "$line"
done < "$file"

Functions

Definition

# Brief description of function
# Arguments:
#   $1 - description
#   $2 - description (optional, default: value)
# Returns:
#   0 on success, 1 on failure
my_function() {
    local arg1="$1"
    local arg2="${2:-default}"

    # Function body
}

Local Variables

Always use local for function variables:

my_function() {
    local result
    result=$(some_command)
    echo "$result"
}

Script Organization

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

# ==============================================================================
# Script Name: my-script.sh
# Description: Brief description
# ==============================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../utils/utils.sh"

# ------------------------------------------------------------------------------
# Constants
# ------------------------------------------------------------------------------
readonly VERSION="1.0.0"
readonly DEFAULT_TIMEOUT=30

# ------------------------------------------------------------------------------
# Functions
# ------------------------------------------------------------------------------
show_usage() {
    # ...
}

main() {
    # Parse arguments
    # Validate prerequisites
    # Execute main logic
}

# ------------------------------------------------------------------------------
# Main
# ------------------------------------------------------------------------------
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
    show_usage
    exit 0
fi

main "$@"

Common Patterns

Retry Logic

retry() {
    local max_attempts="${1:-3}"
    local delay="${2:-5}"
    shift 2
    local attempt=1

    while [[ $attempt -le $max_attempts ]]; do
        if "$@"; then
            return 0
        fi
        log_warning "Attempt $attempt/$max_attempts failed, retrying in ${delay}s..."
        sleep "$delay"
        ((attempt++))
    done

    log_error "All $max_attempts attempts failed"
    return 1
}

# Usage
retry 3 5 curl -sf "$url"

Progress Indicator

total=${#files[@]}
current=0
for file in "${files[@]}"; do
    ((current++))
    log_info "Processing [$current/$total]: $file"
    process "$file"
done

Linting

All scripts should pass shellcheck:

shellcheck scripts/**/*.sh

Common shellcheck directives when needed:

# shellcheck disable=SC2034  # Variable appears unused
# shellcheck source=scripts/utils/utils.sh
source "$SCRIPT_DIR/../utils/utils.sh"

Testing

For complex scripts, consider adding tests:

# scripts/tests/test_my_script.sh
test_function_returns_expected() {
    result=$(my_function "input")
    [[ "$result" == "expected" ]] || {
        echo "FAIL: expected 'expected', got '$result'"
        return 1
    }
    echo "PASS: test_function_returns_expected"
}

test_function_returns_expected