Ошибки пайплайна: когда я путаю bash и sh

Пишу скрипты локально в zsh/bash, они отлично работают, кладу в .gitlab-ci.yml — и job падает с синтаксической ошибкой на ровном месте. Причина одна и та же: дефолтный шелл в job'ах GitLab — /bin/sh (dash в Debian/Ubuntu-образах), а не bash. Вот мои типовые промахи.

1. Двойные скобки и условия

if [ "$CI_COMMIT_BRANCH" == "main" ]; then   # ✗ sh: == не работает, только =

В sh классика — одинарные = в [ ]. [[ ]] — вообще bash-only.

2. source вместо точки

source .env && ./deploy.sh    # ✗ sh: source: not found
. ./.env && ./deploy.sh       # ✓ так работает везде

3. Подстановки и массивы

files=(dist/*.js)        # ✗ массивы — bash
echo ${files[0]}         # ✗ и это тоже
echo "привет" | read x   # ✗ в sh read выполняется в подшелле, x потеряется

В sh вместо массива — for f in dist/*.js, вместо read из пайпа — x=$(echo ...) или here-string.

4. echo -e и прочие «улучшайзинги»

echo -e "строка\nстрока"   # ✗ в dash флага -e нет, напечатает букву n
printf "строка\nстрока"    # ✓ printf есть везде и ведёт себя одинаково

5. local, pipefail и arithmetic

f() { local x=1; }         # ✗ local — bashism
set -o pipefail            # ✗ dash не знает pipefail
i=$((i+1))                 # ✓ а вот это POSIX и работает

Что я делаю теперь

Три правила, которые выработал:

  1. По умолчанию пишу POSIX-sh. Точка вместо source, = вместо ==, printf вместо echo -e. Это работает и в sh, и в bash, и в zsh.
  2. Если уж нужен bash — объявляю явно, а не надеюсь на дефолт:
    myjob:
      before_script:
        - apk add bash    # или apt-get install -y bash
      script:
        - bash -c '... [[ ... ]] ...'
    
    Ещё надёжнее — script: - bash my-script.sh с шебангом #!/usr/bin/env bash и хранить скрипт отдельным файлом, а не в YAML: и diff'ится лучше, и локально гоняется тем же bash script.sh.
  3. Проверяю локально через sh прямо перед коммитом: sh -n .ci/script.sh — синтаксис, или docker run --rm -v $PWD:/w -w /w alpine sh script.sh — прогон именно в dash.

И маленький бонус: set -e в sh работает, но ведёт себя иначе вокруг пайпов и условий — если пользуюсь &&/|| цепочками, set -e вообще не добавляю, чтобы не гадать, где оно сработает.