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
#!/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
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.
#!/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"
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.
# ── 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.
#!/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
# 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() {
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
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.
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
#!/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.
#!/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"
}
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.
#!/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
- IAlways start with strict mode. Every non-trivial script begins with
set -euo pipefailandIFS=$'\n\t'. Silent failures are the most dangerous bugs. - IIQuote all variable expansions. Write
"$var"and"${array[@]}"everywhere. Unquoted expansions break on spaces and trigger unexpected glob expansion. - IIIUse
[[ ]], not[ ]. Double brackets handle empty variables gracefully, support=~regex matching, and never word-split or pathname-expand their operands. - IVTrap EXIT for cleanup. Create temp files with
mktempand register their removal immediately:trap 'rm -rf "$TMPDIR"' EXIT. Never rely on reaching the end of the script. - VNever do
local var=$(cmd). Thelocalbuiltin masks the exit code of the substitution. Declarelocal varfirst, then assignvar=$(cmd)on the next line. - VIWrite 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. - VIIValidate inputs at the top. Check all arguments, files, and dependencies with
command -v,[[ -f ]], and regex validation before doing any real work. - VIIIDesign for idempotency. Ask: "what happens if this runs twice?" Use
mkdir -p,ln -sf,grep -qxF … || echo …. A re-run should be safe. - IXRun 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.
- XWrap 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 tomain.
10 — Quick Reference
| Pattern / Tool | What it's for | Notes |
|---|---|---|
getopts "vo:h" opt | Short option parsing | After loop: shift $((OPTIND-1)) |
while/case "$1" | Long + short option parsing | Handle --opt=val with ${1#--opt=} |
${VAR:-default} | Config precedence — fall through to default | Chain: CLI → env var → config file → default |
[[ -f "$f" ]] || cmd | Idempotent file creation guard | Do the action only if the outcome isn't already there |
grep -qxF "line" file || echo "line" >> file | Idempotent line append | -x whole line, -F literal, -q silent |
flock -n 200 | Prevent concurrent runs | Released automatically when the process ends |
mkdir "$LOCK_DIR" 2>/dev/null | Portable atomic lock (no flock) | Trap rmdir "$LOCK_DIR" on EXIT |
[[ -t 2 ]] | Test if stderr is a terminal | Use to suppress colour codes in scripts/pipes |
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) | Reliable self-location | Works regardless of how the script is called |
readonly VAR=val | Prevent accidental overwrite | Good for SCRIPT_NAME, SCRIPT_DIR, TIMESTAMP |
bats tests/ | Run BATS test suite | Install: apt install bats / brew install bats-core |
shellcheck script.sh | Static analysis | Non-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.
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.[[ =~ ^[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.#!/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 "$@"
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.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.#!/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
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.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.#!/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'