Shell Lesson 42 of 42

Shell Style Guide Capstone: The Production Review Checklist, Lifecycle Policy, Metrics Every Script Should Emit & The Sunset Criteria For Retiring Scripts Cleanly

In a nutshell

A quick shell script is the cheapest software in the world to write and the most expensive to own. Anyone can paste twenty lines onto a server and wire it to cron in five minutes. The bill arrives three years later, when the author has left, the cron still fires at 03:00, half the time it fails silently, and nobody on the current team is willing to touch it because nobody knows what it does or what breaks if it stops.

Think of a production script the way a company thinks of a vehicle in its fleet, not a car you own personally. A fleet vehicle has a logbook (who owns it, what it’s for), a service schedule (tests, review), an MOT it has to pass (the review checklist), a warning light on the dashboard (metrics and alerts), a named driver on call (the owner team), and — eventually — a decommissioning process (sunset and retirement). A “quick script” with none of that is a car someone abandoned in the corner of the car park: it still starts, technically, but there’s no logbook, no owner, no service history, and five years on there are two hundred of them rusting in the lot and nobody dares move any of them.

Production-grade shell, then, isn’t about clever one-liners. It’s four boring, durable artifacts kept on the wall: a style guide (what “good” looks like, so a fleet of scripts reads as if one careful engineer wrote them all), a review checklist (the MOT every script passes before production), a lifecycle policy (the states a script lives through, from draft to retirement, each transition owned and tracked), and a sunset policy (explicit triggers for scrapping a script before it becomes a hazard). Get those four right and shell stops being a graveyard and becomes a surface you can trust to run unattended for years.

This is the capstone, so it links back to the whole course — but the four you lean on hardest here are defensive scripting (the strict-mode header, error handling, ShellCheck), testing with Bats, signal handling & cleanup traps, and monitoring & metrics.

Level: Capstone (all levels) · Time: ~45 min · Prerequisites: ideally the preceding 41 lessons, but you can read this cold as a checklist and follow the links back where a concept is unfamiliar.

One production shell script's whole life read left to right across five stages. AUTHOR: a style-guide header (shebang plus set -Eeuo pipefail) and a draft script that sources the lib/* helpers, living on a branch owned only by its author. REVIEW: the seven-category PR checklist and a human reviewer whose job is to reject the eight anti-patterns. TEST + SHIP: a CI pipeline running bats, shellcheck and shfmt until green, then promotion from provisional to active after a 30-day soak. OPERATE: the script running unattended under cron or systemd while emitting the four standard metrics (last-run, last-success, last-exit-code, duration) into alerts. RETIRE: an explicit sunset trigger (replaced by a real tool, no remaining consumers, failing, or unowned) followed by archival in version control forever but never deployed again. Six numbered badges mark author-under-the-style-guide, the review gate, prove-it-green-then-soak, operate-as-a-monitored-property, the four-metrics-four-alerts surface, and retire-on-an-explicit-trigger.

Read the diagram left → right as the journey of a single script: it is authored under the style guide, passes the seven-category review gate, is proven green in CI and soaked as provisional, runs unattended while emitting the four standard metrics, and is finally retired on an explicit trigger — archived in version control forever but never run again. Skip any one stage and you drift toward the graveyard; keep all five and the number that matters, total active scripts, stays flat instead of climbing forever.

Why This Capstone Exists

A shell script that is good enough today is the easiest piece of software to ship. A shell script that is still earning its keep three years from now, after the original author has left and the surrounding system has been refactored twice, is one of the hardest. The difference is not language quality — it’s lifecycle discipline.

This capstone consolidates the 41 preceding lessons into the four artifacts every team should keep on the wall:

  1. The Production Review Checklist — the questions every PR introducing or modifying a production shell script must answer.
  2. The Lifecycle Policy — the documented states a script lives through, from prototype to active to deprecated to retired.
  3. The Standard Metrics Surface — what every production shell script must emit so monitoring sees it.
  4. The Sunset Criteria — explicit triggers for retiring a script before it becomes a maintenance hazard.

Following these four artifacts turns “shell scripts as a graveyard of one-off tools” into “shell scripts as a sustainable engineering surface.”

The Library Family From The Series

Across L1-L41 we built a layered library of shell helpers. Each script in production should source the libraries relevant to its role:

Library From lesson Purpose
lib/log.sh L7 Structured logging with levels, JSON output, log rotation
lib/err.sh L8 Error trap, stack trace, cleanup on exit
lib/fs.sh L28 Atomic file writes, temp-in-same-dir, fsync helpers
lib/lock.sh L21 flock single-instance, distributed locks via Redis
lib/observe.sh L25 Tracing helpers, structured events, span-id propagation
lib/secrets.sh L24 Vault/SSM lookup, credential masking, never-print discipline
lib/test.sh L31 Bats integration, fixture setup/teardown, assertion helpers
lib/metrics.sh L34 Prometheus textfile exporter, atomic .prom file writes
lib/backup.sh L35 sha256 manifests, GFS retention, S3 upload + verify
lib/db.sh L36 pg_dump/mysqldump pipelines, base backup, PITR drill
lib/loganalyze.sh L37 Streaming awk, mawk detection, fleet fan-out
lib/heal.sh L38 Detect-decide-act, idempotency, rate limit, circuit breaker
lib/migrate.sh L39 Resumable batch, watermark, staging cutover, sample-diff
lib/compliance.sh L40 Controls-as-tests, JSONL bundles, GPG signing, drift
lib/forensics.sh L41 Order-of-volatility capture, chain-of-custody log

A real production deploy keeps these in /usr/local/lib/ with mode 0644, owned by root:root. Scripts source them from the canonical path. Updates ship via the same Ansible/Puppet/cloud-init that ships system config — version-controlled, reviewed, and rolled out with the same discipline as any other infrastructure code.

The Shell Style Guide

