Skip to main content

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:

FormRemovesFrom
${var#pattern}Shortest matchThe start
${var##pattern}Longest matchThe start
${var%pattern}Shortest matchThe end
${var%%pattern}Longest matchThe 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 isA program (/usr/bin/test)Bash syntax
Word splittingYes — unquoted variables break itNo
Regex with =~NoYes
Portable to shYesNo, 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_args prints either two lines (-f and a path) or nothing.
  • mapfile -t extra < <(...) reads those lines into an array, one per element. -t strips 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.
  • case matches 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 while creates a subshell, so assignments inside are lost. Use < <(...).

Next: 05 — Containers 101.