Bash scripting — scripts that survive a stranger's machine

SkillDev tools

Use when writing or hardening a shell script that must survive another machine — a CI step, install script, cron job, git hook, devcontainer entrypoint: strict-mode leaks, quoting/word-splitting, arrays, trap cleanup, bash-vs-POSIX portability, ShellCheck findings. NOT CI workflow structure, runners, caching or matrix (that is `github-actions`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Bash scripting — scripts that survive a stranger's machine skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/bash-scripting/SKILL.md and read by ahel’s review.

You are the shell author who has been burned: by an unquoted "$@" that exploded a path with spaces, by rm -rf "$DIR/" where $DIR was empty, by a trap that fired twice, by a set -e that swore it caught errors and didn't. Write every script as if it will run in CI, in a container, and on a 2014 Mac with bash 3.2 — because eventually it will.

The first decision is not a line of code. It is: which shell am I targeting? That choice decides what you are allowed to write. Make it before the shebang.

The header you start every bash script with

#!/usr/bin/env bash
set -euo pipefail        # see "Strict mode, honestly" — this is a baseline, not a force field
IFS=$'\n\t'              # split words on newline/tab only, never on spaces
  • #!/usr/bin/env bash — find bash on PATH; do not hardcode /bin/bash, which is 3.2 on macOS and may not exist on some images.
  • set -e — exit on an uncaught non-zero command. Leaky (below), still worth having.
  • set -u — error on an unset variable, so a typo'd $OUPUT fails loud instead of expanding to empty.
  • set -o pipefail — a pipeline fails if any stage fails, not just the last. Bashism — not in POSIX sh.
  • IFS=$'\n\t' — stops the classic "unquoted expansion splits on every space" bug at the source.

Pick your shell first

set -o pipefail, arrays, [[ ]], and local are bashisms. If your shebang is #!/bin/sh you may not get bash — on Debian/Alpine sh is dash/busybox.

#!/usr/bin/env bash#!/bin/sh (POSIX)
Where it runsanywhere bash is installedevery Unix; the only safe choice for an unknown box
You GAINarrays, [[ ]], pipefail, local, ${var,,}, process substitution <(…)maximal portability, smaller deps
You LOSEnothing (if bash is guaranteed)all the above — POSIX sh has none of them
Lint withshellcheck script.shshellcheck -s sh script.sh
Test withbash script.shdash script.sh

Two traps to internalize: macOS /bin/bash is 3.2 (no declare -A, no ${var,,}, no mapfile); and bash 5.3 added ${ cmd; } / GLOBSORT / read -E that will not run on either of the above. Pick a floor and stay above it — the full bash-vs-POSIX feature matrix, bash 3.2 workarounds, dash/busybox gotchas, the 5.3 features to guard, and how to test one script under several shells are in references/portability.md.

Strict mode, honestly

set -e is not a force field. It is silently suppressed in three places, and people ship broken scripts because they trusted it:

  1. In an assignment with command substitution. local x=$(failing) — the assignment succeeds (exit status is the local/assignment, not the substitution).
  2. In a condition. Anything in if, while, &&, ||, or after ! is exempt by design — set -e would make if grep … unusable otherwise.
  3. Inside functions called in a condition: a failing line won't abort.

So: do not lean on set -e for control flow. Check what matters explicitly.

# Bad — set -e will NOT catch this; x is empty, script sails on
local x=$(curl -fsS "$url")

# Good — split declaration from assignment so the substitution's status is seen
local x
x=$(curl -fsS "$url") || { echo "fetch failed" >&2; return 1; }

trap 'echo "failed at line $LINENO" >&2' ERR gives you a breadcrumb on the uncaught failures set -e does catch. To deliberately ignore a non-zero exit, say so: cmd || true (and a comment why), never a bare unchecked cmd.

Quoting — the highest-value section

Unquoted expansions are the №1 cause of shell bugs and of the data-loss story everyone has heard. Quote every expansion unless you have a specific reason not to.

BadGoodWhy
rm $filerm "$file"space/glob in $file becomes multiple args (SC2086)
func $@func "$@""$@" preserves each arg verbatim; $@ re-splits them
x=$(cmd)echo $xecho "$x"unquoted output word-splits and glob-expands
[ $x = y ][ "$x" = y ]empty/spaced $x makes [ a syntax error
for f in $(ls)for f in ./*parsing ls breaks on spaces/newlines (SC2045)
rm -rf $DIR/see belowthe disaster

The rm -rf disaster: if $DIR is unset/empty, rm -rf $DIR/ becomes rm -rf /. Guard the variable and quote it:

: "${DIR:?DIR must be set}"   # abort with a message if unset or empty
rm -rf "${DIR:?}"/           # belt and braces: fail rather than expand to /

Use [[ … ]] in bash (no word-splitting inside, supports =~, &&); use [ … ] in POSIX sh. [ a == b ] is a bashism — POSIX [ uses =.

Arrays, not space-split strings

The instant an argument list is built from a string, spaces betray you. Build it as an array and expand "${arr[@]}" (each element stays one argument).

# Bad — flags string splits wrong if any value contains a space
flags="--name my project --force"
docker run $flags image          # 5 args, "my" and "project" split apart

# Good — array; "${flags[@]}" expands to exactly 4 arguments
flags=(--name "my project" --force)
docker run "${flags[@]}" image

Iterating files: never parse ls. Use a glob, or find -print0 with a NUL-delimited read so even newlines in names are safe.

# Good — glob; the ./ prefix protects files named like "-rf"
for f in ./*.txt; do
  [ -e "$f" ] || continue       # guard the no-match case (glob stays literal)
  process "$f"
done

# Good — robust against spaces AND newlines in filenames
find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
  process "$f"
done

macOS bash 3.2 has no mapfile/readarray; the find -print0 loop above is the portable way to collect names.

Cleanup with traps

A script that creates temp state must remove it even when interrupted. Register one EXIT trap right after you create the resource — EXIT fires on normal exit, on set -e abort, and after INT/TERM, so you don't need per-signal traps.

tmp=$(mktemp -d)                          # never a fixed /tmp/foo path (race + collision)
trap 'rm -rf "$tmp"' EXIT                 # one trap, covers every exit path

work_in "$tmp"

Make cleanup idempotent (rm -rf tolerates a missing dir) so a double-fire or a re-entry is harmless. Track background PIDs and reap them in the same trap:

server & srv_pid=$!
trap 'kill "$srv_pid" 2>/dev/null; rm -rf "$tmp"' EXIT

Input & variables

PatternUse
: "${1:?usage: deploy <env>}"require an argument, abort with a message
env="${1:-staging}"default when omitted
readonly ROOT="$PWD"constants that must not be reassigned
local x (bash)function-scope a variable so it doesn't leak (not POSIX)

Option parsing uses getopts (POSIX, single-dash flags):

verbose=0; out=""
while getopts ":vo:" opt; do
  case "$opt" in
    v) verbose=1 ;;
    o) out="$OPTARG" ;;
    *) echo "usage: $0 [-v] [-o FILE]" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

Use printf '%s\n' "$x" instead of echo "$x" for arbitrary data — echo's handling of -n/-e and backslashes is not portable.

Run ShellCheck — and fix, don't silence

ShellCheck v0.11.0 (2025-08) is the canonical static analyzer. Run it on every script; it catches most of the above before runtime.

shellcheck script.sh             # bash target
shellcheck -s sh script.sh       # verify POSIX-sh compliance

Codes worth memorizing: SC2086 (unquoted expansion → quote it), SC2046 (unquoted $(…) word-splits), SC2164 (cd without || exit), SC2155 (local x=$(cmd) masks the command's exit status — declare then assign).

Silence a finding only with a justified directive on the line directly above it, never project-wide:

# shellcheck disable=SC2086  # word-splitting is intentional: $flags is a flag list we control
some_cmd $flags
# shellcheck source=lib/common.sh   # resolve a dynamic `source` for cross-file analysis
. "$dir/common.sh"

Wire shellcheck into CI as a run: step — the workflow scaffolding around it belongs to ../github-actions/SKILL.md, the shell inside the step belongs here.

Anti-patterns

Anti-patternWhy it bitesDo instead
for x in $(ls *.txt)breaks on spaces/newlines; SC2045for x in ./*.txt; do [ -e "$x" ] || continue
Unquoted $var / $@word-split + glob; SC2086always "$var" / "$@"
cd "$d"; rm -rf .if cd fails you rm the wrong dir; SC2164cd "$d" || exit 1
Trusting set -e for control flowleaks in assignments/conditions/functionscheck explicitly: cmd || { …; exit 1; }
local x=$(cmd)masks cmd exit status; SC2155local x; x=$(cmd) || return 1
Bash 5.3 / declare -A in a 3.2 or sh target"command not found" on the user's boxpick a floor; see references/portability.md
echo "$untrusted" for datanon-portable -n/-e/backslash handlingprintf '%s\n' "$x"
[ a == b ] under #!/bin/sh== is a bashism; dash errorsPOSIX uses [ a = b ]
Fixed temp path /tmp/buildrace + collision + no cleanuptmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
pipefail in a #!/bin/sh scriptnot POSIX; dash ignores or errorsuse bash, or check pipeline status another way

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
bash-scripting-ericrisco
Source
github.com/ericrisco/rsc-harness