The review checklist that follows tells a reviewer what to check. This section is the other half: the house style every script is measured against — the concrete conventions that make a fleet of scripts read as if one careful engineer wrote them all, rather than forty rushed ones. The value is in having one written answer to each question, not in which answer you pick; adopt this wholesale or adapt it to your team. (KloudVin’s house style tracks Google’s Shell Style Guide closely, with the strict-mode, observability, and lifecycle additions the earlier lessons argued for.)

The one-screen header every script starts with

Everything from the defensive-scripting lesson compresses into a header that is identical across the fleet, so a reader recognises it instantly:

#!/usr/bin/env bash
#
# <name>.sh — one-line purpose.
# Owner:   team-name@example.com
# Runbook: https://runbooks.example.com/<name>
# Env:     VAR_ONE (required), VAR_TWO (default: 7)
# Exit:    0 ok · 1 generic · 2 usage · 65 bad input (sysexits)
#
set -Eeuo pipefail
shopt -s inherit_errexit nullglob 2>/dev/null || true   # bash 4.4+; harmless on 3.2
IFS=$'\n\t'

readonly SCRIPT_NAME="${0##*/}"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

#!/usr/bin/env bash (not #!/bin/bash) finds bash on PATH, which matters on BSD/macOS where bash lives in /usr/local/bin or /opt/homebrew/bin. The 2>/dev/null || true on the shopt line means the same header runs unmodified on an old bash 3.2 host that lacks inherit_errexit, degrading gracefully instead of aborting.

Naming — the fleet reads like one author

Bash has no real namespacing: a for i in … inside a sourced library collides with the caller’s i. Descriptive, local-scoped names are not a style nicety, they are a correctness requirement (anti-pattern 4, below).

Kind Convention Example
Global constants / config UPPER_SNAKE, readonly readonly MAX_RETRIES=3
Environment inputs UPPER_SNAKE, defaulted CLEANUP_AGE_DAYS=${CLEANUP_AGE_DAYS:-7}
Local variables lower_snake, always local local retry_count=0
Functions lower_snake, verb-first cleanup_dir(), assert_command()
Private/internal helpers leading underscore _log(), _on_err()
Scripts kebab-case.sh nightly-cleanup.sh
Libraries lib/<noun>.sh lib/metrics.sh

Never ship a single-letter or generic global (i, tmp, data, n). Inside a library every variable is local and named for its job (local retry_count, local tmp_file). Reserve UPPER_SNAKE for things that are genuinely constant or come from the environment, and mark the truly-constant ones readonly so a later line can’t silently reassign them:

readonly MAX_RETRIES=3
MAX_RETRIES=5            # bash: MAX_RETRIES: readonly variable  → aborts, as intended

Structure — top-down, main last

Every non-trivial script follows the same top-to-bottom order, so a reader always knows where to look:

