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-run honest — 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 configuration

Configuration, 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
ConcernTechniqueFailure it prevents
Two runs at onceflock -n on a descriptorHalf-written releases and duplicated work
Re-running after a crashCheck-then-act on every stepErrors on the second run that block recovery
Hidden configurationDocumented file plus environment overridesA script that only its author can run
Partial failuretrap cleanup EXIT INT TERMLeftover temp files and locked resources
Silent driftLog the decision, not just the actionNobody 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/deploy into a directory already on PATH, 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.

Process handling, signals and parallelism Testing and linting shell scripts

Last refreshed 2026-09-18.