Todo el curso ha dado por supuesta una cosa: que el intérprete es Bash 5 sobre Ubuntu 24.04. Es una suposición razonable para srv-veloz-01/02/03, y por eso hemos usado arrays asociativos, mapfile, [[ ]], ${s^^} y set -o pipefail sin pensarlo dos veces. Pero el día que haya que escribir el entrypoint.sh del contenedor Alpine de la veloz-api, o un script de arranque que corre antes de que Bash esté disponible, o una herramienta que un compañero ejecutará en su macOS, esa suposición se rompe y el script falla con errores desconcertantes. Esta lección explica qué es POSIX, qué construcciones nuestras son bashismos, cuáles son sus equivalentes, por qué las herramientas externas dan más problemas que el propio shell, cómo comprobar la portabilidad y —lo más importante— cuándo merece la pena y cuándo es un lastre.

Contenido

  1. Qué es POSIX y qué es un bashismo
  2. El escenario real: quién es /bin/sh en cada sistema
  3. Tabla de bashismos y su equivalente POSIX
  4. Detalles que hay que conocer al bajar a POSIX
  5. Las herramientas externas duelen más que el shell
  6. Cómo comprobar la portabilidad
  7. La decisión de diseño, con criterio explícito para Veloz Envíos
  8. Declarar la dependencia: comprobar BASH_VERSINFO
  9. Qué aporta cada versión de Bash y el problema de macOS
  10. Balance del módulo 8

  1. Qué es POSIX y qué es un bashismo

POSIX es un estándar (IEEE 1003.1) que define, entre muchas otras cosas, el lenguaje del shell: qué sintaxis debe entender cualquier shell que se declare conforme. Es el mínimo común denominador, y es deliberadamente austero: no tiene arrays, ni [[ ]], ni sustitución de cadenas en las expansiones.

Un bashismo es cualquier construcción que Bash entiende y el estándar no exige. No son errores —son las razones por las que Bash es cómodo—, pero solo funcionan donde hay Bash. El problema surge por una confusión concreta: escribir #!/bin/sh en la primera línea y usar sintaxis de Bash dentro. En Ubuntu, si además lo pruebas ejecutando bash script.sh, funciona; en el contenedor Alpine, donde /bin/sh es otro programa, falla.

  1. El escenario real: quién es /bin/sh en cada sistema

Sistema /bin/sh es Consecuencia
Debian / Ubuntu dash Rápido y estricto: ningún bashismo funciona
Alpine (contenedores) ash (BusyBox) Aún más reducido; además las herramientas son BusyBox
RHEL / Fedora bash en modo POSIX Muchos bashismos «funcionan»: falsa sensación de seguridad
macOS bash 3.2 o zsh Bash antiguo: sin arrays asociativos ni mapfile
FreeBSD / Solaris sh propio, ksh Diferencias en los detalles

Las dos filas peligrosas son la de RHEL y la de macOS, por motivos opuestos. En RHEL tu script con bashismos y #!/bin/sh funciona, así que nadie detecta el problema hasta que llega a Debian. En macOS el intérprete se llama bash pero es la versión 3.2 de 2006 —Apple no actualiza por motivos de licencia—, así que un script perfectamente correcto para Bash 5 falla con mensajes crípticos.

El error típico se ve así:

