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:

  1. POSIX-sh by default. A dot instead of source, = instead of ==, printf instead of echo -e. Works in sh, bash and zsh alike.
  2. If bash is really needed — declare it explicitly instead of hoping for the default:
    myjob:
      before_script:
        - apk add bash    # or apt-get install -y bash
      script:
        - bash -c '... [[ ... ]] ...'
    
    Even better: script: - bash my-script.sh with a #!/usr/bin/env bash shebang, and keep the script in a separate file rather than inline YAML: it diffs better and runs locally with the same bash script.sh.
  3. Check locally via sh right before committing: sh -n .ci/script.sh for syntax, or docker run --rm -v $PWD:/w -w /w alpine sh script.sh to 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.