shebang
header comment block
strict-mode preamble  (set -Eeuo pipefail, IFS, shopt)
readonly SCRIPT_NAME / SCRIPT_DIR
source /usr/local/lib/*.sh          # dependencies
readonly configuration              # constants, env with defaults
function definitions                # usage(), helpers, then main()
main "$@"                           # the ONLY top-level call, at the very bottom

The single most important structural rule is that main "$@" is the last line of the file. Because bash reads and executes a script line by line (not parse-whole-then-run), a script that does work at the top level can start acting before the interpreter has read the rest of the file — and a truncated download (a dropped SSH connection mid-curl | bash, a full disk during deploy) can execute the first half of a script and never see the second. Defining everything as functions and calling main "$@" only at the end means a partially-written file simply defines some functions and exits without running anything. It is the cheapest possible guard against half-executed scripts.

Quoting — quote by default, unquote deliberately

Quote every expansion unless you have a written reason not to. This is the single highest-value habit in shell and the source of most real-world bugs (see the security lesson).

cp "$src" "$dest"                 # not  cp $src $dest      (SC2086: word-split + glob)
for f in "${files[@]}"; do# not  for f in $files    (SC2068 / SC2086)
result="$(some_command)"          # capture, always quoted
printf 'user=%q\n' "$untrusted"   # %q re-emits input safely as a shell token

Functions — one job, local everything, return status vs. data

cleanup_dir() {
  local dir="$1"                       # every parameter is local, named
  [[ -d "$dir" ]] || return 0          # guard clause: return early, don't nest
  local count=0
  # …work…
  printf '%d\n' "$count"               # DATA goes to stdout
}                                       # STATUS goes via return / exit code

Error handling — fail loud, fail once, clean up once

Straight from defensive scripting and signal handling:

Documentation — the header, the “why”, the CHANGELOG

ShellCheck- and shfmt-clean, always

Two tools, run in CI on every PR, are non-negotiable:

shellcheck -S warning script.sh        # static analysis: quoting, word-split, logic bugs
shfmt -i 2 -ci -s -d     script.sh      # formatter: 2-space indent, -d shows a diff, exit≠0 if unformatted

Green lint is a merge gate, not a nice-to-have. Suppressions are scoped as tightly as possible (one line beats one function beats one file) and always carry a # reason. A # shellcheck disable=SC2086 with no explanation is a future bug hiding behind a green check.

The style guide on one line each

Dimension Rule Verified by
Shebang #!/usr/bin/env bash reviewer / shellcheck shell=bash
Preamble set -Eeuo pipefail, IFS=$'\n\t' reviewer / grep
Naming UPPER consts, lower locals, verb-first fns reviewer
Structure functions first, main "$@" last reviewer
Quoting quote every expansion; arrays for arg lists shellcheck (SC2086/2068)
Functions local all; data→stdout, status→exit code reviewer / shellcheck
Errors die/warn/info to stderr; ERR + EXIT traps reviewer
Docs header block; comments say why; CHANGELOG reviewer
Lint shellcheck -S warning + shfmt clean CI

The Production Review Checklist

Every PR that introduces or modifies a production shell script must satisfy seven categories. The checklist is intentionally numerous — most categories are one-line yes/no, and the explicit list ensures nothing is forgotten.

Category 1: Boilerplate & Shell Mode

#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

trap 'on_exit $?' EXIT
on_exit() { local rc=$1; ...; }

Category 2: Argument Handling

Category 3: Error Handling

Category 4: Idempotency & Safety

Category 5: Observability

Category 6: Testing

Category 7: Documentation

The Single-Page Reviewer’s Checklist

For pasting into a PR template:

SHELL SCRIPT REVIEW

Boilerplate
  [ ] #!/usr/bin/env bash
  [ ] set -euo pipefail (or justified)
  [ ] EXIT trap for cleanup
  [ ] readonly script-self vars

Args & UX
  [ ] --help with examples
  [ ] --dry-run if mutating
  [ ] Required args validated

Error handling
  [ ] No silent 2>/dev/null
  [ ] stderr for errors
  [ ] Documented exit codes

Safety
  [ ] Idempotent on re-run
  [ ] Atomic file writes (lib/fs.sh)
  [ ] No hardcoded secrets
  [ ] Inputs sanitized

Observability
  [ ] Structured logs
  [ ] Heartbeat metric
  [ ] Audit log if mutating

Testing
  [ ] Bats tests in tests/
  [ ] shellcheck clean
  [ ] shfmt clean
  [ ] CI green

Docs
  [ ] Header block
  [ ] Runbook updated
  [ ] CHANGELOG

The Lifecycle Policy

Every production shell script lives through five states. The transitions between states are explicit, owner-driven, and tracked.

   ┌──────────┐   review   ┌──────────┐   adoption   ┌──────────┐
   │ DRAFT    │───────────▶│ PROVISIONAL│────────────▶│ ACTIVE   │
   └──────────┘             └──────────┘              └──────────┘
                                                            │
                                                       deprecation
                                                            ▼
                                                      ┌──────────┐   sunset   ┌──────────┐
                                                      │ DEPRECATED│──────────▶│ RETIRED  │
                                                      └──────────┘             └──────────┘

State 1: DRAFT

Exit criteria: code review approval + at least one test.

State 2: PROVISIONAL

Exit criteria: 30 days of clean operation + observability metrics in place + runbook written.

State 3: ACTIVE

Exit criteria: someone files an issue marking it for deprecation.

State 4: DEPRECATED

Exit criteria: target retirement date passes AND no consumers remain.

State 5: RETIRED

The Lifecycle Tracker (METADATA file)

Every active script has a sibling <script>.lifecycle.yaml:

name: nightly-backup.sh
state: ACTIVE
owner_team: platform-storage
on_call_runbook: https://runbooks.example.com/nightly-backup
metrics_dashboard: https://grafana.example.com/d/backup
created: 2024-08-12
promoted_to_provisional: 2024-08-15
promoted_to_active: 2024-09-15
last_review: 2026-06-01
replacement_candidate: null
deprecated_after: null
retired_after: null
dependencies:
  - lib/log.sh
  - lib/backup.sh
  - lib/db.sh
notes: |
  Replaces the legacy 'backup-cron.pl' from 2018.

Annual review consists of: open every YAML, ask “is this still earning its keep?”, update last_review. Scripts where the answer is “no” get queued for deprecation.

The Owner Departure Trigger

When the owner of a script leaves the team, the script’s state transitions to:

This is the single biggest lever against the “scripts as graveyard” pattern. Without owner-departure triggers, every team accumulates 50+ orphan scripts within 5 years.

The Script Lifecycle, Stage by Stage

The five states above are nouns — where a script is. This section is the matching verbs — what an engineer actually does to move a script along. Author → review → test → ship → operate → retire is the day-to-day workflow; each verb produces an artifact and has a gate that must pass before the next verb begins.

Stage (verb) Moves toward What you do Artifact produced Gate to next stage
Author DRAFT Write against the style guide; source lib/*; write the header block Script + first Bats test Code review requested
Review (merge) Run the 7-category checklist; reject anti-patterns Approved PR All categories pass
Test PROVISIONAL Bats happy + error paths; shellcheck; shfmt; wire into CI Green pipeline CI green on merge
Ship PROVISIONAL → ACTIVE Deploy via Ansible/cloud-init to the canonical path; wire cron/systemd; write lifecycle.yaml + runbook Running unit + tracker file 30-day clean soak
Operate ACTIVE Emit the 4 metrics; add alert rules; annual “still earning its keep?” review Dashboard + alerts Someone files for deprecation
Retire DEPRECATED → RETIRED A sunset trigger fires; run the retirement ceremony Archived script + final commit Target date + no consumers

Two things are worth saying plainly. First, this is a loop with an escape hatch, not a one-way street: a PROVISIONAL script that fails its 30-day soak rolls back to DRAFT, not forward to ACTIVE — the deadline forces a decision instead of letting “experimental forever” become a permanent state. Second, the “author” in stage one is very often future-you picking the script back up in eighteen months; every artifact in the table (the header, the tests, the lifecycle.yaml, the runbook) exists so that person can act without re-deriving the whole thing. The workflow is the mechanism; the lifecycle states are the audit trail it leaves behind.

Standard Metrics Every Production Script Should Emit

Every production script emits at least four metrics. With lib/metrics.sh:

# 1. Last run timestamp (success or failure)
metric_set "myapp_script_last_run_seconds" "$(date +%s)" \
  "script=\"$SCRIPT_NAME\""

# 2. Last success timestamp
trap 'on_exit $?' EXIT
on_exit() {
  local rc=$1
  if (( rc == 0 )); then
    metric_set "myapp_script_last_success_seconds" "$(date +%s)" \
      "script=\"$SCRIPT_NAME\""
  fi
  metric_set "myapp_script_last_exit_code" "$rc" \
    "script=\"$SCRIPT_NAME\""
}

# 3. Duration
SECONDS=0
# ...work happens...
metric_set "myapp_script_duration_seconds" "$SECONDS" \
  "script=\"$SCRIPT_NAME\""

# 4. Domain-specific (rows processed, bytes uploaded, etc.)
metric_set "myapp_backup_bytes_total" "$(stat -c %s /var/backups/...)" \
  "script=\"$SCRIPT_NAME\""

Standard alert rules to wire up (reuse for every production script):

groups:
- name: shell-scripts
  rules:
  # Script hasn't run in 25h (expected nightly cron)
  - alert: ScriptStaleRun
    expr: time() - myapp_script_last_run_seconds > 86400 * 1.04
    for: 10m
    annotations:
      summary: "{{ $labels.script }} hasn't run in over 25 hours"

  # Script ran but failed
  - alert: ScriptLastRunFailed
    expr: myapp_script_last_exit_code != 0
    for: 1h
    annotations:
      summary: "{{ $labels.script }} last run failed with exit code {{ $value }}"

  # Script success is stale (ran recently but kept failing)
  - alert: ScriptLastSuccessStale
    expr: time() - myapp_script_last_success_seconds > 86400 * 2
    for: 30m
    annotations:
      summary: "{{ $labels.script }} hasn't succeeded in 2+ days"

  # Script duration anomaly (took 3× the median)
  - alert: ScriptDurationAnomaly
    expr: myapp_script_duration_seconds > 3 * avg_over_time(myapp_script_duration_seconds[14d])
    for: 0m
    annotations:
      summary: "{{ $labels.script }} took {{ $value }}s, 3× normal"

These four alert rules, applied uniformly across every production script, transform “did the cron run?” from a tribal knowledge question into a monitored property.

The Sunset Criteria

A script earns retirement when at least one of these triggers fires:

Trigger 1: Replaced By A Real Tool

The script’s job is now done by:

When this happens, run both in parallel for at least one full operational cycle (one week minimum, one month preferred), verify the new tool’s outputs match the old script’s, then deprecate.

Trigger 2: No Consumers

The script’s outputs (files, metrics, alerts) have no remaining consumer. Verify:

# Find anything still referencing the script
grep -r "nightly-backup.sh" /etc /opt /home /var/spool /usr/local/bin
grep -r "myapp_backup_bytes_total" /etc/prometheus  # any alerts?
git log --all --oneline -- scripts/active/nightly-backup.sh  # any recent activity?

If all three are empty, the script is unused. Deprecate immediately; retire after 30 days.

Trigger 3: Repeated Failures Without A Fix

If a script’s myapp_script_last_success_seconds has been stale for >30 days and nobody has been able (or willing) to fix it, the script is dead in fact if not in name. Deprecate it and either:

The worst state is “the cron is still listed but the script silently fails every night.” The retirement is more honest.

Trigger 4: Owner Team Departure With No Successor

Already covered in lifecycle. If owner_team is empty for >30 days, the script transitions to DEPRECATED automatically. After 90 more days, it retires.

Trigger 5: Annual Review Says “No Longer Needed”

The yearly check on last_review. The owner team explicitly says “this isn’t earning its keep.” Deprecate immediately.

The Retirement Ceremony

The day a script retires:

  1. Remove from cron / systemd: sudo systemctl disable --now myapp-script.timer.

  2. Move the file: git mv scripts/active/foo.sh scripts/retired/2026/foo.sh.

  3. Update its lifecycle.yaml: state: RETIRED, set retired_after.

  4. Archive monitoring: move alert rules to prometheus/rules/retired/.

  5. Final commit message:

    retire: scripts/active/nightly-backup.sh
    
    Replaced by Velero (https://velero.example.com).
    Velero has run in parallel for 30 days and outputs match.
    No remaining consumers. Cron entry removed.
    
  6. Note in team weekly: “Retired script X, total active scripts now N.”

The “total active scripts now N” metric is itself worth tracking. A team where N goes up monotonically is accumulating debt; a team where N stays flat or shrinks is managing its surface.

The Anti-Patterns To Watch For (And Reject At Review)

After 41 lessons, these are the patterns that should fail review every time:

Anti-Pattern 1: The “Just A Quick Script” That Lives For 5 Years

Every script in production was once a “just a quick script.” Skip the lifecycle policy at your peril. Reject at review if a draft is being merged without lifecycle.yaml.

Anti-Pattern 2: Silent 2>/dev/null Without Justification

Suppressing stderr hides bugs. Every 2>/dev/null should have a comment: # stderr suppressed because rm prints "no such file" but we don't care.

Anti-Pattern 3: Hardcoded Paths That Break On The Other OS

/proc/sys/kernel/... works on Linux, doesn’t exist on macOS / BSD. dscl works on macOS, doesn’t exist on Linux. Either explicitly target one OS or do feature detection (command -v ... >/dev/null && ...).

Anti-Pattern 4: Globals Named i, tmp, data

Bash has no real namespacing. A for i in ... in a sourced library can collide with the caller’s i. Always use descriptive names in libraries, and local everywhere.

Anti-Pattern 5: Comments That Lie

# fast path — copies in O(1) — but the code does a recursive directory walk. Outdated comments are worse than no comments.

Anti-Pattern 6: Print-Then-Sleep

echo "Restarting..."; sleep 5; restart_thing — if restart_thing fails, the operator has been told a lie. Print after the action succeeds, not before.

Anti-Pattern 7: Magic Numbers

sleep 30 — why 30? tail -n 1000 — why 1000? Either a constant with a name (readonly RETRY_BACKOFF_SECONDS=30) or a comment.

Anti-Pattern 8: Unsourced Library Behavior Differences

Some libraries source at runtime, some at parse time. source lib/foo.sh inside a function works differently than at top level. Test both if your script does it.

Sample Production Script Skeleton

Pull together everything from L1-L41 into a template:

#!/usr/bin/env bash
#
# nightly-cleanup.sh — Remove stale temp files and rotate cleanup logs.
# Owner: platform-ops@example.com
# Runbook: https://runbooks.example.com/nightly-cleanup
# Lifecycle: see nightly-cleanup.lifecycle.yaml
#
# Env:
#   CLEANUP_DRY_RUN — if "true", logs intended actions but does not delete
#   CLEANUP_AGE_DAYS — files older than this are removed (default 7)
#
# Exit codes:
#   0  — success
#   1  — generic failure
#   2  — usage error
#   65 — input data invalid (sysexits.h EX_DATAERR)

set -o errexit -o nounset -o pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

source /usr/local/lib/log.sh
source /usr/local/lib/err.sh
source /usr/local/lib/fs.sh
source /usr/local/lib/lock.sh
source /usr/local/lib/metrics.sh

readonly CLEANUP_AGE_DAYS=${CLEANUP_AGE_DAYS:-7}
readonly CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-false}
readonly CLEANUP_DIRS=(/tmp /var/tmp /var/spool/myapp/work)

usage() {
  cat <<EOF
Usage: $SCRIPT_NAME [--dry-run] [--age-days N]
Remove files older than --age-days from configured cleanup dirs.
EOF
}

main() {
  parse_args "$@"
  acquire_lock "$SCRIPT_NAME" || { log_warn "another instance running"; exit 0; }

  log_info "starting cleanup; age=${CLEANUP_AGE_DAYS}d dry_run=${CLEANUP_DRY_RUN}"
  metric_set "myapp_cleanup_last_run_seconds" "$(date +%s)" "script=\"$SCRIPT_NAME\""
  SECONDS=0

  local removed_total=0
  for dir in "${CLEANUP_DIRS[@]}"; do
    [[ -d "$dir" ]] || { log_warn "missing dir: $dir"; continue; }
    local removed
    removed=$(cleanup_dir "$dir") || removed=0
    removed_total=$((removed_total + removed))
  done

  metric_set "myapp_cleanup_files_removed_total" "$removed_total" "script=\"$SCRIPT_NAME\""
  metric_set "myapp_cleanup_duration_seconds" "$SECONDS" "script=\"$SCRIPT_NAME\""
  log_info "removed $removed_total files in ${SECONDS}s"
}

cleanup_dir() {
  local dir="$1"
  local count=0
  while IFS= read -r -d '' file; do
    if "$CLEANUP_DRY_RUN"; then
      log_debug "would remove: $file"
    else
      rm -f "$file" && count=$((count + 1))
    fi
  done < <(find "$dir" -type f -mtime "+$CLEANUP_AGE_DAYS" -print0)
  echo "$count"
}

parse_args() {
  while (( $# > 0 )); do
    case "$1" in
      --dry-run)    CLEANUP_DRY_RUN=true ;;
      --age-days)   shift; CLEANUP_AGE_DAYS="$1" ;;
      -h|--help)    usage; exit 0 ;;
      *)            usage >&2; exit 2 ;;
    esac
    shift
  done
}

on_exit() {
  local rc=$1
  metric_set "myapp_cleanup_last_exit_code" "$rc" "script=\"$SCRIPT_NAME\""
  if (( rc == 0 )); then
    metric_set "myapp_cleanup_last_success_seconds" "$(date +%s)" "script=\"$SCRIPT_NAME\""
  fi
}
trap 'on_exit $?' EXIT

main "$@"

This template hits every category of the review checklist:

Combined with a Bats test file and a lifecycle.yaml, this is a script ready for ACTIVE state.

Going deeper

Everything above is the doctrine on the wall. This section is the senior-engineer fine print — the questions that come up when you try to run this discipline across a whole organisation, at fleet scale, for years.

The KPI that actually matters: total active scripts

Most teams measure their scripts by what they do. The number that predicts pain is simpler: how many active scripts exist, and is that number trending up? Each active script is a standing liability — an owner obligation, a monitoring target, a portability risk, a thing that can page you at 03:00. A team where the count climbs monotonically is accumulating debt no matter how good each individual script is; a team where it stays flat or falls is managing its surface. Track it weekly, put it on the same dashboard as the four per-script metrics, and treat “retired a script this week” as a win worth announcing. The corollary: the highest-leverage engineering act is often deleting a script (replaced by a real tool), not adding one. Optimise for a small, well-owned fleet, not for coverage.

Mechanise the checklist; reserve humans for judgment

A seven-category checklist read by a tired reviewer at 5pm is a checklist half-skipped. Split it in two. The mechanical half — shebang present, strict-mode header present, shellcheck -S warning clean, shfmt clean, a lifecycle.yaml exists next to the script, exit codes documented in the header — is a pre-commit hook plus a CI job, and it should block the merge with zero human involvement:

# CI gate: every script under scripts/active/ must have a sibling lifecycle.yaml in ACTIVE state
find scripts/active -name '*.sh' -print0 |
while IFS= read -r -d '' script; do
  meta="${script%.sh}.lifecycle.yaml"
  [[ -f "$meta" ]] || { echo "MISSING lifecycle.yaml for $script" >&2; exit 1; }
  grep -q '^state: ACTIVE$' "$meta" || { echo "$script not in ACTIVE state" >&2; exit 1; }
done

That frees the human reviewer to spend their scarce attention on the things a linter cannot see: is this genuinely idempotent, what’s the blast radius if it runs twice, is the naming honest, does the runbook actually match the behaviour, is shell even the right tool here. Mechanise the yes/no; reserve people for the judgment.

The supply chain of lib/*

Every script sources the shared libraries — which means a shared library is a privilege-escalation surface. If /usr/local/lib/log.sh is writable by a non-root user and a root cron sources it, that user owns root. So the libraries ship with mode 0644 root:root, are deployed by the same reviewed, version-controlled config-management path as any system file (never curl | sudo bash from the internet), and ideally are checksum- or signature-verified on deploy. Pin the library version a script expects; a silent breaking change in lib/metrics.sh shipped to the fleet breaks forty scripts at once. Sourcing untrusted code is exactly as dangerous as running it, because it is running it.

Bash version skew across the fleet

The style guide targets Linux + bash 4+/5 + GNU coreutils, but real fleets are heterogeneous: an old RHEL 7 box on bash 4.2, a macOS build agent on bash 3.2 with BSD userland, an Alpine container on busybox ash. Features silently differ — mapfile/readarray, associative arrays, inherit_errexit, declare -n, ${var,,} lowercasing, GNU date -d / sed -i / find -printf / grep -P. Three defences, in order of preference: pin the runtime (ship the script inside a container with a known bash), detect at runtime ((( BASH_VERSINFO[0] >= 4 )) || die "needs bash 4+", command -v gdate), or write to the lowest common denominator and let # shellcheck shell=bash / shell=sh enforce it. What you must not do is assume; the POSIX-portability lesson and packaging lesson exist precisely because “it works on my machine” is a version-skew bug waiting to page someone.

Owner departure is the dominant failure mode

Model the whole graveyard problem and one variable dominates: owner turnover. Bugs get fixed, requirements are met, but people leave — and an unowned script is a liability the instant it breaks, because there is nobody to page. This is why the lifecycle policy makes owner_team, not code quality, the field that triggers automatic deprecation. Run a periodic bus-factor audit: grep every lifecycle.yaml for an owner_team that no longer exists on the org chart, and force each orphan to either find a new owner within 30 days or enter DEPRECATED. A team that never does this audit will, empirically, accumulate dozens of orphan scripts within a few years regardless of how well-written each one was.

The meta-sunset: when shell is the wrong tool

The most advanced sunset trigger is the honest admission that the whole approach has outgrown shell. Rules of thumb from real codebases: once a script pushes past a few hundred lines, needs real data structures (nested maps, typed records) beyond bash arrays and associative arrays, does non-trivial string parsing you’d rather express as a grammar, or needs a test suite richer than Bats comfortably supports — it is telling you to move to Python or Go. Shell’s superpower is gluing processes together; the moment the bulk of the work is in-process logic rather than orchestrating other programs, you’re fighting the language. Retiring a 900-line bash monster by rewriting it in Python is a legitimate Trigger 1 (“replaced by a real tool”) — the real tool is just a better-suited language. The discipline in this capstone is what makes that migration safe: a script with tests, metrics, and a runbook can be swapped out under parallel-run validation with confidence.

Security review specifics for shell

Beyond “no hardcoded secrets,” a security-minded review of a production script checks four shell-specific hazards. Injection via unquoted expansion into eval, bash -c, or a command built by string concatenation — never build a command line from untrusted input; use arrays and "${arr[@]}". PATH hijacking — a cron script that calls tar rather than /usr/bin/tar runs whatever tar is first on a possibly-attacker-influenced PATH; set an explicit PATH at the top or call absolute paths for privileged scripts. Temp-file races — a predictable /tmp/work.$$ is a symlink-attack target; use mktemp and an EXIT-trap cleanup, per the signal-handling lesson. Secret leakage — a secret passed as a command-line argument is visible to every user via ps//proc/*/cmdline; pass secrets by env var or file, and mask them in logs. These four are the shell-specific additions to any standard security checklist.

