Before shipping a script that begins with `#!/bin/sh` to hosts where /bin/sh is dash, how would you check it for bash-only constructs, and what does `dash -n script.sh` fail to catch?
answer
- parse-only is not run-only
- -n sees grammar, not missing builtins
- Debian wrote a tool for exactly this
- shellcheck reads the shebang for its dialect
- the real test is the real interpreter
basics
~20 sLint it with shellcheck under sh rules, run Debian's checkbashisms over it, and actually execute it under the real interpreter in a throwaway container. A -n parse check only catches syntax errors, so runtime bashisms such as [[ ]], local and echo -e sail straight through.
solid answer
~50 sUse three layers, weakest first. `dash -n script.sh` asks the real shell to parse but not execute the file — it catches grammar-level bashisms such as `arr=(a b c)`, `function f { }` and process substitution, and nothing else. `checkbashisms` from Debian's devscripts package greps the text for known bash-only constructs; it is broader but text-based, so it produces both false positives and misses. `shellcheck` is the one worth wiring into CI: given a `#!/bin/sh` shebang, or `--shell=sh`, it reports every construct that POSIX does not define with its "In POSIX sh, ... is undefined" family of warnings. None of them replaces running the thing: execute the script's tests under `dash` or `busybox sh` in a container. And note that `bash --posix` is not a check at all — POSIX mode does not disable bash's own keywords.
code
bash · 11 lines# 1. real parser, syntax errors only
dash -n deploy.sh
# 2. Debian devscripts text scan for known bashisms
checkbashisms deploy.sh
# 3. static analysis with POSIX rules forced on
shellcheck --shell=sh deploy.sh
# 4. the only conclusive one: run it where it will run
docker run --rm -v "$PWD:/w" -w /w alpine:3 sh /w/deploy.sh --dry-rungo deeper
Know that shellcheck exists and that it is worth running on any script you write. Recognise that a script parsing without error is not the same as it working.
Explain what each check can and cannot see: -n is grammar only, checkbashisms is a text scan with false positives, shellcheck reads the shebang and flags undefined-in-POSIX constructs.
Argue for running the script against the real target image, because dialect linting says nothing about GNU-versus-BusyBox utility differences, and show where you would place these checks in a pipeline.
Decide the standard: which linter gates a merge, whether warnings fail the build, and whether the organisation targets POSIX at all or simply guarantees bash on every image and lints for that instead.
## Why eyeballing it does not work Bashisms are invisible to people who write bash all day, because they read as ordinary shell. `[[ -n $x ]]`, `source lib.sh`, `local n=1`, `echo -e`, `${file%.*}` versus `${file/.txt/}`, `read -a`, `$'\n'`, `<(cmd)`, `&>log` — some of those are POSIX and some are not, and the boundary is not intuitive. The only reliable checks are mechanical. ## Layer 1: parse it with the target shell ```sh dash -n deploy.sh busybox sh -n deploy.sh ``` `-n` is the POSIX "read commands but do not execute them" option. It uses the real parser, so it is authoritative about grammar, and it is safe to run on a script that would otherwise delete things. What it catches is anything that is a **syntax** error in POSIX: ```sh arr=(a b c) # caught: parenthesis in an assignment function deploy { } # caught: the function keyword diff <(a) <(b) # caught: process substitution ``` What it misses is everything that is *syntactically valid but semantically absent*. `[[ -n "$x" ]]` parses fine — `[[` is simply a command word to dash, and the failure only appears at runtime as `[[: not found`. The same goes for `local`, `source`, `echo -e`, `${var/old/new}` (a bad-substitution error at expansion time) and every bash-only option to a builtin. A clean `-n` run therefore proves very little. ## Layer 2: checkbashisms `checkbashisms` ships in Debian's **devscripts** package and exists because Debian had to audit thousands of maintainer scripts when it moved `/bin/sh` to dash. It is a text scanner that knows a long list of bash-only constructs and reports them with line numbers. Because it is textual, it flags things inside comments or strings occasionally, and it cannot see a bashism that arrives through a variable or an `eval`. Treat its output as a to-do list, not a verdict — but it is fast, it needs no execution, and it catches the runtime-only cases that `-n` cannot. ## Layer 3: shellcheck, in CI `shellcheck` is the one to standardise on. It parses the script properly, infers the dialect from the shebang, and can be forced with `--shell=sh` or an in-file `# shellcheck shell=sh` directive. Non-POSIX constructs come out as warnings of the form "In POSIX sh, `[[ ]]` is undefined" — a whole numbered family reserved for portability, separate from its ordinary correctness warnings. Because it understands scope and quoting it produces far fewer false alarms than a grep, and it fails a build with a non-zero exit status, which is what actually keeps a repository honest over time. ## Layer 4: run it Static analysis cannot tell you that the script calls `readlink -f` (not portable to macOS), `sed -i` without an argument (BSD sed wants one), or `grep -P`. Those are not shell dialect at all — they are the *utilities*, and the same portability question applies to them. So run the script's tests under the real interpreter on the real base image: ```sh docker run --rm -v "$PWD:/w" -w /w debian:stable dash ./run-tests.sh docker run --rm -v "$PWD:/w" -w /w alpine:3 sh ./run-tests.sh ``` ## The check that is not a check `bash --posix script.sh` and `set -o posix` look like portability tests and are not. POSIX mode makes bash *conform* on a list of specific behaviours; it does not remove `[[ ]]`, arrays, `local`, `source` or brace expansion from the language. A script full of bashisms passes `bash --posix` cleanly and then dies under dash. Likewise, running `sh script.sh` on a RHEL box is running bash. ## What good practice looks like Put `shellcheck` in CI over every script in the repository, let the shebang declare the dialect, and run at least one smoke test on the minimal image the script will actually meet in production. That combination costs seconds per build and removes an entire class of "works on my machine" incidents.
- Why does `dash -n` miss `[[ -n "$x" ]]` but catch `arr=(a b c)`?They fail at different stages. The array assignment is invalid POSIX grammar, so the parser rejects it and `-n` reports it. `[[` is a perfectly legal command word, so parsing succeeds; the failure is a runtime `command not found`, and `-n` never reaches runtime.
- Portability is not only about the shell. What else does a #!/bin/sh script commonly trip over?The external utilities. GNU and BSD versions of `sed`, `date`, `readlink`, `grep` and `stat` differ in options and defaults, and a BusyBox image gives you cut-down applets of all of them. Static shell linting cannot see any of that, which is why running the script on the target base image is the check that matters.
saying these in an interview costs you the question
- Treats a clean sh -n as proof the script is portable
- Claims bash --posix disables bash keywords
- Assumes shellcheck knows the dialect without a shebang or flag
- Thinks checkbashisms executes the script to test it
- Forgets that GNU and BusyBox utilities differ too