Writing
set -e killed the error handler I wrote
Twice in one day, a CI step failed on a command whose failure I had explicitly handled. The handler was correct. It just never ran.
4 min read · ci-cd · bash · github-actions
Every shell step in this project's GitHub Actions workflows starts the same way, because that is the advice everyone gives and it is good advice:
set -euo pipefailFail on error, fail on an unset variable, fail on a broken pipe. It turns the default shell behaviour — sail past a failed command and keep going with garbage — into something you can reason about.
Then twice in one day it failed builds that had already succeeded, on lines where I had written the fallback myself.
The first one
The deploy step captures the URL that vercel deploy prints. The CLI emits
JSON on a non-TTY, so parsing it with jq is right — but I had seen the
format vary, so I wrote a fallback:
vercel deploy --prebuilt --prod --skip-domain > deploy.json
url=$(jq -r '.deployment.url // empty' deploy.json)
if [ -z "$url" ]; then
url=$(grep -oE 'https://[A-Za-z0-9._-]+\.vercel\.app' deploy.json | tail -1)
fiLocally, flawless. On the runner, the step failed — on a deployment that had
completed successfully, whose URL was sitting right there in deploy.json.
Under CI=true the CLI prints a bare URL, not JSON. jq was handed a plain
string, could not parse it, and exited non-zero. Under set -e that ends
the step immediately. The if on the next line was never evaluated. I had
written the recovery for exactly this case and the shell exited before
reaching it.
The second one
Different workflow, same afternoon. After promoting a deployment, verify the site is serving:
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$SITE_URL/")
if [ "$code" != "200" ]; then
echo "::warning::site returned $code"
fiThe custom domain still pointed at the registrar's parking IP, which accepts
the TCP connection and then never answers. curl burned its timeout and
exited 28. Non-zero. Step over. The warning I wrote for precisely this
situation never printed.
Two different commands, two different failure modes, one bug: set -e fires
before your handler does.
Why it is easy to miss
set -e has a well-known list of exemptions. A command is not fatal if it is
the condition of an if, part of a && or || chain, negated with !, or
in a while test. Every one of those exemptions is about a command whose
result you are testing.
if jq -e . deploy.json >/dev/null 2>&1; then ... # exempt
url=$(jq -r '.url' deploy.json) # NOT exemptThe second line looks like it is testing something too — that is what the
fallback below it is for. But the exemption is syntactic, not semantic. The
shell has no idea you intended to inspect $url afterwards. Assignment from a
command substitution is a plain command, and a plain command that fails ends
the script.
So the rule that actually predicts behaviour is narrower than "handled failures are fine":
set -eexempts a command whose exit status is consumed by the shell's own syntax. Consuming it yourself, one line later, is too late.
Both fixes
For jq, test first and only then parse — the if condition is an exempt
position, so a parse failure becomes a branch instead of an exit:
url=""
if jq -e . deploy.json >/dev/null 2>&1; then
url=$(jq -r '.deployment.url // empty' deploy.json)
fi
if [ -z "$url" ]; then
url=$(grep -oE 'https://[A-Za-z0-9._-]+\.vercel\.app' deploy.json | tail -1)
fiFor curl, the exit code is the information, so capture it deliberately
rather than letting the shell act on it:
probe() {
local url="$1" code rc
set +e
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$url")
rc=$?
set -e
if [ "$rc" -ne 0 ]; then echo "unreachable(curl:$rc)"; else echo "$code"; fi
}
alias_code=$(probe "https://sadiqeen.vercel.app/")Turning set -e off for three lines is the honest version of what the code
already meant. curl || true would have worked too, but it discards the
distinction between "returned 503" and "never connected," which on that step
is the whole diagnosis — the parking-IP hang and a bad deploy look identical
without it.
What I took from it
The failure mode was not that error handling was missing. It was that error handling was present, looked right in review, and was unreachable — which is strictly worse, because it reads as covered.
set -e is still the right default. I am not arguing for turning it off. But
it makes a specific and narrow promise, and I had been reading it as a much
broader one for years without noticing, because the two readings agree on
every script that never handles a failure.
The tell, in hindsight, is anywhere the shell captures output from something
that legitimately fails. Every one of those is a place where the recovery path
below it may be decorative, and neither of these bugs was reachable from my
laptop — one needed CI=true, the other needed DNS pointing somewhere real
and wrong. Both were found by running the thing, in the place it runs.