The Capstone Quick-Reference Card

THE SEVEN-CATEGORY REVIEW
  1. Boilerplate (shebang, set -e, trap)
  2. Args & UX (--help, --dry-run, validation)
  3. Error handling (stderr, exit codes, no silent suppress)
  4. Safety (idempotent, atomic, no secrets, sanitized inputs)
  5. Observability (logs, 4 metrics, audit log)
  6. Testing (bats, shellcheck, shfmt, CI)
  7. Documentation (header, runbook, CHANGELOG)

THE FIVE STATES
  DRAFT → PROVISIONAL → ACTIVE → DEPRECATED → RETIRED
  Each transition is owner-driven and tracked in lifecycle.yaml

THE FOUR STANDARD METRICS
  myapp_*_last_run_seconds       (when did it run?)
  myapp_*_last_success_seconds   (when did it last succeed?)
  myapp_*_last_exit_code         (what was the outcome?)
  myapp_*_duration_seconds       (how long did it take?)

THE FIVE SUNSET TRIGGERS
  1. Replaced by a real tool (with parallel-run validation)
  2. No remaining consumers (grep across infra)
  3. Failing for >30 days without fix
  4. Owner team departed without successor
  5. Annual review says "no longer needed"

THE LIBRARY FAMILY
  lib/log.sh, lib/err.sh, lib/fs.sh, lib/lock.sh,
  lib/observe.sh, lib/secrets.sh, lib/test.sh,
  lib/metrics.sh, lib/backup.sh, lib/db.sh,
  lib/loganalyze.sh, lib/heal.sh, lib/migrate.sh,
  lib/compliance.sh, lib/forensics.sh

