Pipeline errors: when I confuse bash and sh
I write scripts locally in zsh/bash, they work beautifully, I put them into .gitlab-ci.yml —
and the job fails with a syntax error out of nowhere. The reason is always the same:
the default shell in GitLab jobs is /bin/sh (dash on Debian/Ubuntu images),
not bash. Here are my typical mistakes.
1. Double brackets and conditions
if [ "$CI_COMMIT_BRANCH" == "main" ]; then # ✗ sh: == doesn't work, only =
In sh the classic is a single = inside [ ]. And [[ ]] is bash-only.
2. source instead of a dot
source .env && ./deploy.sh # ✗ sh: source: not found
. ./.env && ./deploy.sh # ✓ works everywhere
3. Substitutions and arrays
files=(dist/*.js) # ✗ arrays are bash
echo ${files[0]} # ✗ this too
echo "hi" | read x # ✗ in sh, read runs in a subshell and x is lost
In sh, replace arrays with for f in dist/*.js, and read-from-pipe
with x=$(echo ...) or a here-string.
4. echo -e and other "improvements"
echo -e "one\ntwo" # ✗ dash has no -e flag, prints the letter n
printf "one\ntwo" # ✓ printf exists everywhere and behaves the same
5. local, pipefail and arithmetic
f() { local x=1; } # ✗ local is a bashism
set -o pipefail # ✗ dash knows no pipefail
i=$((i+1)) # ✓ this is POSIX and works
What I do now
Three rules I've developed:
- POSIX-sh by default. A dot instead of source,
=instead of==, printf instead of echo -e. Works in sh, bash and zsh alike. - If bash is really needed — declare it explicitly instead of hoping for the default:
Even better:myjob: before_script: - apk add bash # or apt-get install -y bash script: - bash -c '... [[ ... ]] ...'script: - bash my-script.shwith a#!/usr/bin/env bashshebang, and keep the script in a separate file rather than inline YAML: it diffs better and runs locally with the samebash script.sh. - Check locally via sh right before committing:
sh -n .ci/script.shfor syntax, ordocker run --rm -v $PWD:/w -w /w alpine sh script.shto actually run it in dash.
And a small bonus: set -e does work in sh, but behaves differently around pipes
and conditionals — if I chain things with &&/||, I skip set -e entirely
so I don't have to guess where it fires.