04 — Reading someone else's bash
Read this first: this lesson is about reading rather than writing. It covers the four
constructs that make real shell scripts look like line noise — parameter expansion, case, [[ ]]
versus [ ], and arrays — so that deploy/deploy.sh becomes a file you can
work through rather than a wall.
Time: about 40 minutes. Assumes lesson 03.
Parameter expansion: cutting strings without a tool
You have used "$VAR". Bash can also slice while expanding, and this is the syntax that makes
scripts look cryptic.
line="JWT_SECRET=changeme"
echo "${line%%=*}" # JWT_SECRET
echo "${line#*=}" # changeme
The pattern is:
| Form | Removes | From |
|---|---|---|
${var#pattern} | Shortest match | The start |
${var##pattern} | Longest match | The start |
${var%pattern} | Shortest match | The end |
${var%%pattern} | Longest match | The end |
A memory hook that works: on a keyboard, # is left of %, and # cuts from the left. Doubling the
character makes it greedier.
Predict: with path="/srv/motorph/deploy/deploy.sh", what do these give?
echo "${path##*/}" # deploy.sh -- longest match from the start, so everything up to the last /
echo "${path%/*}" # /srv/motorph/deploy -- shortest match from the end
echo "${path%.sh}" # /srv/motorph/deploy/deploy
That is basename and dirname without running a program.
Two more you will see constantly:
echo "${GITHUB_SHA:0:12}" # substring: 12 characters starting at index 0
echo "${#APP_SECRET}" # length
${GITHUB_SHA:0:12} is exactly how the real pipeline builds its image tag, and ${#VAR} is how the
deploy workflow's preflight step reports secret lengths without ever printing a value.
case — globbing, not regex
case "$val" in
\'*\') continue ;;
*\$[A-Za-z_]*) bad="$bad $key" ;;
*) : ;;
esac
case matches glob patterns, the same wildcards as filenames: * any characters, ? one
character, [abc] a character class. Each branch ends with ;;.
Read the two patterns above:
\'*\'— starts and ends with a literal single quote. (The backslashes stop the shell eating the quotes.)*\$[A-Za-z_]*— contains a literal$followed by a letter or underscore.
That is the lesson 09 guard: "a value already wrapped in single quotes is
safe; a value containing $name is dangerous."
case is preferred over if chains when matching one value against several shapes — it is faster to
read and cannot accidentally do word splitting.
[[ ]] versus [ ]
Both test conditions. They are not the same thing.
[ ] | [[ ]] | |
|---|---|---|
| What it is | A program (/usr/bin/test) | Bash syntax |
| Word splitting | Yes — unquoted variables break it | No |
Regex with =~ | No | Yes |
Portable to sh | Yes | No, bash only |
Predict: x="a b". What does [ $x = "a b" ] do?
bash: [: too many arguments
It broke, because [ is a program and the unquoted $x was split into two words before it ran — so
it received four arguments where it expected three. [[ $x = "a b" ]] works, because [[ ]] is
syntax and does not word-split.
The house rule in this repository, which is a good one: use [ ] by default, and reach for
[[ ]] only when you need regex. That way [[ ]] in a script is a signal that something
non-trivial is happening. From deploy/deploy.sh:
validate_tag() {
[[ "$1" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]] || die "invalid image tag: '$1'"
}
That is a genuine regex, and it exists because the tag arrives from a workflow input box — untrusted text that will be interpolated into a compose variable. Validating it is not pedantry.
Arrays: the right way to build an argument list
Predict: you want to pass optional flags to a command. Does this work?
args="-f a.yml -f b.yml"
docker compose $args up -d
It appears to. It breaks the moment a path contains a space, and it breaks silently — the flag is split in the wrong place and compose looks for a file with half a name.
The correct tool is an array:
args=(-f a.yml -f b.yml)
docker compose "${args[@]}" up -d
"${args[@]}" — quoted, with @ — expands to zero or more correctly-separated words, whatever
they contain. "${args[*]}" with a star would join them into one word, which is almost never what
you want.
Real usage, from deploy.sh:
compose() {
local extra; mapfile -t extra < <(monitoring_args)
docker compose --project-directory "$REPO_ROOT" --env-file "$ENV_FILE" -f "$APP_YML" "${extra[@]}" "$@"
}
Three things at once:
monitoring_argsprints either two lines (-fand a path) or nothing.mapfile -t extra < <(...)reads those lines into an array, one per element.-tstrips the newlines."${extra[@]}"expands to zero or two words, correctly. With a plain string it would expand to one empty word when monitoring is off — and compose would reject an empty argument.
< <(...) is process substitution: it makes a command's output look like a file. It matters here
for a reason worth knowing — piping into while or mapfile would run it in a subshell, and any
variable set inside would vanish when the subshell exits.
That exact subtlety is why the documentation workflow's content-leak check is written
done < <(...) rather than ... | while: the loop sets fail=1, and in a subshell that assignment
would be lost and the gate would pass while finding problems.
Break it on purpose: the disappearing variable
count=0
printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)); done
echo "count = $count"
Predict the output. Most people say 3.
count = 0
The while ran in a subshell because it was on the right of a pipe. It counted correctly and then
the subshell exited, taking count with it. The fix:
count=0
while read -r line; do count=$((count + 1)); done < <(printf 'a\nb\nc\n')
echo "count = $count"
count = 3
One more idiom: { ... || true; }
current_tag() { { grep -s '^CURRENT_TAG=' "$STATE_FILE" || true; } | cut -d= -f2; }
Reading it now: grep exits 1 when it matches nothing, and under set -e that would kill the
script — but "no tag recorded yet" is a normal state, not an error. || true tolerates it, and the
braces group the command so the tolerance applies to grep alone and not to the whole pipeline.
Recap
${var%%pattern}and${var#pattern}cut strings while expanding;#cuts from the left,%from the right, doubled is greedier.casematches globs, not regex, and is the readable way to test one value against several shapes.[ ]is a program and word-splits;[[ ]]is syntax and does not. Use[[ ]]where you need=~.- Build argument lists as arrays and expand with
"${arr[@]}". - A pipe into
whilecreates a subshell, so assignments inside are lost. Use< <(...).
Next: 05 — Containers 101.