THE EIGHT ANTI-PATTERNS
  1. "Just a quick script" without lifecycle.yaml
  2. Silent 2>/dev/null without justification
  3. OS-hardcoded paths
  4. i, tmp, data globals (no namespacing)
  5. Comments that lie
  6. Print-then-sleep
  7. Magic numbers
  8. Library behavior differences from sourcing

THE NUMBER THAT MATTERS
  Total active scripts: track it weekly. Up = debt; flat = managed.

Practice challenges

These are graded from beginner to advanced, and every one is a review-and-fix — exactly what you do to a colleague’s PR. Read the snippet, decide what fails the checklist, then open the solution. Each answer names the earlier lesson that covers the underlying skill. The build host here is bash 3.2 + BSD userland, so anything needing bash 4.4+ or GNU coreutils is called out.

Challenge 1 (beginner) — give a “quick script” its style-guide header

A colleague opens a PR with this. It “works.” What does it fail in the Boilerplate and Documentation categories, and what’s the fixed version?

#!/bin/bash
rm -rf /var/tmp/cache/*
echo "cleaned"

<details> <summary>Solution</summary>

#!/usr/bin/env bash
#
# clear-cache.sh — Remove everything under the app cache dir.
# Owner: platform-ops@example.com  ·  Runbook: https://runbooks.example.com/clear-cache
# Exit: 0 ok · 1 generic · 2 usage
#
set -Eeuo pipefail
IFS=$'\n\t'
readonly CACHE_DIR="/var/tmp/cache"

[[ -d "$CACHE_DIR" ]] || { echo "cache dir missing: $CACHE_DIR" >&2; exit 1; }
find "$CACHE_DIR" -mindepth 1 -delete
echo "cleaned $CACHE_DIR" >&2

Why: #!/bin/bash becomes #!/usr/bin/env bash (portability); it gains a header block (purpose/owner/runbook/exit codes), the strict-mode preamble, and a status message on stderr. rm -rf /var/tmp/cache/* is replaced with a find … -delete guarded by an existence check — the original would blow past a typo’d path silently and, worse, the unquoted glob does nothing useful if the dir is empty and everything terrible if the variable were ever wrong. Covered in defensive scripting. </details>

Challenge 2 (beginner) — harden a silent-failure snippet

This line appears in a backup script. It trips two checklist items (Error Handling: no silent 2>/dev/null; Safety: inputs sanitized/quoted). Fix it.

cp $SRC $DEST 2>/dev/null

<details> <summary>Solution</summary>

cp -- "$SRC" "$DEST" || die "cp failed: $SRC -> $DEST"

Why: the unquoted $SRC/$DEST word-split on spaces (ShellCheck SC2086) — a filename like my report.pdf becomes two arguments; quoting fixes it, and -- stops a filename that starts with - being read as a flag. The 2>/dev/null silently swallowed the very error that tells you the backup didn’t happen (anti-pattern 2); replacing it with || die makes the failure loud. If you genuinely must suppress a stderr, it carries a # reason comment. Covered in security & quoting and defensive scripting. </details>

Challenge 3 (intermediate) — make a mutating script idempotent and add --dry-run

This “archive old logs” loop fails Idempotency & Safety: re-running it double-moves, and there’s no dry-run for a mutating script. Harden it.

for f in /var/log/app/*.log; do
  mv "$f" /var/log/app/archive/
done

<details> <summary>Solution</summary>

readonly DRY_RUN=${DRY_RUN:-false}
readonly ARCHIVE=/var/log/app/archive
mkdir -p "$ARCHIVE"                       # idempotent: no error if it exists

for f in /var/log/app/*.log; do
  [[ -e "$f" ]] || continue              # nullglob-safe: skip the literal glob if no matches
  dest="$ARCHIVE/$(basename "$f")"
  [[ -e "$dest" ]] && { echo "already archived: $f" >&2; continue; }   # don't re-move
  if "$DRY_RUN"; then
    echo "would move: $f -> $dest" >&2
  else
    mv -- "$f" "$dest"
  fi
done

Why: mkdir -p and the [[ -e "$dest" ]] guard make a re-run a no-op instead of an error or a double-move — the definition of idempotent. The DRY_RUN env flag lets an operator preview before mutating state, which the checklist requires of any mutating script. The [[ -e "$f" ]] || continue guards the case where the glob matches nothing (without nullglob, $f would be the literal *.log). Covered in idempotency & state. </details>

Challenge 4 (intermediate) — instrument a bare cron job with the four metrics

This script runs from cron and “works,” but monitoring is blind to it — it fails Observability. Add the four standard metrics and the EXIT trap so a dashboard can see last-run, last-success, exit code, and duration.

#!/usr/bin/env bash
set -Eeuo pipefail
sync_data() { rsync -a /data/ backup:/data/; }
sync_data

<details> <summary>Solution</summary>

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="${0##*/}"
source /usr/local/lib/metrics.sh          # provides metric_set (L34)