$ dash /tmp/informe.sh
/tmp/informe.sh: 12: [[: not found

[[: not found es la firma inconfundible de un bashismo en dash: no es un error de sintaxis, es que dash busca una orden llamada [[.

  1. Tabla de bashismos y su equivalente POSIX

Bashismo Equivalente POSIX Nota
[[ $a == $b ]] [ "$a" = "$b" ] En POSIX las comillas son obligatorias
[[ $s == a* ]] case $s in a*) ;; esac POSIX no tiene comparación con patrón en [ ]
[[ $s =~ regex ]] expr "$s" : 'regex' o grep -q Sin BASH_REMATCH; el más doloroso
arr=(a b c), ${arr[@]} Parámetros posicionales: set -- a b c, "$@" Un solo «array» por ámbito
declare -A No existe Sin arrays asociativos: fichero temporal o awk
${s//a/b} printf '%s' "$s" | sed 's/a/b/g' Cuesta un proceso
${s^^} / ${s,,} tr '[:lower:]' '[:upper:]' Idem
${s:0:3} printf '%s' "$s" | cut -c1-3 ${s#...} y ${s%...} son POSIX
$(( a + b )) $(( a + b )) Sí es POSIX, úsalo sin miedo
(( i++ )) i=$(( i + 1 )) La orden (( )) no es POSIX
local x (no está en el estándar) Lo implementan dash, ash y ksh: seguro en la práctica
function f { }, source f f() { }, . f La forma con paréntesis y el punto son las portables
echo -e "a\tb" printf 'a\tb\n' echo con opciones no es portable
<<< "$cadena" <<EOFEOF o printf ... | Here-string es de Bash
<(cmd) Fichero temporal con mktemp Sustitución de proceso: sin equivalente
arr+=(x), s+=texto s="${s}texto" El operador += no es POSIX
${BASH_SOURCE[0]}, read -a, mapfile $0; while read + set -- Sin arrays no hay equivalente directo
set -o pipefail, trap ... ERR No existen Ver apartado 4; solo trap ... EXIT INT TERM
{1..10} seq 1 10 o while La expansión de llaves no es POSIX
&> fichero > fichero 2>&1 Muy fácil de olvidar

  1. Detalles que hay que conocer al bajar a POSIX

[ ] no es [[ ]]. Es una orden, no sintaxis, así que sus argumentos sufren división de palabras. [ $a = $b ] con $a vacía se convierte en [ = valor ] y da un error de sintaxis. Con comillas —[ "$a" = "$b" ]— funciona siempre. Usa =, no ==, y -a/-o están obsoletos: encadena con && y || entre dos [ ].

Sin pipefail. Es la pérdida más seria, porque cmd1 | cmd2 devuelve el código de cmd2 y un fallo del primero pasa inadvertido (05-03). La salida practicable es romper la tubería: escribir la primera etapa en un temporal con mktemp, comprobar su código de salida y leer el temporal en la segunda. En la práctica, un script POSIX se reestructura para no encadenar órdenes cuyo fallo importe.

Sin arrays. El sustituto son los parámetros posicionales:

set -- Valencia Sevilla Bilbao Madrid    # "array" de 4 elementos
for ciudad in "$@"; do printf 'procesando %s\n' "$ciudad"; done
printf 'total: %d\n' "$#"

Es un único conjunto por ámbito, así que si necesitas dos listas a la vez, o guardas una en una función o pasas a awk.

Sin local, en teoría. El estándar no lo incluye, pero dash, ash, ksh y zsh lo implementan. La recomendación práctica: úsalo, y documenta en la cabecera que el script requiere un shell con local.

  1. Las herramientas externas duelen más que el shell

Esta es la parte que casi todo el mundo subestima. Puedes escribir un script POSIX impecable y que falle en macOS porque sed -i funciona distinto. Linux usa GNU coreutils; macOS y los BSD usan las herramientas BSD; Alpine usa BusyBox, que es una tercera implementación reducida.

Uso GNU (Linux) BSD / macOS Portable
Editar en sitio sed -i 's/a/b/' f sed -i '' 's/a/b/' f sed 's/a/b/' f > t && mv t f
Fecha relativa date -d '3 days ago' date -v-3d Calcular con $(( )) sobre date +%s
Fecha desde época date -d "@$s" date -r "$s" Ninguna: detectar la variante
Ruta absoluta readlink -f No existe (usa realpath o stat) cd "$(dirname "$f")" && pwd
Regex Perl grep -P '\d+' No soportado grep -E '[0-9]+'
Tamaño de fichero stat -c %s f stat -f %z f wc -c < f
Sin entrada, no ejecutar xargs -r Comportamiento por defecto xargs -r solo en GNU/BusyBox
Formato en find find . -printf '%s\n' No existe find . -exec stat ... \;

Tres estrategias, en orden de preferencia:

  1. Ceñirse a las opciones POSIX de cada herramienta. sed 's/a/b/' sin -i, grep -E, find -exec. Es lo más robusto y casi siempre suficiente.
  2. Detectar la variante al arrancar y adaptarse:
if date -d '@0' >/dev/null 2>&1
    then fecha_de_epoca() { date -d "@$1" +%F; }   # GNU
    else fecha_de_epoca() { date -r "$1" +%F; }    # BSD
fi
  1. Declarar la dependencia: exigir coreutils y comprobarlo con veloz_requiere (06-03). En macOS se instalan con brew install coreutils, aunque quedan con prefijo g (gsed, gdate).

  1. Cómo comprobar la portabilidad

Tres herramientas complementarias, más una prueba real:

$ checkbashisms entrypoint.sh          # paquete devscripts
possible bashism in entrypoint.sh line 14 (should be '.', not 'source'):
source /etc/veloz/env.sh
possible bashism in entrypoint.sh line 22 ([[ ... ]]):
if [[ -z "$VELOZ_API_URL" ]]; then

$ shellcheck -s sh entrypoint.sh       # 08-05, dialecto POSIX
In entrypoint.sh line 22:
   ^-- SC2039 (warning): In POSIX sh, [[ ]] is undefined.

$ dash -n entrypoint.sh                # solo sintaxis, pero es el interprete real
$ dash entrypoint.sh                   # ejecucion real: lo mas fiable

checkbashisms es el especialista y detecta cosas que ShellCheck no marca; shellcheck -s sh se integra con el resto del flujo y explica el porqué; dash -n valida la sintaxis con el intérprete que realmente se usará en Debian. Pero la comprobación definitiva es ejecutarlo donde va a correr:

$ docker run --rm -v "$PWD:/w" -w /w alpine:3.20 sh entrypoint.sh --dry-run

Ahí se descubren a la vez los bashismos y las diferencias de BusyBox, que son la mitad del problema. Merece un trabajo de CI (08-05) junto al de ShellCheck.

  1. La decisión de diseño, con criterio explícito para Veloz Envíos

POSIX puro no es «mejor»: es una restricción que se paga en legibilidad y en procesos. Un ${s^^} se convierte en un tr con su fork, y un array asociativo que resolvía una agregación en diez líneas se convierte en un fichero temporal ordenado. Escribir en POSIX lo que va a correr siempre en Ubuntu es pagar un coste sin comprar nada.

Merece la pena POSIX Es un lastre
entrypoint.sh de un contenedor mínimo sin Bash Herramientas internas de un parque homogéneo
Scripts de arranque y initramfs Scripts con lógica de datos y agregación
Instaladores que se ejecutan en sistemas desconocidos Automatización con arrays y expresiones regulares
Fragmentos que otros incrustan en su /bin/sh Todo lo que ya funciona en Bash 5

Criterio para el toolkit, escrito y aplicado:

  • lib/comun.sh y los cinco scripts de bin/ siguen siendo Bash 5, con #!/usr/bin/env bash, arrays asociativos, [[ ]], mapfile y set -euo pipefail. Corren en tres servidores idénticos que administramos nosotros; renunciar a esas herramientas empeoraría el código sin ganancia alguna.
  • El entrypoint.sh del contenedor de la veloz-api se escribe en POSIX puro con #!/bin/sh. La imagen base es Alpine, no lleva Bash, y añadirlo solo para el arranque significa aumentar la imagen y su superficie de ataque. El script es corto —comprueba variables de entorno, espera a la base de datos y hace exec del servicio—, justo el tipo de lógica que POSIX cubre sin esfuerzo.
#!/bin/sh
# entrypoint.sh - arranque de veloz-api. POSIX puro: la imagen Alpine no lleva bash.
set -eu                                     # sin pipefail: no es POSIX
: "${VELOZ_API_PUERTO:=8080}"               # valor por defecto, POSIX
: "${VELOZ_BD_HOST:?falta VELOZ_BD_HOST}"   # obligatoria o abortar (03-06)

espera=0
while ! nc -z "$VELOZ_BD_HOST" 5432; do
    espera=$(( espera + 1 ))
    [ "$espera" -ge 30 ] && { echo "la base de datos no responde" >&2; exit 1; }
    sleep 1
done
echo "veloz-api arrancando en el puerto $VELOZ_API_PUERTO"
exec /usr/local/bin/veloz-api --port "$VELOZ_API_PUERTO" "$@"

Ni un bashismo: [ ] en lugar de [[ ]], $(( )) que sí es POSIX, : con ${var:=} y ${var:?}, echo sin opciones y exec final para que el servicio herede el PID 1 y reciba las señales (05-02). La decisión queda documentada en la cabecera, que es tan importante como la decisión misma.

  1. Declarar la dependencia: comprobar BASH_VERSINFO

Si un script requiere Bash 4 o superior, dilo y falla pronto con un mensaje claro, en lugar de reventar cincuenta líneas después con «syntax error near unexpected token». La comprobación va al principio de lib/comun.sh, justo después del shebang, y usa BASH_VERSINFO (01-02):

#!/usr/bin/env bash
# lib/comun.sh - libreria comun del toolkit. Requiere Bash 4.3+.

if [ -z "${BASH_VERSINFO:-}" ]; then
    echo "Error: este script requiere Bash, no sh/dash." >&2
    exit 1
fi
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
    echo "Error: se requiere Bash 4.3 o superior; encontrado ${BASH_VERSION}." >&2
    echo "En macOS: brew install bash y use /opt/homebrew/bin/bash." >&2
    exit 1
fi

Dos detalles del código. La primera comprobación se escribe con [ ] y echo a propósito: si el intérprete resulta ser dash, esas dos líneas tienen que funcionar para poder dar el mensaje; un [[ ]] aquí produciría el error críptico que precisamente queremos evitar. Y el mensaje dice qué hacer, no solo qué falta: un error que indica la solución ahorra media hora a quien lo encuentre.

  1. Qué aporta cada versión de Bash y el problema de macOS

Versión Novedades relevantes Año
3.2 La de macOS. Sin arrays asociativos, sin mapfile, sin ${s^^}, sin &>> 2006
4.0 Arrays asociativos (declare -A), mapfile/readarray, ${s^^}/${s,,}, |& 2009
4.2 printf -v con %(fmt)T (fechas sin date), declare -g 2011
4.3 declare -n (referencias de nombre, 08-03), mejoras en [[ ]] 2014
4.4 ${var@Q} (entrecomillado seguro), mapfile -d, local - 2016
5.0+ EPOCHSECONDS y EPOCHREALTIME (tiempo sin date), SRANDOM 2018-22

La frontera práctica es Bash 4.0: casi todo lo que hace cómodo a Bash moderno llegó ahí. Ubuntu 24.04 trae 5.2, así que en la flota no hay problema.

El problema es macOS, que sigue con 3.2 de 2006 por la licencia GPLv3 de las versiones posteriores. Si un compañero desarrolla en Mac, sus scripts fallarán al usar declare -A o mapfile aunque en los servidores funcionen. Tres respuestas posibles: instalar Bash moderno con Homebrew y usar #!/usr/bin/env bash con el nuevo primero en el PATH —la opción normal—; ceñirse a Bash 3.2, que es renunciar a demasiado; o desarrollar dentro de un contenedor con la misma imagen que producción, que además elimina el resto de diferencias de herramientas del apartado 5.

  1. Balance del módulo 8

El módulo empezó con tres preguntas sobre el toolkit: ¿se entiende?, ¿es rápido?, ¿es seguro? Siete lecciones después, ~/veloz-ops/ es otra cosa:

Lección Qué aportó
08-01 Legible: estilo uniforme, estructura canónica, funciones cortas, comentarios que explican el porqué
08-02 Rápido: medido antes y después; informe-diario.sh de 3 min 12 s a 3,9 s
08-03 Auditado: sin eval, entrada validada con lista blanca, secretos fuera del código, temporales seguros, sudo acotado
08-04 Versionado: historia con motivos, ramas, etiquetas, .gitignore que protege la configuración, hook pre-commit
08-05 Analizado: 47 avisos corregidos, entre ellos un rm -rf con variable vacía; formato automático con shfmt
08-06 Probado: tests/ con dobles y datos ficticios, ejecutado en cada commit y en CI
08-07 Con su portabilidad decidida: Bash 5 documentado y comprobado, POSIX donde de verdad hace falta

Y todo sin añadir ni una función nueva. Esa es la idea del módulo: la diferencia entre un script que funciona y software del que fiarse no está en lo que hace, sino en cómo está hecho.

Errores Comunes y Consejos

  • Poner #!/bin/sh y escribir Bash. El error central. Si usas bashismos, el shebang es #!/usr/bin/env bash.
  • Probar con bash script.sh un script que declara sh. El shebang se ignora al invocar así, y el problema queda oculto. Prueba con dash script.sh.
  • Creer que POSIX es «más seguro» o «mejor». Es más restrictivo: elegirlo sin necesidad produce código más largo, más lento y más difícil de leer.
  • Olvidar las comillas en [ ]. En [[ ]] perdonaba; aquí una variable vacía produce un error de sintaxis.
  • Suponer que las herramientas son GNU. sed -i, date -d y readlink -f no existen igual en macOS ni en BusyBox, y ahí no ayuda ningún shell.
  • Consejo: un contenedor Alpine en CI cuesta segundos y detecta a la vez los bashismos y las diferencias de BusyBox.
  • Consejo: escribe la decisión en la cabecera del fichero. «Requiere Bash 4.3+» o «POSIX puro: la imagen no lleva bash» evita que el siguiente la deshaga sin querer.

Ejercicios

Ejercicio 1. Convierte este fragmento a POSIX puro manteniendo el comportamiento.

#!/usr/bin/env bash
ciudades=(Valencia Sevilla Bilbao)
for c in "${ciudades[@]}"; do
    [[ $c == V* ]] && echo -e "prioritaria:\t${c^^}"
done

Ejercicio 2. Escribe una función veloz_hace_dias que devuelva la fecha de hace N días en formato AAAA-MM-DD y funcione tanto con date de GNU como de BSD.

Ejercicio 3. Un compañero informa de que informe-diario.sh falla en su macOS con declare: -A: invalid option. Explica la causa y propón dos soluciones con sus contrapartidas.

Soluciones

Solución 1.

#!/bin/sh
set -- Valencia Sevilla Bilbao              # parametros posicionales como "array"
for c in "$@"; do
    case $c in
        V*) mayus=$(printf '%s' "$c" | tr '[:lower:]' '[:upper:]')
            printf 'prioritaria:\t%s\n' "$mayus" ;;
    esac
done

Cuatro sustituciones: el array pasa a set -- y "$@"; la comparación con patrón [[ $c == V* ]] pasa a case, que es la forma POSIX de comparar con patrones; ${c^^} pasa a tr; y echo -e pasa a printf, que interpreta \t de forma portable y además evita el problema de que echo -e imprima literalmente -e en algunos shells.

Solución 2.

# veloz_hace_dias <n>  ->  fecha AAAA-MM-DD de hace n dias
veloz_hace_dias() {
    if date -d '@0' +%F >/dev/null 2>&1; then
        date -d "$1 days ago" +%F                       # GNU
    else
        date -v "-$1"d +%F                              # BSD / macOS
    fi
}

La detección se hace probando una opción exclusiva de GNU y descartando su salida y su error. La alternativa realmente universal es aritmética sobre la época —date -d "@$(( $(date +%s) - 86400 * $1 ))"— pero tiene el mismo problema al convertir de vuelta, y además ignora los cambios de horario, así que la detección de variante es preferible.

Solución 3. La causa es que macOS trae Bash 3.2, anterior a los arrays asociativos que llegaron en 4.0, y #!/usr/bin/env bash resuelve al /bin/bash del sistema. Dos soluciones:

  • Instalar Bash moderno con brew install bash y anteponer su directorio al PATH, de modo que env bash encuentre el 5.x. Contrapartida: cada persona debe configurar su máquina, y un PATH mal ordenado reproduce el fallo en silencio.
  • Desarrollar en un contenedor con la misma imagen que producción. Contrapartida: flujo de trabajo algo más pesado; ventaja: elimina también las diferencias de sed, date y stat del apartado 5, que aparecerían igualmente.

Reescribir el toolkit para Bash 3.2 sería una tercera opción, y es la peor: penaliza a tres servidores para acomodar a un portátil. En cualquier caso, la comprobación de BASH_VERSINFO del apartado 8 habría convertido ese error críptico en un mensaje que explica qué hacer.

Conclusión

La portabilidad es una decisión, no una virtud automática. POSIX define el mínimo común denominador del shell, y todo lo que Bash añade por encima —[[ ]], arrays, ${s//a/b}, ${s^^}, mapfile, <<<, <( ), +=, set -o pipefail, trap ERR— es un bashismo que desaparece en cuanto /bin/sh es dash en Debian, ash en Alpine o ksh en otro Unix; el error [[: not found es su firma. La tabla de equivalencias cubre lo esencial: [ "$a" = "$b" ] con comillas obligatorias, case para comparar con patrones, set -- y "$@" como sustituto de los arrays, tr y sed en lugar de las expansiones de cadena, . en vez de source, printf en vez de echo -e y reestructurar el código allí donde falta pipefail. Pero el problema mayor no está en el shell sino en las herramientas externas: sed -i, date -d, readlink -f, grep -P y stat se comportan de forma distinta en GNU, BSD y BusyBox, y ante eso solo caben tres estrategias —ceñirse a las opciones POSIX, detectar la variante al arrancar o declarar la dependencia de coreutils—. La portabilidad se comprueba, no se supone: checkbashisms, shellcheck -s sh, dash -n y, sobre todo, una ejecución real en un contenedor Alpine dentro de CI. El criterio de Veloz Envíos queda escrito: lib/comun.sh y los cinco scripts de bin/ siguen siendo Bash 5 porque corren en tres servidores idénticos y renunciar a sus herramientas no compraría nada, mientras que el entrypoint.sh del contenedor de la veloz-api se escribe en POSIX puro porque la imagen Alpine no lleva Bash y su lógica es corta. Esa dependencia se declara y se comprueba con BASH_VERSINFO al arrancar, en [ ] y echo para que el mensaje llegue incluso si el intérprete es dash, y diciendo qué hacer y no solo qué falta. La frontera práctica es Bash 4.0 —arrays asociativos, mapfile, ${s^^}— con declare -n en 4.3, y el obstáculo recurrente es el Bash 3.2 de macOS, que se resuelve con Homebrew o desarrollando en contenedor.

Con esto se cierra el módulo 8, y con él la transformación del toolkit: los mismos cinco scripts y la misma librería que empezaron el módulo son ahora legibles, rápidos, auditados, versionados, analizados, probados y con su portabilidad decidida, sin haber añadido una sola función nueva. Ya sabes escribir Bash y ya sabes escribirlo bien. Lo que queda es juntarlo todo. El Módulo 9 son cinco proyectos completos construidos paso a paso, aplicando a la vez todo lo aprendido: un recolector de información del sistema (09-01), un analizador de logs que agrega, detecta patrones y genera informes (09-02), un sistema de respaldo automatizado con retención, verificación y restauración probada (09-03), un monitor de red con umbrales y alertas (09-04) y, como cierre del curso, la integración final del toolkit (09-05), donde las piezas de los cuatro proyectos anteriores se unifican en una herramienta única, instalable, versionada, probada y desplegada en la flota de Veloz Envíos.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados