Build the mental model
This exercise digs into a specific, extremely common class of bash bug: silent word-splitting caused by storing a list in a plain string instead of an array, then iterating over that string with an unquoted expansion. The bug class matters more than the specific symptom here — write for f in $FILES anywhere in a script and bash will always split that value on IFS (default: space, tab, newline), no matter what's inside it. What makes this bug class especially dangerous is that it produces no error message at all in the failing case; cp simply gets called with arguments that don't correspond to any real file, fails on those specific calls, and the loop moves on to the next word without anyone noticing that "release" and "notes.txt" were never actually one file. Compounding that, the script's own success message — a file count pulled from ls | wc -l — reports on whatever ended up sitting in the release directory rather than on what was actually supposed to be there, so a partial deploy still prints "complete." The mental habit that catches this before it ships is treating every bare $variable expansion as a compile error waiting to happen: ask "could this value ever contain a space, glob character, or be empty?" before you write it unquoted, and treat any list of more than one thing as an array from the first line, not a convenience string that happens to work while your test data is boring. Running scripts through shellcheck, which flags unquoted expansions by default, is the automatable version of that same habit.
Connect it to a real scenario
deploy.sh keeps its list of files to copy in a single string, FILES="app.conf release notes.txt server.py", and then loops with for f in $FILES — no quotes around $FILES. Walk through what that line actually does: bash expands $FILES to the raw text app.conf release notes.txt server.py, and because the expansion is unquoted, splits that text on whitespace before handing it to the for loop, so the loop doesn't see three files, it sees four words: app.conf, release, notes.txt, and server.py. The first and last of those happen to be real filenames, so cp "$SOURCE_DIR/app.conf" and cp "$SOURCE_DIR/server.py" succeed normally. The middle two don't: cp tries to stat ./build/release and then ./build/notes.txt, both of which fail with "No such file or directory," while the actual file, release notes.txt, is never touched by any cp call at all — it's simply absent from every command bash generates. Because the script never checks cp's exit status, those two failures print to stderr and the loop just continues to the next word as if nothing happened. Then look at the last line: it counts whatever is already sitting in $RELEASE_DIR with ls | wc -l, which reports 2 (the two files that actually got copied) — not 3, not an error, just a number that looks plausible enough that nobody skimming the log would think to question it. Reproducing this means running the script as-is, catching the two "cannot stat" lines buried in otherwise normal-looking output, and tracing them back to the single unquoted $FILES expansion in the for line.
Try the working example
#!/usr/bin/env bash
# deploy.sh - copies build artifacts into the release directory and restarts the app
SOURCE_DIR="./build"
RELEASE_DIR="./releases/current"
FILES="app.conf release notes.txt server.py"
mkdir -p "$SOURCE_DIR" "$RELEASE_DIR"
# Set up fake build artifacts so this demo is self-contained
touch "$SOURCE_DIR/app.conf"
touch "$SOURCE_DIR/release notes.txt"
touch "$SOURCE_DIR/server.py"
echo "Deploying files to $RELEASE_DIR..."
for f in $FILES; do
cp "$SOURCE_DIR/$f" "$RELEASE_DIR/"
echo " copied: $f"
done
echo "Restarting app service..."
echo "Deployment complete. $(ls "$RELEASE_DIR" | wc -l) file(s) now in the release directory."Deploying files to ./releases/current...
copied: app.conf
cp: cannot stat './build/release': No such file or directory
copied: release
cp: cannot stat './build/notes.txt': No such file or directory
copied: notes.txt
copied: server.py
Restarting app service...
Deployment complete. 2 file(s) now in the release directory.5-minute try-it
Run the script and compare its final "file(s) now in the release directory" count to the three files it was supposed to copy. Then look closely at the "copied:" lines from the middle two loop iterations — neither of those names matches any real file in ./build, yet the script cheerfully claims to have copied them while the actual file, "release notes.txt", never makes it across. Find the line where the space-separated file list gets expanded without protection from word splitting, and restructure how that file list is stored and iterated so a filename containing a space survives as a single item end to end.
One important caution
Storing a list of items in a plain space-separated string variable instead of a bash array — there's no way to safely iterate over it once any item can contain whitespace
Assuming a script that prints a "Deployment complete" message and exits 0 actually did what it claims — always back a success message with a real count or check, not just an optimistic echo statement
Greg's Wiki — BashPitfalls — Bash / Shell Scripting