Practical Script Design

🏗️ Topic 12 — Practical Script Design

The previous eleven chapters gave you the language. This final chapter is about the craft — the decisions and patterns that separate a script you wrote once and never want to touch again from one you are confident running in production. We cover argument parsing, configuration management, idempotency, locking, output design, modular organisation, and testing. The chapter closes with a complete, fully-annotated real-world script that draws on every major topic in the course.

1 — Argument Parsing

Scripts that go beyond a single required argument need proper option parsing. There are two good approaches: the built-in getopts (short flags only) and a manual while/case loop (short and long flags).

getopts — built-in short option parser

🐧 getopts — handles -v, -o file, -vn etc.
#!/usr/bin/env bash set -euo pipefail verbose=0 output="output.txt" dry_run=0 usage() { cat <<EOF Usage: $(basename "$0") [OPTIONS] INPUT_FILE Options: -v Enable verbose output -o FILE Write output to FILE (default: output.txt) -n Dry run — show what would happen without doing it -h Show this help EOF exit "${1:-0}" } # getopts string: each letter is a flag; a colon after means it takes an argument while getopts "vno:h" opt; do case "$opt" in v) verbose=1 ;; n) dry_run=1 ;; o) output="$OPTARG" ;; h) usage ;; *) usage 2 ;; esac done shift $(( OPTIND - 1 )) # remove parsed options; $1 is now the first positional arg [[ $# -ge 1 ]] || usage 2 input_file="$1" [[ $verbose -eq 1 ]] && echo "Verbose mode on. Output: $output"
getopts handles combined flags (-vn), option arguments with or without a space (-o file or -ofile), and -- to end option parsing. It does NOT support long options like --verbose.

Manual while/case — short and long options