on_exit() {
  local rc=$?
  metric_set "myapp_sync_last_exit_code"  "$rc"          "script=\"$SCRIPT_NAME\""
  metric_set "myapp_sync_duration_seconds" "$SECONDS"    "script=\"$SCRIPT_NAME\""
  (( rc == 0 )) && metric_set "myapp_sync_last_success_seconds" "$(date +%s)" "script=\"$SCRIPT_NAME\""
}
trap on_exit EXIT

SECONDS=0
metric_set "myapp_sync_last_run_seconds" "$(date +%s)" "script=\"$SCRIPT_NAME\""
sync_data

Why: *_last_run_seconds is set at the start (so a script that dies mid-run still records that it ran); *_last_success_seconds, *_last_exit_code, and *_duration_seconds are set from the EXIT trap so they’re written on every exit path, success or failure. Those four feed the four uniform alert rules (stale-run, last-run-failed, success-stale, duration-anomaly), turning “did the cron fire?” into a monitored property. SECONDS is a bash builtin that counts wall-clock seconds since it was zeroed. Covered in monitoring & metrics. </details>

Challenge 5 (advanced) — write the review verdict and the lifecycle.yaml

A PR promotes s3-nightly-sync.sh to scripts/active/. It has: a strict-mode header, Bats tests passing in CI, shellcheck-clean, a --dry-run flag. It does not have: any metric_set calls, an owner_team/runbook, or a lifecycle.yaml. Give the review verdict (which categories fail, and therefore which state it may enter) and write the tracker file it should ship with.

