A CI script that begins `declare -A seen` runs fine on the Linux runners but fails on a developer's Mac with `declare: -A: invalid option`. What is happening, and what are your options for making that script work there?
answer
- macOS bash is stuck at 3.2
- associative arrays are a bash 4 feature
- the failed declare does not stop the script
- guard on BASH_VERSINFO, not $SHELL
- or restructure so no map is needed
basics
~20 smacOS still ships bash 3.2 as /bin/bash, and associative arrays arrived in bash 4.0, so declare -A is simply not a valid option there. Options: run the script under a newer bash the developer installed, guard on BASH_VERSINFO and fail fast, or restructure to avoid the map.
solid answer
~50 sAssociative arrays are a bash 4.0 feature, and Apple's stock `/bin/bash` is still 3.2 for licensing reasons, so `declare -A` is rejected outright. The dangerous part is what happens next: `declare` fails but, without `set -e`, the script keeps going, and the following `seen[key]=1` quietly creates an *indexed* array with arithmetic subscripts, so every key collapses onto element 0 and the run produces wrong results instead of stopping. Three practical options. Run it under a bash 4+ that the developer installed separately, which a `#!/usr/bin/env bash` shebang will pick up from PATH ahead of `/bin/bash`. Add a version guard at the top — `(( BASH_VERSINFO[0] >= 4 ))` or exit with a clear message — so the failure is loud and diagnosable. Or drop the map: two parallel indexed arrays, a `case` statement, or piping through `sort`/`awk` all work on 3.2.
code
bash · 11 lines#!/usr/bin/env bash
# fail loudly instead of producing a silently wrong run on bash 3.2
if (( BASH_VERSINFO[0] < 4 )); then
printf 'error: this script needs bash 4+, found %s\n' "$BASH_VERSION" >&2
exit 1
fi
declare -A seen
seen[build]=ok
seen[test]=ok
printf 'entries: %d\n' "${#seen[@]}"go deeper
Remember that associative arrays need bash 4 and macOS still ships bash 3.2 as /bin/bash, so declare -A fails there and the script needs a newer bash to run.
Explain the mechanism: the failed declare returns non-zero but does not stop the script, and the following subscripted assignment creates an indexed array whose keys all evaluate to 0, so the run continues with wrong data.
Show the guard you would actually add — BASH_VERSINFO[0] with a clear message and a non-zero exit — and explain why #!/usr/bin/env bash plus a guard beats relying on PATH alone.
Decide the script's supported-interpreter contract deliberately: a CI-only script can require bash 4+ and check it, but anything shipped to machines you do not control should target 3.2 or delegate keyed work to a tool, because a silently wrong run is a worse outcome than a refusal.
## Why the Mac is different Bash 4.0 changed licence to GPLv3, and Apple has never shipped a GPLv3 bash. macOS therefore still carries bash 3.2 at `/bin/bash` (zsh has been the default login shell since Catalina, but `/bin/bash` remains, and remains ancient). Associative arrays, `declare -A`, `mapfile`/`readarray`, `${var^^}` case conversion and `wait -n` all arrived in bash 4.x, so every one of them is a cliff at that boundary. This leaf's concern is the first of those; the general bashisms-versus-POSIX story is a topic of its own. ``` $ /bin/bash --version | head -1 GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin...) $ /bin/bash -c 'declare -A m' /bin/bash: declare: -A: invalid option ``` ## The failure is worse than the error message A visible error is the good case. What usually happens is this sequence: ```bash declare -A seen # fails, prints to stderr, returns non-zero seen[build]=ok # creates an INDEXED array, subscript evaluated as arithmetic seen[test]=ok # writes element 0 again (( ${#seen[@]} == 2 )) # false - there is one element ``` Without `set -e`, the failed `declare` does not stop anything; the script continues and every map key lands on index 0. The developer sees one confusing line on stderr followed by output that is merely *wrong* — a deduplication that deduplicates everything, a counter stuck at the last value. That is why the version guard below matters more than the fix itself: you want an unambiguous stop, not a subtly wrong run. ## Option 1: run under a newer bash Developers on macOS usually already have a modern bash from a package manager, installed alongside the system one rather than replacing it (typically under `/opt/homebrew/bin` on Apple Silicon or `/usr/local/bin` on Intel). A shebang of `#!/usr/bin/env bash` resolves `bash` through `PATH`, so it picks up that newer interpreter when the package manager's directory precedes `/bin` — which is the normal arrangement. A hard-coded `#!/bin/bash` pins you to 3.2 no matter what is installed. This is the least invasive fix, but it is a *convention*, not a guarantee: it depends on each machine's PATH. Pair it with option 2 so a mis-ordered PATH is caught rather than silently downgrading. ## Option 2: guard the version and fail loudly ```bash #!/usr/bin/env bash if (( BASH_VERSINFO[0] < 4 )); then printf 'error: this script needs bash 4+, found %s\n' "$BASH_VERSION" >&2 exit 1 fi declare -A seen ``` `BASH_VERSINFO` is an indexed array the shell maintains, element 0 being the major version; `BASH_VERSION` is the full string for the message. Inside `(( ))` the name needs no `$`. Do not test `$SHELL` instead — that is the user's *login* shell from the password database, not the interpreter currently running the script, and it is a classic wrong answer. A guard converts a mystery into a one-line instruction. It costs four lines and it is what a reviewer should ask for in any script that uses a bash 4 feature and might be run outside the CI image. ## Option 3: do not need the map If the script genuinely has to run on stock macOS bash — an installer, a bootstrap script, something you cannot ask people to prepare a shell for — restructure so no map is required. Practical substitutes on 3.2: - **Two parallel indexed arrays**, one of keys and one of values, with a small linear-search helper. Fine for tens of entries, poor for thousands. - **A `case` statement** when the key set is fixed and known at authoring time — often clearer than a map anyway. - **Delegate to a tool.** Deduplication is `sort -u`, counting is `sort | uniq -c`, and keyed lookup is a two-line `awk` program with its own associative arrays. Shelling out to `awk` is frequently both faster and more portable than emulating a hash in bash. The restructure is the honest answer when the script is distributed to machines you do not control. Emulating a hash map with prefixed variable names and `eval` is the fourth option people reach for, and it is the one to argue against: it re-introduces quoting and injection problems to work around a missing data structure. ## Choosing For an internal script that also runs on the CI image, guard plus `env bash` is right. For anything shipped to users' machines, target 3.2 or explicitly declare and check the requirement — the failure mode of the middle path, a wrong-but-silent run, is the outcome to design away.
- Why is a failed `declare -A` more dangerous than a syntax error would be?A syntax error stops the shell before anything runs. A failed `declare` is just a command returning non-zero: without `set -e` the script continues, and the next `seen[k]=v` implicitly creates an indexed array whose subscripts are arithmetic. Every key becomes element 0, so the run finishes successfully with wrong data rather than aborting.
- Why not just test `$SHELL` to decide whether the shell is new enough?`SHELL` holds the user's login shell from their account record, not the interpreter executing the script — it is often stale, and on macOS it usually says zsh while your script runs under bash. Use `BASH_VERSINFO[0]`, which the running shell sets for itself, or `BASH_VERSION` for a human-readable message.
- If the script must run on stock macOS bash, what replaces the map?Two parallel indexed arrays with a small lookup helper, a `case` statement when the keys are fixed, or delegating the keyed work to `awk`, which has its own associative arrays. Avoid emulating a hash with `eval` and prefixed variable names — that trades a missing data structure for quoting and injection problems.
saying these in an interview costs you the question
- Says macOS just needs bash updated, it is the same version
- Assumes a failed declare aborts the script
- Checks $SHELL to detect the running bash version
- Thinks #!/bin/bash picks up a Homebrew bash
- Emulates a map with eval and prefixed variable names