A practical automation project
Requirements and structure, logging and dry-run modes, configuration and environment, idempotent operations, locking to prevent double runs, and packaging a reusable tool.
Structure, logging and dry-run
#!/usr/bin/env bash
set -euo pipefail
readonly PROG=${0##*/}
readonly VERSION=1.2.0
DRY_RUN=false
log() { printf '%s %s %s\n' "$(date -Is)" "$PROG" "$*" >&2; }
warn() { log "WARN: $*"; }
die() { log "ERROR: $*"; exit 1; }
run() {
if $DRY_RUN; then
log "DRY-RUN: $*"
else
log "run: $*"
"$@" || die "command failed: $*"
fi
}
usage() {
cat >&2 <<EOF
$PROG $VERSION
usage: $PROG [-n] [-c FILE] [-v] TARGET
-n dry run: print what would happen
-c FILE configuration file (default: ./deploy.conf)
-v verbose (set -x)
EOF
exit 2
}- Everything human-facing goes to stderr so the script can still be used in a pipeline; only real data goes to stdout.
- One implementation of "do the thing" (
run) is what makes--dry-runhonest — a side effect that bypasses it is a bug. - A version string and a usage message cost nothing and turn a script into a tool other people can adopt.
bin/deploy entry point: arg parsing, then calls lib functions
lib/common.sh log, die, run, config loading
lib/deploy.sh the actual work, one function per step
test/ bats tests and fixtures
README.md usage, configuration, exit codes
deploy.conf.example documented sample configurationConfiguration, idempotency and locking
# precedence: flags > environment > config file > built-in defaults
config_file=./deploy.conf
[[ -f $config_file ]] || config_file=./deploy.conf.example
# shellcheck source=/dev/null
. "$config_file" # the file sets plain variables
env=${DEPLOY_ENV:-staging} # environment overrides the file
max_retries=${MAX_RETRIES:-3}
# idempotent building blocks: check, then act
mkdir -p "$target_dir"
ln -sfn "$release" "$current_link" # -n makes the second run a no-op, not a nested link
grep -qxF "$entry" /etc/hosts || printf '%s\n' "$entry" >> /etc/hosts
# one writer at a time
exec 9>"${TMPDIR:-/tmp}/$PROG.lock"
flock -n 9 || die "another $PROG run is already in progress"
if [[ -f $state_file ]] && [[ $(cat "$state_file") == "$desired" ]]; then
log "already at $desired, nothing to do"
exit 0
fi| Concern | Technique | Failure it prevents |
|---|---|---|
| Two runs at once | flock -n on a descriptor | Half-written releases and duplicated work |
| Re-running after a crash | Check-then-act on every step | Errors on the second run that block recovery |
| Hidden configuration | Documented file plus environment overrides | A script that only its author can run |
| Partial failure | trap cleanup EXIT INT TERM | Leftover temp files and locked resources |
| Silent drift | Log the decision, not just the action | Nobody understanding why a run changed nothing |
Idempotency is the property that makes automation safe to retry. Aim for every step to be either naturally repeatable (mkdir -p, ln -sfn) or guarded by a check that reads the current state first.
Finishing and shipping it
# exit codes your callers can branch on
# 0 success (or nothing to do)
# 1 unexpected runtime failure
# 2 usage error
# 3 another instance is running
# 4 configuration invalid
trap 'status=$?; cleanup; exit "$status"' EXIT INT TERM
trap 'log "failed at line $LINENO" >&2' ERR
if $verbose; then set -x; fi
log "starting $PROG $VERSION env=$env target=$target dry_run=$DRY_RUN"- Ship it as a directory with a stable entry point in
bin/; a single 800-line file is harder to test and review than five labelled ones. - Install by symlinking
bin/deployinto a directory already onPATH, so upgrades do not require re-copying files. - Run the bats tests and ShellCheck in CI before the tool is used for real, and record the run in a log directory that rotates.
- Document the exit codes: they are the script's public API for every cron job and pipeline that calls it.
💡
The mark of a finished automation tool is not that it works when everything is fine, but that a second run is a no-op, a failed run is safe to repeat, and the log explains what was decided rather than only what was executed.
FAQ
How much should a shell script do before it should be rewritten?
When you need data structures, JSON manipulation, concurrency and error types together, a shell wrapper around a small Python or Go program becomes easier to maintain. Keep shell for orchestration and let a real language do the logic.
What should go in the log?
Timestamped decisions with the values that drove them — environment, target, whether a step was skipped because the state already matched. Logging every command is noise; logging why is what you need at 2am.
Related
Process handling, signals and parallelism Testing and linting shell scripts
Last refreshed 2026-09-18.