<details> <summary>Solution</summary>

Verdict — it may NOT go straight to ACTIVE. It passes Boilerplate, Args & UX, Testing. It fails Observability (no metrics, so alerts can’t see it) and fails Documentation/ownership (no owner team, no runbook). Per the lifecycle policy, ACTIVE requires metrics, alert rules, a named owner team, and a runbook. The correct outcome is PROVISIONAL: merge it, let it run with monitoring marked experimental, and give it 30 days to add metrics + runbook + owner before promotion — or roll it back.

name: s3-nightly-sync.sh
state: PROVISIONAL
owner_team: TODO-assign-before-active        # blocks promotion to ACTIVE
on_call_runbook: TODO-write-before-active
metrics_dashboard: null                      # add metric_set calls first (L34)
created: 2026-07-19
promoted_to_provisional: 2026-07-19
promoted_to_active: null
last_review: 2026-07-19
replacement_candidate: null
deprecated_after: null
retired_after: null
dependencies:
  - lib/log.sh
  - lib/fs.sh
notes: |
  Passes tests + shellcheck. Provisional until it emits the 4 standard
  metrics and has an owner_team + runbook (Observability + Docs gaps).

Why: the checklist is a gate, not advice — an unmonitored, unowned script that “works” is exactly the future orphan the whole capstone exists to prevent. PROVISIONAL with a 30-day deadline forces the missing pieces to be added deliberately instead of never. Covered by this lesson’s review checklist and lifecycle policy. </details>