🐧 Manual parsing — supports --verbose, --output=FILE, etc.
verbose=0; dry_run=0; output="output.txt" while [[ $# -gt 0 ]]; do case "$1" in -v|--verbose) verbose=1; shift ;; -n|--dry-run) dry_run=1; shift ;; -o|--output) output="${2:?--output requires a value}"; shift 2 ;; --output=*) output="${1#--output=}"; shift ;; # --output=value form -h|--help) usage; exit 0 ;; --) shift; break ;; # end of options -*) echo "Unknown option: $1" >&2; exit 2 ;; *) break ;; # first non-option arg esac done # Remaining positional arguments are in "$@"

2 — Configuration Management

Well-designed scripts read configuration from multiple sources in a defined precedence order: built-in defaults are overridden by a config file, which is overridden by environment variables, which are overridden by command-line flags. This makes scripts flexible without being fragile.

🐧 Configuration precedence pattern
#!/usr/bin/env bash # ── 1. Hard-coded defaults ──────────────────────────────────── DB_HOST="localhost" DB_PORT="5432" DB_NAME="myapp" LOG_LEVEL="INFO" BACKUP_DIR="/var/backups/myapp" # ── 2. Load config file (if it exists) ─────────────────────── CONFIG_FILE="${CONFIG_FILE:-/etc/myapp/myapp.conf}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" fi # ── 3. Environment variables override config file ───────────── # (Already set in environment — no action needed if we used # the same variable names, since sourcing the config file # would override env vars. Use a different naming convention:) DB_HOST="${MYAPP_DB_HOST:-$DB_HOST}" DB_PORT="${MYAPP_DB_PORT:-$DB_PORT}" LOG_LEVEL="${MYAPP_LOG_LEVEL:-$LOG_LEVEL}" # ── 4. Command-line flags override everything (parsed earlier) ─ # (already set by getopts/while-case above) # ── Validate required configuration ────────────────────────── [[ -n "$DB_HOST" ]] || die "DB_HOST is not set" [[ "$DB_PORT" =~ ^[0-9]+$ ]] || die "DB_PORT must be numeric: $DB_PORT"
Convention: use APPNAME_VARNAME for environment variables (e.g. MYAPP_DB_HOST) to avoid clashing with system variables. Inside the script, use shorter local names. Document the full list of supported environment variables in the --help output.

3 — Idempotency

An idempotent script produces the same result whether it has been run once or ten times. This is essential for deployment scripts, cron jobs, and anything that might be retried after failure. The golden rule: check before you act.

🐧 Idempotent patterns
# ── Creating files and directories ─────────────────────────── mkdir -p /etc/myapp/conf.d # -p: no error if already exists [[ -f /etc/myapp/default.conf ]] \ || cp default.conf /etc/myapp/ # only copy if not there yet # ── Installing packages ─────────────────────────────────────── # Bad: always runs dpkg apt-get install -y nginx # Better: skip if already installed dpkg -s nginx >/dev/null 2&1 || apt-get install -y nginx # ── Adding a line to a file (only once) ─────────────────────── line="export PATH=\$PATH:/opt/myapp/bin" grep -qxF "$line" ~/.bashrc || echo "$line" >> ~/.bashrc # ── Creating a symlink ──────────────────────────────────────── ln -sf /opt/myapp/bin/myapp /usr/local/bin/myapp # -f: replace if exists # ── Conditional database migration ─────────────────────────── schema_version=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY id DESC LIMIT 1") if [[ "$schema_version" -lt 42 ]]; then psql -f migration_042.sql fi

4 — Script Locking

When a script must not run concurrently with itself — a backup job, a queue processor, a cron task — use a lock file. The safest implementation uses flock, which is atomic and automatically releases the lock if the process dies.

🐧 flock — advisory locking
#!/usr/bin/env bash set -euo pipefail LOCK_FILE="/var/run/myapp.lock" # Method 1: flock wraps the entire script (simplest) # Re-execute the script under flock if not already locked [ "${FLOCKER:-}" != "$0" ] && \ exec env FLOCKER="$0" flock -en "$LOCK_FILE" "$0" "$@" || \ { echo "Already running — exiting" >&2; exit 1; } # Method 2: open a file descriptor to the lock file exec 200<>"$LOCK_FILE" # open fd 200 for read+write flock -n 200 || { echo "Another instance is running (PID: $(cat "$LOCK_FILE"))" >&2 exit 1 } # Write our PID to the lock file so others can identify us echo $$ >&200 # Lock is released automatically when fd 200 closes at script exit # No explicit unlock needed — even if the script crashes # Portable fallback (no flock): mkdir is atomic on most filesystems LOCK_DIR="/tmp/myapp.lock" if ! mkdir "$LOCK_DIR" 2>/dev/null; then echo "Script already running" >&2; exit 1 fi trap 'rmdir "$LOCK_DIR"' EXIT

5 — Output Design

Well-designed output makes scripts easy to use interactively and easy to parse in automation. The key principles: write progress/status to stderr, write data to stdout; detect whether output is a terminal before adding colour; and give users a --quiet mode when the script is used in pipelines.

Detecting terminal and colour support

🐧 Colour output that degrades gracefully
# Only use colours when stderr is a real terminal if [[ -t 2 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED=""; GREEN=""; YELLOW=""; BLUE=""; BOLD=""; RESET="" fi # -t N: true if file descriptor N is open and is a terminal # -t 1 → stdout is a terminal # -t 2 → stderr is a terminal info() { printf "${GREEN}✓${RESET} %s\n" "$*" >&2; } warn() { printf "${YELLOW}⚠${RESET} %s\n" "$*" >&2; } error() { printf "${RED}✗${RESET} %s\n" "$*" >&2; } heading() { printf "\n${BOLD}%s${RESET}\n" "$*" >&2; }

A simple spinner for long-running tasks

🐧 Spinner that runs while a background job works
spinner() { local pid=$1 local msg="${2:-Working...}" local frames=( '⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏' ) local i=0 while kill -0 "$pid" 2>/dev/null; do printf "\r %s %s" "${frames[$i]}" "$msg" >&2 i=$(( (i + 1) % ${#frames[@]} )) sleep 0.1 done printf "\r \033[32m✓\033[0m %s\n" "$msg" >&2 } # Usage: run something in the background, spin while it works # heavy_command arg1 arg2 & # spinner $! "Compressing archive..." # wait $! # pick up its exit code example_usage() { sleep 3 & # simulate a long operation spinner $! "Backing up database..." wait $! }

Prompting for confirmation

🐧 Confirmation prompts and non-interactive mode
force=0 # set with --force / -f flag confirm() { local msg="${1:-Are you sure?}" # Skip prompt in non-interactive mode or when --force is set [[ $force -eq 1 ]] && return 0 [[ ! -t 0 ]] && { echo "Non-interactive mode — use --force to proceed" >&2; return 1; } read -r -p "${msg} [y/N] " reply [[ "$reply" == [yY] ]] } if confirm "Delete all logs in /var/log/myapp?"; then rm -rf /var/log/myapp/*.log info "Logs deleted" else info "Aborted" fi

6 — Modular Organisation

Once a collection of scripts shares common functions — logging, config loading, output helpers — extract them into library files and source them. This eliminates copy-paste drift and makes the shared code testable in isolation.

🐧 Recommended project layout
myapp/ ├── bin/ │ ├── backup.sh # entry-point scripts │ ├── deploy.sh │ └── health_check.sh ├── lib/ │ ├── log.sh # shared libraries │ ├── config.sh │ └── utils.sh ├── tests/ │ ├── test_utils.bats # BATS test files │ └── test_config.bats └── myapp.conf.example
🐧 Locating and sourcing library files reliably
#!/usr/bin/env bash # bin/deploy.sh # Find the script's own directory regardless of how it was invoked readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly LIB_DIR="${SCRIPT_DIR}/../lib" # Source libraries — use a guard so they can be sourced multiple times safely source "${LIB_DIR}/log.sh" source "${LIB_DIR}/config.sh" source "${LIB_DIR}/utils.sh" # The guard pattern inside each library file: # [[ -n "${_LOG_LOADED:-}" ]] && return # readonly _LOG_LOADED=1 # ... function definitions ...

7 — Testing Bash Scripts

The best tool for testing Bash is BATS (Bash Automated Testing System). Tests are written as plain Bash with a thin assertion layer on top. Even without BATS, well-structured scripts can be tested with a few conventions.

🐧 BATS test structure
#!/usr/bin/env bats # tests/test_utils.bats # Install: npm install -g bats OR apt install bats # Run: bats tests/ # Load the library to test setup() { source "${BATS_TEST_DIRNAME}/../lib/utils.sh" } # Each @test block is one test case @test "is_integer: accepts valid integers" { run bash -c 'source lib/utils.sh; is_integer 42 && echo yes' [ "$status" -eq 0 ] [ "$output" = "yes" ] } @test "is_integer: rejects strings" { run bash -c 'source lib/utils.sh; is_integer "hello"' [ "$status" -eq 1 ] } @test "slugify: converts spaces to hyphens" { run bash -c 'source lib/utils.sh; slugify "Hello World"' [ "$output" = "hello-world" ] } @test "backup creates output file" { local tmpdir tmpdir=$(mktemp -d) run ./bin/backup.sh --output "$tmpdir" ./fixtures/sample.txt [ "$status" -eq 0 ] [ -f "${tmpdir}/sample.txt.bak" ] rm -rf "$tmpdir" }
Design for testability: keep logic in functions, not at the top level. Use a main() function called at the very bottom of the script. This lets you source the script in a test to load the functions without executing them, exactly as you would with any library file.

8 — A Complete Real-World Script

This script ties together all twelve topics. It performs a configurable, logged, idempotent database backup with rotation — the kind of thing you would actually schedule in cron.

db_backup.sh ~180 lines · strict mode · trap · logging · config · locking · idempotency · rotation
#!/usr/bin/env bash # ============================================================= # db_backup.sh — PostgreSQL database backup with rotation # Usage: ./db_backup.sh [OPTIONS] # # Environment variables (override config file): # BACKUP_DB_HOST DB_USER DB_NAME BACKUP_DIR # BACKUP_KEEP_DAYS LOG_LEVEL LOG_FILE # ============================================================= set -euo pipefail IFS=$'\n\t' # ── Script metadata ─────────────────────────────────────────── readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly TIMESTAMP="$(date '+%Y%m%d_%H%M%S')" # ── Defaults ────────────────────────────────────────────────── DB_HOST="localhost" DB_PORT="5432" DB_USER="postgres" DB_NAME="myapp" BACKUP_DIR="/var/backups/db" KEEP_DAYS="7" LOG_LEVEL="INFO" LOG_FILE="" COMPRESS="1" LOCK_FILE="/tmp/${SCRIPT_NAME}.lock" # ── Colour setup ────────────────────────────────────────────── if [[ -t 2 ]]; then R='\033[31m' G='\033[32m' Y='\033[33m' B='\033[1m' X='\033[0m' else R="" G="" Y="" B="" X="" fi # ── Logging ─────────────────────────────────────────────────── declare -A _LL=( [DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3 ) _log() { local lvl="$1"; shift [[ "${_LL[$lvl]:-0}" -lt "${_LL[$LOG_LEVEL]:-1}" ]] && return local ts="$(date '+%H:%M:%S')" local col case "$lvl" in DEBUG) col='\033[36m' ;; INFO) col="$G" ;; WARN) col="$Y" ;; ERROR) col="$R" ;; esac printf "${col}[%s][%s]${X} %s\n" "$ts" "$lvl" "$*" >&2 [[ -n "$LOG_FILE" ]] && \ printf "[%s][%s] %s\n" "$ts" "$lvl" "$*" >> "$LOG_FILE" } log_debug() { _log DEBUG "$@"; } log_info() { _log INFO "$@"; } log_warn() { _log WARN "$@"; } log_error() { _log ERROR "$@"; } die() { log_error "$*"; exit 1; } # ── Cleanup / trap ──────────────────────────────────────────── TMPDIR="" cleanup() { local rc=$? [[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR" [[ $rc -ne 0 ]] && log_error "Script exited with code $rc" } trap 'cleanup' EXIT trap 'die "Unexpected error on line $LINENO"' ERR # ── Usage ───────────────────────────────────────────────────── usage() { cat <<EOF Usage: $SCRIPT_NAME [OPTIONS] -H HOST Database host (default: $DB_HOST) -p PORT Database port (default: $DB_PORT) -u USER Database user (default: $DB_USER) -d DB Database name (default: $DB_NAME) -o DIR Backup output directory (default: $BACKUP_DIR) -k DAYS Keep backups for N days (default: $KEEP_DAYS) -n No compression -v Verbose (DEBUG) logging -h Show this help EOF exit "${1:-0}" } # ── Argument parsing ────────────────────────────────────────── while getopts "H:p:u:d:o:k:nvh" opt; do case "$opt" in H) DB_HOST="$OPTARG" ;; p) DB_PORT="$OPTARG" ;; u) DB_USER="$OPTARG" ;; d) DB_NAME="$OPTARG" ;; o) BACKUP_DIR="$OPTARG" ;; k) KEEP_DAYS="$OPTARG" ;; n) COMPRESS="0" ;; v) LOG_LEVEL="DEBUG" ;; h) usage ;; *) usage 2 ;; esac done # ── Apply env var overrides (higher precedence than defaults) ── DB_HOST="${BACKUP_DB_HOST:-$DB_HOST}" DB_USER="${BACKUP_DB_USER:-$DB_USER}" DB_NAME="${BACKUP_DB_NAME:-$DB_NAME}" # ── Require pg_dump ─────────────────────────────────────────── command -v pg_dump >/dev/null 2&1 || die "pg_dump not found — install postgresql-client" # ── Locking ─────────────────────────────────────────────────── exec 200<>"$LOCK_FILE" flock -n 200 || die "Another backup is already running (lock: $LOCK_FILE)" echo $$ >&200 # ── Main backup function ─────────────────────────────────────── do_backup() { log_info "Starting backup of ${DB_NAME} @ ${DB_HOST}:${DB_PORT}" # Idempotent: create output directory if it doesn't exist mkdir -p "$BACKUP_DIR" TMPDIR=$(mktemp -d) local dump_file="${TMPDIR}/${DB_NAME}_${TIMESTAMP}.sql" local final_file="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql" # Run pg_dump — pass password via .pgpass or PGPASSWORD env var log_debug "Running pg_dump to $dump_file" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" \ -Fp --no-password "$DB_NAME" > "$dump_file" # Optionally compress if [[ $COMPRESS -eq 1 ]]; then log_debug "Compressing..." gzip "$dump_file" dump_file="${dump_file}.gz" final_file="${final_file}.gz" fi # Atomic move to final location mv "$dump_file" "$final_file" local size size=$(du -sh "$final_file" | cut -f1) log_info "Backup saved: $final_file ($size)" } # ── Rotation function ───────────────────────────────────────── rotate_backups() { log_info "Removing backups older than ${KEEP_DAYS} days..." local count=0 while IFS= read -r -d '' f; do log_debug "Removing: $f" rm "$f" (( count++ )) done <(find "$BACKUP_DIR" -name "${DB_NAME}_*.sql*" \ -type f -mtime +"${KEEP_DAYS}" -print0) [[ $count -gt 0 ]] && log_info "Removed $count old backup(s)" [[ $count -eq 0 ]] && log_debug "No old backups to remove" } # ── Entry point ─────────────────────────────────────────────── main() { log_info "=== $SCRIPT_NAME started ===" do_backup rotate_backups log_info "=== $SCRIPT_NAME finished ===" } main "$@"

9 — The Ten Commandments of Bash Scripting

  • I
    Always start with strict mode. Every non-trivial script begins with set -euo pipefail and IFS=$'\n\t'. Silent failures are the most dangerous bugs.
  • II
    Quote all variable expansions. Write "$var" and "${array[@]}" everywhere. Unquoted expansions break on spaces and trigger unexpected glob expansion.
  • III
    Use [[ ]], not [ ]. Double brackets handle empty variables gracefully, support =~ regex matching, and never word-split or pathname-expand their operands.
  • IV
    Trap EXIT for cleanup. Create temp files with mktemp and register their removal immediately: trap 'rm -rf "$TMPDIR"' EXIT. Never rely on reaching the end of the script.
  • V
    Never do local var=$(cmd). The local builtin masks the exit code of the substitution. Declare local var first, then assign var=$(cmd) on the next line.
  • VI
    Write to stderr, pipe data through stdout. Log messages, progress, and errors all go to &2. Only actual output data goes to stdout — so your script can be used in pipelines.
  • VII
    Validate inputs at the top. Check all arguments, files, and dependencies with command -v, [[ -f ]], and regex validation before doing any real work.
  • VIII
    Design for idempotency. Ask: "what happens if this runs twice?" Use mkdir -p, ln -sf, grep -qxF … || echo …. A re-run should be safe.
  • IX
    Run ShellCheck before committing. It catches quoting bugs, masked failures, portability issues, and dozens of subtle traps that even experienced scripters miss. Make it part of your CI pipeline.
  • X
    Wrap logic in functions, call main "$@" at the bottom. This makes scripts sourceable, testable, and readable. The top level should contain only declarations and a single call to main.

10 — Quick Reference

Pattern / ToolWhat it's forNotes
getopts "vo:h" optShort option parsingAfter loop: shift $((OPTIND-1))
while/case "$1"Long + short option parsingHandle --opt=val with ${1#--opt=}
${VAR:-default}Config precedence — fall through to defaultChain: CLI → env var → config file → default
[[ -f "$f" ]] || cmdIdempotent file creation guardDo the action only if the outcome isn't already there
grep -qxF "line" file || echo "line" >> fileIdempotent line append-x whole line, -F literal, -q silent
flock -n 200Prevent concurrent runsReleased automatically when the process ends
mkdir "$LOCK_DIR" 2>/dev/nullPortable atomic lock (no flock)Trap rmdir "$LOCK_DIR" on EXIT
[[ -t 2 ]]Test if stderr is a terminalUse to suppress colour codes in scripts/pipes
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)Reliable self-locationWorks regardless of how the script is called
readonly VAR=valPrevent accidental overwriteGood for SCRIPT_NAME, SCRIPT_DIR, TIMESTAMP
bats tests/Run BATS test suiteInstall: apt install bats / brew install bats-core
shellcheck script.shStatic analysisNon-negotiable — run on every script

✏️ Exercises

These final exercises ask you to design complete, production-quality scripts. Each one deliberately spans multiple topics from the course.

Exercise 1
Write a script called setup_project.sh that bootstraps a new project directory. It should accept a project name as an argument (validated as lowercase letters, digits, and hyphens only), create a standard directory structure (src/, tests/, docs/, scripts/), generate a .gitignore, a README.md with the project name, and an initial scripts/run.sh. The script must be fully idempotent — running it twice in the same directory should not overwrite existing files or produce errors.
Hint: validate the project name with [[ =~ ^[a-z][a-z0-9-]+$ ]]. Use mkdir -p for directories. For files, write a helper: create_file_if_missing() { [[ -f "$1" ]] && return; cat > "$1" <<'EOF' ... EOF }. Add strict mode, trap, and a main() function.
Sample Solution
#!/usr/bin/env bash # setup_project.sh — usage: ./setup_project.sh PROJECT-NAME set -euo pipefail die() { printf '\033[31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; } info() { printf '\033[32m ✓\033[0m %s\n' "$*" >&2; } skip() { printf '\033[33m –\033[0m %s (already exists)\n' "$*" >&2; } create_file_if_missing() { local path="$1" if [[ -f "$path" ]]; then skip "$path" else cat > "$path" # content piped in from caller info "$path" fi } main() { local name="${1:?Usage: $0 <project-name>}" [[ "$name" =~ ^[a-z][a-z0-9-]+$ ]] \ || die "Invalid name '$name'. Use lowercase letters, digits, hyphens only." printf '\n\033[1mSetting up project: %s\033[0m\n\n' "$name" # Directories (idempotent — mkdir -p) for dir in src tests docs scripts; do if [[ -d "$dir" ]]; then skip "$dir/" else mkdir -p "$dir"; info "$dir/"; fi done # .gitignore create_file_if_missing .gitignore <<'EOF' *.log *.tmp .env dist/ EOF # README.md create_file_if_missing README.md <<EOF # $name Project description goes here. ## Getting started \`\`\`bash ./scripts/run.sh \`\`\` EOF # scripts/run.sh create_file_if_missing scripts/run.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail echo "Running..." EOF chmod +x scripts/run.sh printf '\n\033[1mDone!\033[0m Project "%s" is ready.\n\n' "$name" } main "$@"
Exercise 2
Write a script called monitor.sh that runs continuously, checks disk usage on a configurable mount point every N seconds, and sends an alert (prints a coloured warning to stderr and appends to a log file) when usage exceeds a configurable threshold percentage. Support --mount, --threshold, --interval, and --log-file options. The script should handle Ctrl+C cleanly (print a summary of how many checks were run and how many alerts were triggered), and must not run two instances simultaneously.
Hint: parse disk usage with df --output=pcent MOUNT | tail -1 | tr -d ' %'. Store check/alert counts in variables incremented inside a while true; do ... sleep "$interval"; done loop. Use trap 'print_summary; exit 0' INT TERM. Use flock or a lock directory to prevent concurrent runs.
Sample Solution
#!/usr/bin/env bash # monitor.sh — disk usage monitor set -uo pipefail # no -e: we handle errors in the loop ourselves MOUNT="/"; THRESHOLD="80"; INTERVAL="60"; LOG_FILE="/tmp/disk_monitor.log" LOCK_DIR="/tmp/monitor_$$.lock" # per-mount locking via mktemp would be cleaner while [[ $# -gt 0 ]]; do case "$1" in --mount) MOUNT="$2"; shift 2 ;; --threshold) THRESHOLD="$2"; shift 2 ;; --interval) INTERVAL="$2"; shift 2 ;; --log-file) LOG_FILE="$2"; shift 2 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done # Locking if ! mkdir "$LOCK_DIR" 2>/dev/null; then echo "monitor.sh already running" >&2; exit 1 fi checks=0; alerts=0 log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } print_summary() { printf '\n\033[1mMonitor stopped.\033[0m Checks: %d | Alerts: %d\n' \ "$checks" "$alerts" >&2 rmdir "$LOCK_DIR" } trap 'print_summary; exit 0' INT TERM EXIT log "Starting monitor: mount=$MOUNT threshold=${THRESHOLD}% interval=${INTERVAL}s" \ | tee -a "$LOG_FILE" >&2 while true; do local usage usage=$(df --output=pcent "$MOUNT" | tail -1 | tr -d ' %') (( checks++ )) if (( usage >= THRESHOLD )); then (( alerts++ )) local msg msg="ALERT: $MOUNT is at ${usage}% (threshold: ${THRESHOLD}%)" printf '\033[31m%s\033[0m\n' "$(log "$msg")" >&2 log "$msg" >> "$LOG_FILE" else log "OK: $MOUNT is at ${usage}%" | tee -a "$LOG_FILE" >&2 fi sleep "$INTERVAL" done
Exercise 3 — Capstone
Write a script called release.sh that automates a software release process. It should: (1) accept a version string as an argument, validated as vMAJOR.MINOR.PATCH (e.g. v1.4.2); (2) check that the git working tree is clean; (3) run tests (simulate with a function that may pass or fail); (4) bump the version number in a version.txt file; (5) create a git tag; (6) build a release archive (tar.gz of the src/ directory); (7) log every step with timestamps; and (8) support a --dry-run mode that shows exactly what would happen without making any changes.
Hint: create a run_step() function that takes a description and a command. In dry-run mode it prints the command prefixed with [DRY-RUN] instead of running it. Use git status --porcelain to check for uncommitted changes. Use git tag -a "$version" -m "Release $version" for tagging.
Sample Solution
#!/usr/bin/env bash # release.sh — usage: ./release.sh [--dry-run] vMAJOR.MINOR.PATCH set -euo pipefail dry_run=0 version="" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) dry_run=1; shift ;; -*) echo "Unknown option: $1" >&2; exit 2 ;; *) version="$1"; shift ;; esac done [[ -n "$version" ]] || { echo "Usage: $0 [--dry-run] vMAJOR.MINOR.PATCH" >&2; exit 2; } [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \ || { echo "Invalid version format. Expected: vMAJOR.MINOR.PATCH" >&2; exit 2; } log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; } info() { printf '\033[32m ✓\033[0m %s\n' "$*"; } step() { printf '\n\033[1m▶ %s\033[0m\n' "$*"; } die() { printf '\033[31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; } run() { if [[ $dry_run -eq 1 ]]; then printf '\033[33m [DRY-RUN]\033[0m %s\n' "$*" else "$@" fi } [[ $dry_run -eq 1 ]] && printf '\033[33m[DRY-RUN MODE — no changes will be made]\033[0m\n' log "Starting release: $version" step "1. Check working tree is clean" if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then die "Working tree has uncommitted changes. Commit or stash them first." fi info "Working tree is clean" step "2. Run tests" run_tests() { # Simulate: replace with: bats tests/ or pytest etc. echo " Running test suite..." sleep 1 # (( RANDOM % 5 == 0 )) && { echo "Tests FAILED" >&2; return 1; } echo " All tests passed." } run run_tests || die "Tests failed — aborting release" info "Tests passed" step "3. Bump version in version.txt" run bash -c "echo '$version' > version.txt" info "version.txt → $version" step "4. Commit version bump" run git add version.txt run git commit -m "chore: bump version to $version" info "Committed" step "5. Create git tag" run git tag -a "$version" -m "Release $version" info "Tagged: $version" step "6. Build release archive" archive="release-${version}.tar.gz" run tar -czf "$archive" src/ info "Archive: $archive" printf '\n\033[32m\033[1m✓ Release %s complete!\033[0m\n\n' "$version" [[ $dry_run -eq 1 ]] && printf '(No changes were made — dry-run mode was active)\n'