10 · Capstone Project — Deployment Automation Tool¶
This is the capstone for the entire Shell Mastery Path: deploy-tool, a
production-style deployment automation CLI. It ships a Capistrano-style
release layout (releases/<timestamp>/ plus a current symlink), supports
instant rollbacks, and folds in nearly every skill from Levels 1-4 —
subcommand dispatch, structured JSON logging, least-privilege execution
and secrets handling, bats tests, a Dockerfile, and a CI pipeline that
lints and tests it automatically on every push.
What you'll build¶
bin/deploy-tool— a single CLI with four subcommands:deploy,rollback,status, andlogslib/releases.sh— helpers for creating, listing, activating, and pruning releases in the Capistrano-style directory layoutlib/logging.sh— shared structured JSON logging (Module 08), used by every subcommand- A least-privilege guard that refuses to deploy as root, plus a secrets-aware webhook notifier that reads its token from a mode-600 file (Module 09)
tests/deploy-tool.bats— a bats suite exercising deploy, status, rollback, and pruning end-to-end- A
Dockerfilepackaging the tool into a minimal, non-root image - A GitHub Actions pipeline that runs
shellcheckand the bats suite on every push
Project layout¶
deploy-tool/
bin/
deploy-tool
lib/
logging.sh
releases.sh
tests/
deploy-tool.bats
.github/
workflows/
ci.yml
Dockerfile
lib/logging.sh¶
#!/usr/bin/env bash
# lib/logging.sh — structured JSON logging shared by every deploy-tool subcommand
LOG_FILE="${DEPLOY_LOG_FILE:-/var/log/deploy-tool/deploy-tool.log}"
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
_log() {
local level="$1"; shift
local message="$1"
local timestamp
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# one JSON object per line — the same format Module 08 builds up from scratch
printf '{"time":"%s","level":"%s","pid":%d,"msg":"%s"}\n' \
"$timestamp" "$level" "$$" "$message" | tee -a "$LOG_FILE" >&2
}
log_info() { _log "info" "$1"; }
log_warn() { _log "warn" "$1"; }
log_error() { _log "error" "$1"; }
lib/releases.sh¶
#!/usr/bin/env bash
# lib/releases.sh — Capistrano-style release management helpers
#
# Expects DEPLOY_ROOT, RELEASES_DIR, CURRENT_LINK, and KEEP_RELEASES to
# already be exported by the caller (bin/deploy-tool does this before
# sourcing this file).
new_release_path() {
echo "$RELEASES_DIR/$(date -u +%Y%m%d%H%M%S)"
}
list_releases() {
# oldest to newest, one directory name per line
# shellcheck disable=SC2012
ls -1 "$RELEASES_DIR" 2>/dev/null | sort
}
current_release() {
if [[ -L "$CURRENT_LINK" ]]; then
basename "$(readlink "$CURRENT_LINK")"
else
echo ""
fi
}
activate_release() {
local release_name="$1"
local target="$RELEASES_DIR/$release_name"
[[ -d "$target" ]] || { log_error "release '$release_name' does not exist"; return 1; }
# -f replaces an existing symlink, -n avoids dereferencing "current" if
# it were ever a real directory — the same swap technique Capistrano uses
ln -sfn "$target" "$CURRENT_LINK"
}
prune_releases() {
local all_releases total to_remove
all_releases=$(list_releases)
total=$(echo "$all_releases" | grep -c . || true)
if (( total <= KEEP_RELEASES )); then
return 0
fi
to_remove=$(( total - KEEP_RELEASES ))
echo "$all_releases" | head -n "$to_remove" | while IFS= read -r old; do
[[ -z "$old" ]] && continue
rm -rf "${RELEASES_DIR:?}/${old}"
log_info "pruned old release: $old"
done
}
bin/deploy-tool¶
#!/usr/bin/env bash
# deploy-tool — a small Capistrano-style deployment automation CLI
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/myapp}"
export KEEP_RELEASES="${KEEP_RELEASES:-5}"
RELEASES_DIR="$DEPLOY_ROOT/releases"
CURRENT_LINK="$DEPLOY_ROOT/current"
# shellcheck source=lib/logging.sh
source "$SCRIPT_DIR/lib/logging.sh"
# shellcheck source=lib/releases.sh
source "$SCRIPT_DIR/lib/releases.sh"
usage() {
cat <<'EOF'
Usage: deploy-tool <command> [args]
Commands:
deploy <source_dir> ship the contents of source_dir as a new release
rollback [n] point "current" at the n-th previous release (default 1)
status show the active release and recent history
logs [-n N] show the last N structured log lines (default 20)
help show this help
Environment:
DEPLOY_ROOT base directory for releases/current (default /opt/myapp)
KEEP_RELEASES how many releases to retain (default 5)
DEPLOY_LOG_FILE structured log file path
EOF
}
# Module 09: refuse to run as root — deploys should use a dedicated,
# unprivileged service account. DEPLOY_ALLOW_ROOT exists only so the bats
# suite and CI (which often run as root in a container) can opt in.
require_least_privilege() {
if [[ "$(id -u)" -eq 0 && "${DEPLOY_ALLOW_ROOT:-0}" != "1" ]]; then
log_error "refusing to run as root — use the 'deploy' service account instead"
exit 1
fi
}
# Module 09: read a webhook token from a mode-600 secrets file, never from
# an argument or a hardcoded value. Silently skips if not configured.
notify_webhook() {
local message="$1"
local token_file="${DEPLOY_WEBHOOK_TOKEN_FILE:-$DEPLOY_ROOT/shared/webhook_token}"
[[ -f "$token_file" ]] || { log_warn "no webhook token configured — skipping notification"; return 0; }
local perms
perms=$(stat -c '%a' "$token_file" 2>/dev/null || stat -f '%Lp' "$token_file")
if [[ "$perms" != "600" && "$perms" != "400" ]]; then
log_error "refusing to read '$token_file' — permissions are $perms, expected 600"
return 1
fi
local token
token=$(<"$token_file")
curl -fsS -H "Authorization: Bearer ${token}" \
-d "{\"text\":\"${message}\"}" \
"${DEPLOY_WEBHOOK_URL:?DEPLOY_WEBHOOK_URL not set}" > /dev/null
}
cmd_deploy() {
local source_dir="${1:-}"
[[ -n "$source_dir" && -d "$source_dir" ]] || { log_error "deploy requires an existing source directory"; exit 1; }
require_least_privilege
mkdir -p "$RELEASES_DIR" "$DEPLOY_ROOT/shared"
local release_path release_name
release_path=$(new_release_path)
release_name=$(basename "$release_path")
log_info "starting deploy from '$source_dir' to '$release_path'"
mkdir -p "$release_path"
cp -a "$source_dir"/. "$release_path"/
if [[ -x "$release_path/bin/post_deploy.sh" ]]; then
log_info "running post_deploy hook"
"$release_path/bin/post_deploy.sh"
fi
activate_release "$release_name"
log_info "release $release_name is now live"
prune_releases
notify_webhook "deployed $release_name" || log_warn "notification failed, continuing"
echo "deployed $release_name"
}
cmd_rollback() {
local steps="${1:-1}"
mapfile -t releases < <(list_releases) # bash 4+; oldest first
local current
current=$(current_release)
local current_index=-1
for i in "${!releases[@]}"; do
if [[ "${releases[$i]}" == "$current" ]]; then
current_index=$i
break
fi
done
if (( current_index < 0 )); then
log_error "could not determine the current release"
exit 1
fi
local target_index=$(( current_index - steps ))
if (( target_index < 0 )); then
log_error "no release $steps step(s) back from '$current'"
exit 1
fi
local target="${releases[$target_index]}"
log_warn "rolling back from $current to $target"
activate_release "$target"
notify_webhook "rolled back to $target" || log_warn "notification failed, continuing"
echo "rolled back to $target"
}
cmd_status() {
local current
current=$(current_release)
echo "current release: ${current:-none}"
echo "releases (oldest first):"
list_releases | sed 's/^/ - /'
}
cmd_logs() {
local n=20
if [[ "${1:-}" == "-n" ]]; then
n="$2"
fi
tail -n "$n" "$LOG_FILE" | jq -c '.'
}
main() {
local command="${1:-help}"
shift || true
case "$command" in
deploy) cmd_deploy "$@" ;;
rollback) cmd_rollback "$@" ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;;
help|-h|--help) usage ;;
*) echo "deploy-tool: unknown command '$command' (see 'deploy-tool help')" >&2; exit 1 ;;
esac
}
main "$@"
tests/deploy-tool.bats¶
#!/usr/bin/env bats
# tests/deploy-tool.bats
setup() {
export DEPLOY_ALLOW_ROOT=1 # bats/CI may run as root — bypass the guard for tests
TEST_ROOT=$(mktemp -d)
export DEPLOY_ROOT="$TEST_ROOT/app"
export DEPLOY_LOG_FILE="$TEST_ROOT/deploy-tool.log"
export KEEP_RELEASES=2
SRC_V1="$TEST_ROOT/src_v1"
mkdir -p "$SRC_V1"
echo "v1" > "$SRC_V1/VERSION"
SRC_V2="$TEST_ROOT/src_v2"
mkdir -p "$SRC_V2"
echo "v2" > "$SRC_V2/VERSION"
DEPLOY_TOOL="$BATS_TEST_DIRNAME/../bin/deploy-tool"
}
teardown() {
rm -rf "$TEST_ROOT"
}
@test "deploy creates a release and points current at it" {
run "$DEPLOY_TOOL" deploy "$SRC_V1"
[ "$status" -eq 0 ]
[ -L "$DEPLOY_ROOT/current" ]
[ "$(cat "$DEPLOY_ROOT/current/VERSION")" = "v1" ]
}
@test "status reports the current release" {
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
run "$DEPLOY_TOOL" status
[ "$status" -eq 0 ]
[[ "$output" == *"current release:"* ]]
}
@test "a second deploy switches current to the new release" {
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
"$DEPLOY_TOOL" deploy "$SRC_V2" >/dev/null
[ "$(cat "$DEPLOY_ROOT/current/VERSION")" = "v2" ]
}
@test "rollback switches current back to the previous release" {
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
sleep 1
"$DEPLOY_TOOL" deploy "$SRC_V2" >/dev/null
run "$DEPLOY_TOOL" rollback 1
[ "$status" -eq 0 ]
[ "$(cat "$DEPLOY_ROOT/current/VERSION")" = "v1" ]
}
@test "deploy rejects a nonexistent source directory" {
run "$DEPLOY_TOOL" deploy "/nonexistent/path"
[ "$status" -ne 0 ]
}
@test "old releases are pruned beyond KEEP_RELEASES" {
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
sleep 1
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
sleep 1
"$DEPLOY_TOOL" deploy "$SRC_V1" >/dev/null
release_count=$(find "$DEPLOY_ROOT/releases" -mindepth 1 -maxdepth 1 -type d | wc -l)
[ "$release_count" -le 2 ]
}
Dockerfile¶
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends bash coreutils findutils jq curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
# a dedicated, unprivileged service account — Module 09's least-privilege pattern
RUN useradd --system --create-home --shell /usr/sbin/nologin deploy
WORKDIR /opt/deploy-tool
COPY bin/ ./bin/
COPY lib/ ./lib/
RUN chmod +x bin/deploy-tool
ENV PATH="/opt/deploy-tool/bin:${PATH}"
ENV DEPLOY_ROOT=/opt/myapp
RUN mkdir -p /opt/myapp/releases /var/log/deploy-tool && \
chown -R deploy:deploy /opt/myapp /var/log/deploy-tool /opt/deploy-tool
USER deploy
ENTRYPOINT ["deploy-tool"]
CMD ["help"]
docker build -t deploy-tool:latest .
docker run --rm deploy-tool:latest status
# current release: none
# releases (oldest first):
.github/workflows/ci.yml¶
name: CI
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck, bats, and jq
run: sudo apt-get update && sudo apt-get install -y shellcheck bats jq
- name: Lint with shellcheck
run: shellcheck bin/deploy-tool lib/*.sh
- name: Run bats tests
run: bats tests/deploy-tool.bats
- name: Build the Docker image
run: docker build -t deploy-tool:ci .
Every push and pull request now gets the same two gates a human reviewer
would apply manually: does it pass lint, and do the behavioral tests still
pass — exactly the "fail the pipeline on a bad set -x habit" idea from
Level 3's git-hooks-and-CI module, just running against this project.
Running it¶
export DEPLOY_ROOT=/opt/myapp
export DEPLOY_ALLOW_ROOT=1 # only for this local walkthrough — real deploys use a service account
sudo mkdir -p "$DEPLOY_ROOT" && sudo chown "$USER" "$DEPLOY_ROOT"
./bin/deploy-tool deploy ./my-app-build
# {"time":"2026-07-18T09:00:00Z","level":"info","pid":4210,"msg":"starting deploy from './my-app-build' to '/opt/myapp/releases/20260718090000'"}
# {"time":"2026-07-18T09:00:00Z","level":"info","pid":4210,"msg":"release 20260718090000 is now live"}
# deployed 20260718090000
./bin/deploy-tool status
# current release: 20260718090000
# releases (oldest first):
# - 20260718090000
./bin/deploy-tool deploy ./my-app-build-v2
# deployed 20260718091500
./bin/deploy-tool rollback 1
# {"time":"2026-07-18T09:20:00Z","level":"warn","pid":4300,"msg":"rolling back from 20260718091500 to 20260718090000"}
# rolled back to 20260718090000
./bin/deploy-tool logs -n 3
# {"time":"2026-07-18T09:20:00Z","level":"warn","pid":4300,"msg":"rolling back from 20260718091500 to 20260718090000"}
How every level shows up here¶
| Level | Where it's used |
|---|---|
Level 1 — variables, functions, file tests, set -euo pipefail |
throughout bin/deploy-tool and lib/releases.sh |
Level 2 — arrays, case dispatch, environment configuration |
mapfile/array lookup in cmd_rollback, subcommand case in main |
Level 3 — sed/awk one-liners, CI shell steps, security basics |
sed 's/^/ - /' in cmd_status, .github/workflows/ci.yml |
| Level 4 — CLI subcommands, structured logging, security hardening, jq pipelines | the whole tool: lib/logging.sh, notify_webhook, cmd_logs's jq -c |
How It Actually Works¶
This capstone pulls together nearly every mechanism covered across all four
levels into one running program: argument parsing populates positional
parameters at process start; subcommand dispatch resolves a string to a
function or external command through bash's normal command-lookup order
(function, builtin, then $PATH search); any spawned helper processes
communicate through kernel pipes exactly as described in the pipes and
parallelism modules, with the kernel — not bash — actually scheduling
concurrent stages; and structured error handling ultimately still boils
down to the same single 8-bit exit-status integer every process in this
course has reported since Level 1.
Where the capstone differs from smaller scripts is in how much state persists across the tool's lifetime: because each subcommand invocation is typically a fresh process (unless implemented as an in-process function), anything meant to survive between invocations — config, cached data, lock files — has to be explicitly written to and read back from disk, using exactly the atomic-rename and lock-file patterns covered in the production- scripts and scheduling modules; there is no shared memory or persistent interpreter state between separate runs of a shell script, unlike a long-lived server process in another language.
Performance and robustness at this scale come down to the same trade-off
surfaced throughout the course: every external command is a real
fork()+execve() with measurable kernel overhead, so a well-built CLI
tool deliberately minimizes how many of its hot-path operations leave the
current bash process, reserving actual subprocess calls for operations
bash genuinely cannot do internally (network I/O, complex text
transformation, calling other installed tools) — the entire arc of this
course, from #!/usr/bin/env bash on line one of Level 1 through this
final project, is really about knowing exactly when that trade-off is
worth making.
Stretch goals¶
- Add a
deploy-tool diffsubcommand that shows what changed between the current release and the one before it (diff -rq). - Make
activate_releasesymlink-swap fully atomic on Linux using a temp-symlink-then-mvsequence, and document the BSD/macOS difference. - Add a
--dry-runflag todeploy(Level 1's backup-script stretch goal, applied here) that logs every step without touching the filesystem. - Extend the CI workflow to actually run
tests/deploy-tool.batsinside the built Docker image, so CI validates the exact artifact that ships. - Add a
deploy-tool.jsonmachine-readable output mode (--jsonflag onstatus) so other tools can consume it with thejqpipelines from Module 06.
You've now built a CLI tool, hardened it, logged it, tested it, containerized it, and wired it into CI — the same shape as real deployment tooling running in production today. You've completed the Shell Mastery Path — Entry to Master.