Challenge 6 (advanced) — run the sunset decision and the retirement ceremony

legacy-report.sh has been in scripts/active/ for years. Its myapp_report_last_success_seconds has been stale for 40 days; the owning team was reorganised out of existence last month; one Prometheus alert still references its metric. Which sunset triggers fire, what do you verify before retiring, and what is the ceremony?

<details> <summary>Solution</summary>

Triggers fired — two of the five: Trigger 3 (repeated failures without a fix: success stale > 30 days) and Trigger 4 (owner team departed with no successor). Either alone justifies deprecation; both together make the decision easy.

Verify before retiring (Trigger 2 check — are there consumers?):

grep -rn "legacy-report.sh"        /etc /opt /usr/local/bin /var/spool/cron
grep -rn "myapp_report_"           /etc/prometheus            # the dangling alert
git log --all --oneline -- scripts/active/legacy-report.sh    # any recent activity?

The grep over /etc/prometheus finds the one lingering alert — that’s not a real consumer of the output, it’s a monitoring rule for a metric that’s about to stop existing, so it gets archived, not preserved.

The ceremony:

sudo systemctl disable --now legacy-report.timer            # 1. stop scheduling it
git mv scripts/active/legacy-report.sh scripts/retired/2026/ # 2. archive, keep in VC
# 3. edit legacy-report.lifecycle.yaml: state: RETIRED, retired_after: 2026-07-19
git mv prometheus/rules/legacy-report.yml prometheus/rules/retired/  # 4. archive the alert

Final commit message:

retire: scripts/active/legacy-report.sh

Success metric stale 40 days (Trigger 3); owner team disbanded (Trigger 4).
No output consumers found; the one lingering Prometheus alert is archived.
Timer disabled. Kept in VC for audit.

Then note in the team weekly: “Retired legacy-report.sh, total active scripts now N-1.”

Why: the worst state a script can be in is “cron still listed, fails silently every night” — retirement is the honest outcome, and doing it as a tracked ceremony (disable, archive, flip the tracker, archive alerts, record why) keeps the audit trail intact and the active-script count moving the right way. Covered by this lesson’s sunset criteria and retirement ceremony. </details>

Common beginner mistakes

These are misconceptions about the discipline itself, distinct from the code-level anti-patterns above (which are things you reject at review). Each is a wrong mental model plus the right one.

“The checklist and lifecycle.yaml are bureaucracy that slow me down.”

Wrong model: process is overhead that gets in the way of shipping. Right model: the ten minutes of paperwork is radically cheaper than the day you’ll spend at 03:00 debugging an unowned, unmonitored, undocumented script that broke in a way no one can diagnose. The discipline isn’t ceremony for its own sake — it’s the cheapest known way to stop shell scripts from becoming the most expensive part of your infrastructure five years out.

“Once the script is in prod and working, it’s done.”

Wrong model: ACTIVE is the finish line. Right model: ACTIVE is a state with ongoing obligations — emit metrics, keep the runbook current, pass the annual “still earning its keep?” review, hand over ownership cleanly when people leave. The only truly finished state is RETIRED. A script you stopped thinking about the day it merged is already drifting toward the graveyard.

“A script with no owner is fine as long as it still works.”

Wrong model: ownership only matters when something’s broken. Right model: an unowned script is a liability the instant it breaks, because there’s nobody to page and nobody who knows what breaks downstream if it stops. Owner-departure is the single biggest cause of the graveyard — which is why every ACTIVE script names an owner_team, and an empty one auto-triggers deprecation.

“More scripts means more automation, which is good.”

Wrong model: script count is a productivity metric to grow. Right model: the number that predicts pain is total active scripts, and you want it flat or shrinking. Every script is a standing maintenance liability, so the high-leverage move is often deleting one (its job absorbed by a real tool), not adding one. Automation is the outcome; a sprawling script fleet is the cost.

“shellcheck-clean and shfmt-clean means the script is correct.”

Wrong model: green lint equals correct. Right model: linters catch a class of bugs — quoting, word-splitting, obvious logic slips. They say nothing about whether the script is idempotent, whether its blast radius is acceptable, whether it uses the right exit codes, or whether shell was even the right tool. Green lint is necessary, not sufficient; correctness still needs tests plus human judgment.

“Deprecated means deleted.”

Wrong model: deprecating a script removes it. Right model: DEPRECATED still runs (usually logging a warning) and still has consumers you’re weaning off — it’s a signalled, dated transition, not a removal. RETIRED is the removal from active use, and even then the file stays in version control forever for audit. Skipping the deprecation window and deleting outright breaks consumers you didn’t know existed.

“If I write the script well enough, I won’t need the lifecycle policy.”

Wrong model: quality is a substitute for process. Right model: quality delays the graveyard, it doesn’t prevent it — the surrounding system gets refactored, requirements change, and the author leaves, no matter how clean the code. Lifecycle discipline is orthogonal to quality: a beautifully-written script with no owner and no monitoring is still an orphan-in-waiting.

Glossary

Closing — What This Course Is Really About

If you’ve read all 42 lessons in order, you now have the equivalent of 4-5 years of senior-engineer apprenticeship in production shell scripting, distilled. But the deeper lesson isn’t any specific pattern.

The deeper lesson is shell is a serious engineering surface when treated with the same discipline as any compiled language: version-controlled, tested, monitored, reviewed, owned, lifecycle-managed, retired. Most teams treat shell as a graveyard of one-off tools because it’s easy to do that — write a script, drop it on a host, never think about it again. That’s how you end up with 200 scripts on every box, half of which fail silently every night, and nobody knows which ones still matter.

The investment in lifecycle policy, review checklist, standard metrics, and sunset criteria is not bureaucracy — it’s the cheapest way to keep shell scripts from becoming the most expensive part of your infrastructure five years out.

The series ends here. Use it. Keep the checklists on the wall. Retire scripts ruthlessly. And when in doubt, source lib/log.sh first.

shellstyle-guidereview-checklistlifecyclesunsetproduction-readinessengineering-disciplinecapstonemetricsdeprecation
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments