Skip to content
shellhacks.dev
Testing

How to Check Scripts for Portability With checkbashisms

Check scripts for portability with checkbashisms and ShellCheck, run them under dash and busybox ash, and find the Bash features that break sh scripts.

6 min read
Summarize in AI
Illustration for "How to Check Scripts for Portability With checkbashisms": a shell script in an editor, a table of open ports, test results.

A script that starts with #!/bin/sh promises to run on any POSIX shell, and a single [[ ]] breaks the promise on Debian, Ubuntu and Alpine, where sh is dash or BusyBox ash. Catching this takes two linters and a test run, and it should be automatic. The language rules are in bashisms to avoid in POSIX sh, and the checks assume ShellCheck 0.10, devscripts 2.23 and dash 0.5.12.

Lint with checkbashisms

Shell
sudo apt install devscripts
cat > demo.sh <<'EOF'
#!/bin/sh
files=(a b c)
if [[ -n $1 ]]; then echo "arg: ${1^^}"; fi
diff <(sort a) <(sort b)
EOF
checkbashisms demo.sh
Text
possible bashism in demo.sh line 2 (bash arrays, ${name[0]}, ${name[@]}, ${name[*]}, ${#name[0]}, ${#name[@]}):
files=(a b c)
possible bashism in demo.sh line 3 ([[ ]] should be [ ]):
if [[ -n $1 ]]; then echo "arg: ${1^^}"; fi
possible bashism in demo.sh line 4 (process substitution):
diff <(sort a) <(sort b)

checkbashisms reads a script with an sh shebang and lists constructs that dash does not support. It is a heuristic based on patterns, so it can miss things and produce false positives, but it is fast and it names the feature. Use -f to check scripts even if their shebang says bash, to see what a port would need.

Lint with ShellCheck

Shell
shellcheck -s sh demo.sh
shellcheck -s dash demo.sh
Text
In demo.sh line 2:
files=(a b c)
      ^-- SC3030 (warning): In POSIX sh, arrays are undefined.

In demo.sh line 3:
if [[ -n $1 ]]; then echo "arg: ${1^^}"; fi
   ^-- SC3010 (warning): In POSIX sh, [[ ]] is undefined.

ShellCheck's SC3xxx codes are the POSIX portability checks. It understands the shebang, so a file starting with #!/bin/sh is checked as sh by default, and the -s flag overrides it. ShellCheck also finds quoting and logic bugs, so run it on every script regardless, as in ShellCheck warnings explained. Keep a .shellcheckrc for project-wide settings.

Run it under real shells

Shell
dash -n script.sh && echo "dash syntax OK"
dash script.sh --self-test
docker run --rm -v "$PWD":/w -w /w alpine:3.22 sh script.sh --self-test
docker run --rm -v "$PWD":/w -w /w busybox:1.37 ash script.sh --self-test

Linters do not execute code, so some failures appear only at run time: a builtin missing in one shell, an option unsupported by BusyBox sed or grep. Running the real tests under dash and in an Alpine container (BusyBox plus musl) catches those. The runs are the same as in testing against dash and BusyBox and the test matrix in testing under multiple shells.

What linters cannot see

Shell
sed -i 's/a/b/' file          # GNU only; BSD sed needs -i ''
grep -P '\d+' file            # GNU only
date -d yesterday             # GNU only
readlink -f file              # missing on older macOS
echo -e "a\tb"                # not portable; use printf

These are valid shell and non-portable tools. They need testing on each target or avoidance through a different approach. The cases are covered in sed on macOS and Linux and date on macOS and Linux. printf instead of echo -e is the usual fix for escape sequences.

Put it in CI

YAML
jobs:
  portability:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get install -y shellcheck devscripts dash
      - run: git ls-files '*.sh' | xargs -r shellcheck -s sh
      - run: git ls-files '*.sh' | xargs -r checkbashisms
      - run: dash tests/run.sh

Make the lint a required check so a stray bashism cannot merge; the pattern is in ShellCheck in CI. Decide up front whether the project targets sh or Bash. Most scripts are better as Bash with a clear #!/usr/bin/env bash, and portability is a cost worth paying only for installers, Docker entrypoints on Alpine and tools meant for many systems.

Static checks first

Two linters cover the first level of portability. checkbashisms, from the Debian devscripts package, scans a script declared as POSIX sh and reports constructs that only Bash supports, such as arrays, double brackets, process substitution and the function keyword. ShellCheck reads the shebang and warns about constructs that do not belong to the shell named there, so a script starting with #!/bin/sh gets warnings for Bash-only syntax. Run both: they overlap but find different things. Fix what they report, and for deliberate exceptions add a ShellCheck directive with a comment explaining why. These checks are fast and belong in the editor and in CI, where they catch most issues before a human reads the code.

The shebang is the claim the script makes about itself. If it says sh, the script promises to work in any POSIX shell; if it says bash, it needs Bash. Make the claim true, or change it.

Running under several shells

Linting cannot catch everything, so run the script. Dash is the best test of POSIX conformance because it is small and strict, and it is the system shell on Debian and Ubuntu. BusyBox ash is the shell in Alpine containers and embedded systems, with its own set of applets and options. Bash in POSIX mode, started as sh, catches some differences but accepts more than a strict shell does. Test on a container or virtual machine for each platform you support, with a matrix in CI that runs the test suite under each shell. The tests should exercise the main paths, including the failure paths, since error handling varies most between shells.

The syntax is only half of it. The utilities differ too, and a script that passes in Dash can still fail on a system where sed -i or date -d behaves differently.

The commands, not just the shell

GNU and BSD versions of common tools differ in options, and BusyBox implements a subset. The in-place edit option of sed takes an argument on macOS and none on GNU. The date command parses strings with different flags. Options for find, xargs, sort, stat and readlink differ in ways that break scripts silently. Stick to the options listed in the POSIX specification where possible, and when you need more, detect the platform and branch, wrapping each difference in a function with a clear name. Check the specification for an option before using it, and document the platforms supported in the README. A table of tools and the minimum options you rely on, kept with the script, helps reviewers notice when a change adds a new dependency.

Avoid using echo with escape sequences or options, since shells treat them differently. Use printf, whose behaviour is specified.

What linters cannot see

Static analysis cannot know the contents of variables, the state of the file system or the behaviour of external programs. It misses locale dependence, where sort order and character classes change with the environment, and the effects of different default permissions. Time, encoding and line endings are further sources of difference. Cover these with tests that run under different locale settings and with input containing spaces, newlines and non-ASCII characters. Add a CI job that runs the tests in a minimal container with a stripped environment, which exposes the assumptions that a developer machine hides. Keep a list of known limitations in the documentation, and when users report a failure on an unusual platform, add a test for it, so the matrix grows from real problems rather than guesses. Portability is a property you keep by testing, not one you achieve once.

Where this does not apply

If the script declares Bash, the right check is that Bash exists on the target and is recent enough: macOS ships Bash 3.2, which lacks associative arrays and mapfile. Linting cannot test that, so state the requirement and check BASH_VERSINFO at the top of the script. See the checkbashisms manual page and the ShellCheck SC3010 page.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.