Scripts robustos en Bash: set -euo pipefail, trap, getopts y ShellCheck

Lección 7 de 8: scripts robustos. Un script que funciona en tu terminal no es lo mismo que uno que puede correr solo, cada noche, sin que nadie lo vigile. Esta lección cubre lo que marca la diferencia: pararse cuando algo falla con set -euo pipefail (y sus trampas), limpiar al salir con trap, crear temporales seguros con mktemp, aceptar opciones con getopts, dejar un registro, depurar con bash -x, evitar ejecuciones dobles con flock y revisarlo todo con ShellCheck.

Por omisión, Bash sigue adelante

Cuando una orden falla en un script, Bash escribe el error y pasa a la siguiente línea, como si nada. En la terminal tiene sentido: tú ves el error y decides. En un script que corre solo, de madrugada y desde cron, es peligrosísimo:

#!/usr/bin/env bash
cd /copias/antiguas
echo "Estoy en: $PWD"
echo "Ahora borraría todo lo que hay aquí"
salida./sin_e.sh: line 2: cd: /copias/antiguas: No such file or directory
Estoy en: /home/ana/proyecto
Ahora borraría todo lo que hay aquí
código de salida: 0

El cd falló, pero el script siguió en la carpeta en la que estaba, y termina con código 0, como si todo hubiera ido bien. Si en lugar del último echo hubiera un rm -rf ./*, habría borrado la carpeta equivocada. Esta lección va de evitar exactamente eso.

set -e, set -u y set -o pipefail

Tres opciones de Bash cambian ese comportamiento, y casi todos los scripts serios empiezan con ellas, juntas, en la línea set -euo pipefail. Cada una tapa un agujero distinto.

set -e: pararse en el primer error

#!/usr/bin/env bash
set -e
cd /copias/antiguas
echo "Estoy en: $PWD"
echo "Ahora borraría todo lo que hay aquí"
salida con error./con_e.sh: line 3: cd: /copias/antiguas: No such file or directory
código de salida: 1

Con -e (errexit), el script termina en cuanto una orden devuelve un código distinto de 0, y sale con ese mismo código. Las líneas peligrosas no llegan a ejecutarse.

set -u: las variables sin definir son un error

El error que te ahorra: una errata en el nombre de una variable

Sin -u, una variable que no existe vale una cadena vacía, sin avisar. Con una errata, esto puede ser catastrófico:

#!/usr/bin/env bash
set -u
carpeta_temporal="/home/ana/proyecto/tmp"
echo "Voy a vaciar: $carpta_temporal/"
echo "rm -rf $carpta_temporal/*  (aquí no se ejecuta, solo se escribe)"
salida con error./errata.sh: line 4: carpta_temporal: unbound variable
código de salida: 1

Sin set -u, la primera línea habría escrito «Voy a vaciar: /», y un rm -rf "$carpta_temporal/"* real habría intentado borrar todo lo que hay en la raíz del disco. Con -u, Bash se para en la primera vez que la usa. ShellCheck lo ve incluso antes de ejecutar:

shellcheckIn errata.sh line 3:
carpeta_temporal="/home/ana/proyecto/tmp"
^--------------^ SC2034 (warning): carpeta_temporal appears unused. Verify use (or export if used externally).

In errata.sh line 4:
echo "Voy a vaciar: $carpta_temporal/"
                    ^--------------^ SC2154 (warning): carpta_temporal is referenced but not assigned (did you mean 'carpeta_temporal'?).

For more information:
  https://www.shellcheck.net/wiki/SC2034 -- carpeta_temporal appears unused. ...
  https://www.shellcheck.net/wiki/SC2154 -- carpta_temporal is referenced but...

Si una variable puede no estar definida a propósito, por ejemplo un argumento opcional, se le da un valor por omisión con ${1:-} o ${MODO:-normal}, y -u deja de quejarse.

set -o pipefail: los errores dentro de una tubería

#!/usr/bin/env bash
grep 'ERROR' no-existe.log | sort
echo "código sin pipefail: $?"

set -o pipefail
grep 'ERROR' no-existe.log | sort
echo "código con pipefail: $?"
salidagrep: no-existe.log: No such file or directory
código sin pipefail: 0
grep: no-existe.log: No such file or directory
código con pipefail: 2

El código de salida de una tubería es, por omisión, el de su última orden. Aquí sort funcionó bien (ordenó cero líneas), así que el fallo de grep se pierde y set -e no se entera. Con pipefail, la tubería devuelve el último código distinto de 0, el 2 de grep, que significa que no pudo leer el archivo.

  • OpciónNombre largoQué hace
  • set -eerrexitTermina el script cuando una orden falla (con excepciones, ver abajo).
  • set -unounsetUsar una variable sin definir es un error.
  • set -o pipefailUna tubería falla si falla cualquiera de sus órdenes, no solo la última.
  • set -xxtraceEscribe cada orden antes de ejecutarla (para depurar).
  • set -EerrtraceLa trampa ERR también salta dentro de las funciones.

Cuándo set -e no salta

set -e no es una red de seguridad completa. Bash lo desactiva en varias situaciones, a propósito, y conocerlas evita sorpresas. Este script las junta:

#!/usr/bin/env bash
set -e

falla() {
    false
    echo "  falla() siguió después de false"
}

echo "1. Dentro de un if:"
if falla; then echo "  el if cree que falla() fue bien"; fi

echo "2. A la izquierda de && o ||:"
falla && echo "  y el && también"

echo "3. local con una sustitución de órdenes:"
leer() {
    local valor=$(false)
    echo "  local se tragó el error; valor='$valor'"
}
leer

echo "4. Un contador que empieza en 0:"
n=0
((n++))
echo "  esta línea no llega a escribirse"
salida con error1. Dentro de un if:
  falla() siguió después de false
  el if cree que falla() fue bien
2. A la izquierda de && o ||:
  falla() siguió después de false
  y el && también
3. local con una sustitución de órdenes:
  local se tragó el error; valor=''
4. Un contador que empieza en 0:
código de salida: 1
  1. Dentro de un if, de un while o de un until, la condición puede fallar sin parar el script, y eso se extiende a todo lo que se ejecuta dentro, funciones incluidas: por eso false no detuvo falla().
  2. A la izquierda de && o || pasa lo mismo: es una comprobación, y el fallo es una respuesta válida.
  3. local valor=$(false) devuelve el código de local, que siempre es 0, y no el de la orden. Hay que separarlo en dos líneas: local valor y después valor=$(false). ShellCheck avisa (SC2155).
  4. ((n++)) con n=0 evalúa el valor de antes de sumar, 0, y en aritmética de Bash 0 es «falso»: devuelve código 1 y set -e mata el script sin ningún mensaje. Usa n=$((n + 1)) o ((++n)).

Por eso set -e ayuda, pero no sustituye a comprobar los errores que importan: cd "$dir" || exit 1, o un if con un mensaje claro.

trap: limpiar al salir, pase lo que pase

Un script que crea archivos temporales tiene que borrarlos al terminar, también si falla a medias o si alguien pulsa Ctrl+C. Para eso está trap, que registra una orden que Bash ejecutará cuando ocurra algo. La más útil es EXIT, que salta siempre que el script termina:

#!/usr/bin/env bash
set -euo pipefail

temporal=$(mktemp -d)
limpiar() {
    rm -rf -- "$temporal"
    echo "limpieza: borrado el temporal"
}
trap limpiar EXIT

echo "datos intermedios" > "$temporal/parcial.txt"
echo "permisos del temporal: $(stat -c %a "$temporal")"
ls "$temporal"
false
echo "esta línea no llega"
salida con errorpermisos del temporal: 700
parcial.txt
limpieza: borrado el temporal
código de salida: 1

Un script crea un temporal con mktemp y registra con trap una función de limpieza para la salida. Haga el trabajo bien, falle una orden con set -e o reciba Ctrl+C, al salir siempre se ejecuta la limpieza, que borra el temporal. Solo kill -9 se la saltaCon trap EXIT, el script limpia salga como salgatmp=$(mktemp -d)trap limpiar EXITel trabajotermina bienexit 0una orden fallaset -e: exit 1Ctrl+Cseñal INTlimpiar()borra el temporal,suelta cerrojos, avisaSolo kill -9 (SIGKILL) se salta la trampa: esa señal no se puede capturar.

La trampa de EXIT se ejecuta al terminar por cualquier camino: bien, por un error con set -e o por una señal. Esquema: decodigo.com.

mktemp -d crea una carpeta temporal con un nombre imposible de adivinar (algo como /tmp/tmp.k3X9aQ2b) y permisos 700: solo tu usuario puede entrar. mktemp sin -d crea un archivo, con permisos 600. El script falla en false, y aun así la limpieza se ejecuta antes de salir con código 1.

ERR salta cada vez que una orden falla, y es el sitio ideal para escribir dónde. $LINENO tiene la línea y $BASH_COMMAND, la orden que falló:

#!/usr/bin/env bash
set -eEuo pipefail
trap 'echo "ERROR en la línea $LINENO: «$BASH_COMMAND» devolvió $?" >&2' ERR

echo "Copiando la configuración..."
cp configuracion.ini /etc/miapp/
echo "Hecho"
salida con errorCopiando la configuración...
cp: cannot stat 'configuracion.ini': No such file or directory
ERROR en la línea 6: «cp configuracion.ini /etc/miapp/» devolvió 1
código de salida: 1

Y con INT y TERM puedes reaccionar a Ctrl+C y a un kill, por ejemplo para guardar el progreso. Aquí el script se manda a sí mismo la señal en el paso 3, para simular la pulsación:

#!/usr/bin/env bash
trap 'echo "Interrumpido en el paso $paso: guardo el progreso y salgo"; exit 130' INT

for paso in 1 2 3 4 5; do
    echo "paso $paso"
    if (( paso == 3 )); then
        kill -INT $$
    fi
    sleep 0.1
done
echo "terminado"
salida con errorpaso 1
paso 2
paso 3
Interrumpido en el paso 3: guardo el progreso y salgo
código de salida: 130

El 130 es la convención para «terminado por Ctrl+C»: 128 más el número de la señal INT, que es 2.

Opciones con getopts

Un script que se usa más de una vez acaba necesitando opciones: -v para que cuente lo que hace, -n 5 para un número, -h para la ayuda. getopts las analiza como lo hacen las órdenes del sistema, incluidas las juntas (-vn 5). Este script enseña las primeras líneas de un archivo:

#!/usr/bin/env bash
set -euo pipefail

uso() {
    cat <<EOF
Uso: ${0##*/} [-v] [-n NÚMERO] ARCHIVO
  -n NÚMERO  cuántas líneas enseñar (10 si no se indica)
  -v         cuenta lo que va haciendo
  -h         esta ayuda
EOF
}

lineas=10
detallado=false
while getopts ':hvn:' opcion; do
    case $opcion in
        h) uso; exit 0 ;;
        v) detallado=true ;;
        n) lineas=$OPTARG ;;
        :) echo "Error: -$OPTARG necesita un valor" >&2; uso >&2; exit 2 ;;
        \?) echo "Error: opción desconocida -$OPTARG" >&2; uso >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))

if (( $# != 1 )); then
    echo "Error: indica un ARCHIVO" >&2; uso >&2; exit 2
fi
if ! [[ $lineas =~ ^[0-9]+$ ]]; then
    echo "Error: -n espera un número entero, no «$lineas»" >&2; exit 2
fi
archivo=$1
if [[ ! -r $archivo ]]; then
    echo "Error: no puedo leer $archivo" >&2; exit 1
fi

if $detallado; then echo "Leyendo $lineas líneas de $archivo" >&2; fi
head -n "$lineas" -- "$archivo"
terminal$ bash opciones.sh -v -n 2 tareas.txt
Leyendo 2 líneas de tareas.txt
Revisar las copias
Renovar el certificado
$ bash opciones.sh -vn1 tareas.txt
Leyendo 1 líneas de tareas.txt
Revisar las copias
$ bash opciones.sh -n dos tareas.txt; echo "código: $?"
Error: -n espera un número entero, no «dos»
código: 2
$ bash opciones.sh -x tareas.txt; echo "código: $?"
Error: opción desconocida -x
Uso: opciones.sh [-v] [-n NÚMERO] ARCHIVO
  -n NÚMERO  cuántas líneas enseñar (10 si no se indica)
  -v         cuenta lo que va haciendo
  -h         esta ayuda
código: 2
$ bash opciones.sh -n; echo "código: $?"
Error: -n necesita un valor
Uso: opciones.sh [-v] [-n NÚMERO] ARCHIVO
  -n NÚMERO  cuántas líneas enseñar (10 si no se indica)
  -v         cuenta lo que va haciendo
  -h         esta ayuda
código: 2
$ bash opciones.sh -h
Uso: opciones.sh [-v] [-n NÚMERO] ARCHIVO
  -n NÚMERO  cuántas líneas enseñar (10 si no se indica)
  -v         cuenta lo que va haciendo
  -h         esta ayuda

La cadena ':hvn:' lista las opciones válidas: una letra seguida de : espera un valor, que llega en $OPTARG. Los dos puntos del principio activan el modo silencioso: getopts no escribe sus propios errores y en cambio devuelve ? (opción desconocida) o : (falta el valor), y el script escribe los suyos, en español. Después del bucle, shift $((OPTIND - 1)) quita las opciones ya procesadas y deja en $1 el primer argumento normal.

La convención de los códigos de salida: 0 si todo fue bien, 1 si falló la tarea y 2 si el script se usó mal (una opción que no existe, falta un argumento). Los mensajes de error van a la salida de errores, con >&2, para que no se mezclen con los datos si alguien redirige la salida a un archivo. getopts solo entiende opciones cortas; para opciones largas como --verbose, mira Argumentos y opciones en Bash.

Mensajes de registro

Cuando un script corre sin que nadie lo mire, sus mensajes son lo único que queda para entender qué pasó. Una función pequeña con la fecha y el nivel, que escribe en la salida de errores, basta en la mayoría de los casos:

#!/usr/bin/env bash
set -euo pipefail

registrar() {
    local nivel=$1; shift
    printf '%s [%-5s] %s\n' "$(date '+%F %T')" "$nivel" "$*" >&2
}

registrar INFO "Empieza la copia de 3 archivos"
registrar AVISO "Quedan menos de 2 GB libres en el disco"
registrar ERROR "No se pudo copiar facturas.db"
echo "resultado: 2 de 3 archivos copiados"
salida2026-10-30 03:00:12 [INFO ] Empieza la copia de 3 archivos
2026-10-30 03:00:12 [AVISO] Quedan menos de 2 GB libres en el disco
2026-10-30 03:00:12 [ERROR] No se pudo copiar facturas.db
resultado: 2 de 3 archivos copiados

Como los mensajes van a la salida de errores y el resultado a la normal, quien use el script puede separarlos: ./copia.sh > resultado.txt 2>> copia.log guarda el resultado en un archivo y va acumulando el registro en otro. Si el script lo lanza cron, también puedes mandar los mensajes al registro del sistema con logger -t copia "mensaje", y verlos después con journalctl -t copia.

Depurar con bash -x

Cuando un script no hace lo que esperas, bash -x lo ejecuta escribiendo cada orden, ya con las variables sustituidas, antes de ejecutarla. Cada línea de la traza empieza por el contenido de la variable PS4 (un + por omisión), y si le pones el número de línea, la traza dice exactamente dónde está cada cosa:

#!/usr/bin/env bash
total=0
for precio in 12.50 8 30; do
    entero=${precio%.*}
    total=$((total + entero))
done
echo "total: $total"
terminal$ bash calcula.sh
total: 50
$ PS4='+ línea ${LINENO}: ' bash -x calcula.sh
+ línea 2: total=0
+ línea 3: for precio in 12.50 8 30
+ línea 4: entero=12
+ línea 5: total=12
+ línea 3: for precio in 12.50 8 30
+ línea 4: entero=8
+ línea 5: total=20
+ línea 3: for precio in 12.50 8 30
+ línea 4: entero=30
+ línea 5: total=50
+ línea 7: echo 'total: 50'
total: 50

La traza enseña el fallo de un vistazo: ${precio%.*} quita los decimales, así que 12.50 cuenta como 12, y el total sale 50 en lugar de 50.50. Bash solo hace aritmética con enteros; para decimales hay que usar awk o bc. Si solo te interesa un trozo del script, rodéalo con set -x y set +x.

Que no se ejecute dos veces a la vez: flock

Si una copia de seguridad programada cada hora tarda más de una hora, la siguiente empezará mientras la anterior sigue, y las dos se pisarán. flock pone un cerrojo sobre un archivo: la primera copia lo consigue, y la segunda, con -n, se rinde en el acto en lugar de esperar:

#!/usr/bin/env bash
set -euo pipefail

exec 9> /tmp/copia-nocturna.lock
if ! flock -n 9; then
    echo "Ya hay una copia en marcha: salgo sin hacer nada" >&2
    exit 1
fi

echo "Copiando..."
sleep 1
echo "Copia terminada"
terminal$ bash cerrojo.sh & sleep 0.3; bash cerrojo.sh; echo "código: $?"; wait
Copiando...
Ya hay una copia en marcha: salgo sin hacer nada
código: 1
Copia terminada

exec 9> archivo abre el archivo con el descriptor 9 durante todo el script, y flock -n 9 intenta bloquearlo. El cerrojo se suelta solo cuando el proceso termina, aunque sea por un error o por un kill -9, así que no quedan cerrojos huérfanos como pasa con los scripts que crean un archivo .pid y lo borran al final.

ShellCheck: el revisor que no se cansa

ShellCheck analiza un script sin ejecutarlo y avisa de los errores típicos de Bash: variables sin comillas, órdenes que fallan en silencio, sintaxis antigua, cosas que no funcionan en sh. Se instala con el gestor de paquetes (sudo apt install shellcheck, brew install shellcheck) y está integrado en los editores más usados. Todos los scripts de este curso pasan por él. Mira lo que dice de este script, que funciona con archivos sencillos:

#!/bin/bash
for f in $(ls *.txt)
do
  lineas=`wc -l < $f`
  if [ $lineas -gt 100 ]
  then
    echo $f es largo
  fi
done
cd $DESTINO
shellcheckIn revisar.sh line 2:
for f in $(ls *.txt)
         ^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
              ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.

In revisar.sh line 4:
  lineas=`wc -l < $f`
         ^----------^ SC2006 (style): Use $(...) notation instead of legacy backticks `...`.
                  ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
  lineas=$(wc -l < "$f")

In revisar.sh line 5:
  if [ $lineas -gt 100 ]
       ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
  if [ "$lineas" -gt 100 ]

In revisar.sh line 7:
    echo $f es largo
         ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
    echo "$f" es largo

In revisar.sh line 10:
cd $DESTINO
^---------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
   ^------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
cd "$DESTINO" || exit

For more information:
  https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
  https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
  https://www.shellcheck.net/wiki/SC2035 -- Use ./*glob* or -- *glob* so name...

Cada aviso trae un código (SC2045, SC2006…) con su explicación en la web de ShellCheck. Corregidos todos, el script queda así, y ShellCheck ya no tiene nada que decir:

#!/usr/bin/env bash
set -euo pipefail
for f in ./*.txt; do
    [[ -e $f ]] || continue
    lineas=$(wc -l < "$f")
    if (( lineas > 100 )); then
        echo "$f es largo"
    fi
done
cd -- "${DESTINO:?falta la variable DESTINO}"

Si un aviso no aplica en tu caso, se puede desactivar solo para una línea con un comentario encima, # shellcheck disable=SC2086, mejor con una nota de por qué. Y en un proyecto, lo ideal es ejecutar ShellCheck en la integración continua, para que ningún script llegue a producción sin pasarlo.

Ojo con la seguridad: rm -rf con una variable que puede estar vacía

Es el error más destructivo que se puede cometer en Bash. En enero de 2015, un usuario del cliente de Steam para Linux contó que, tras mover la carpeta del programa, había perdido todos sus archivos, y en el hilo del fallo se señaló una línea de su script, rm -rf "$STEAMROOT/"*: si la variable queda vacía, eso se convierte en rm -rf "/"*. ShellCheck lo detecta:

#!/usr/bin/env bash
dir_copias=$(cat ruta-de-copias.conf)
rm -rf "$dir_copias/"*
shellcheckIn vaciar.sh line 3:
rm -rf "$dir_copias/"*
       ^-------------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .

For more information:
  https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure this nev...

La forma segura es ${variable:?mensaje}, que detiene el script si la variable está vacía o sin definir, antes de que rm llegue a ver la ruta:

#!/usr/bin/env bash
set -euo pipefail
dir_copias=""
rm -rf -- "${dir_copias:?está vacía, no borro nada}/"*
echo "esta línea no llega"
salida con error./vaciar_seguro.sh: line 4: dir_copias: está vacía, no borro nada
código de salida: 1

Añade también --, para que un nombre que empiece por guion no se tome como una opción, y piensa si de verdad necesitas -f: sin él, rm te avisaría de los archivos protegidos.

Más en Los fallos de seguridad típicos de los scripts de Bash.

Scripts que se pueden repetir

Un último hábito: escribe los scripts para que ejecutarlos dos veces dé el mismo resultado que una (los informáticos lo llaman idempotente). Si un script falla a medias y lo vuelves a lanzar, no debería romperse por lo que ya hizo la primera vez. Muchas órdenes tienen una opción para eso:

#!/usr/bin/env bash
set -euo pipefail
for vuelta in 1 2; do
    echo "== vuelta $vuelta"
    mkdir -p copias/2026
    ln -sfn copias/2026 copias-actuales
    grep -qxF 'export EDITOR=nano' perfil.txt 2>/dev/null || echo 'export EDITOR=nano' >> perfil.txt
    ls -d copias/2026 copias-actuales
    cat perfil.txt
done
salida== vuelta 1
copias-actuales
copias/2026
export EDITOR=nano
== vuelta 2
copias-actuales
copias/2026
export EDITOR=nano

mkdir -p no falla si la carpeta ya existe, ln -sfn sustituye el enlace en lugar de quejarse, y el grep -qxF … || echo … >> solo añade la línea si aún no está, que evita el clásico archivo de configuración con la misma línea repetida diez veces.

Retos

Reto 1. Este script cuenta los archivos .log de una carpeta, pero con set -e termina sin escribir nada y con código 1. ¿Por qué, y cómo se arregla? set -euo pipefail; n=0; for f in *.log; do ((n++)); done; echo "$n archivos"

Ver la solución

El primer ((n++)) vale 0, que en aritmética es «falso», devuelve 1 y set -e corta. Con n=$((n + 1)) funciona:

#!/usr/bin/env bash
set -euo pipefail
touch a.log b.log c.log
n=0
for f in *.log; do
    [[ -e $f ]] || continue
    n=$((n + 1))
done
echo "$n archivos"
salida3 archivos

El [[ -e $f ]] || continue evita contar 1 cuando no hay ningún .log: en ese caso el patrón se queda tal cual, *.log.

Reto 2. Haz que un script exija un argumento numérico y se pare con un mensaje claro si no lo recibe. Pruébalo sin argumentos.

Ver la solución
#!/usr/bin/env bash
set -euo pipefail
dias=${1:?Uso: limpiar.sh DÍAS}
if ! [[ $dias =~ ^[0-9]+$ ]]; then
    echo "Error: DÍAS debe ser un número, no «$dias»" >&2
    exit 2
fi
echo "Borraría las copias de más de $dias días"
salida con error./reto2.sh: line 3: 1: Uso: limpiar.sh DÍAS
código de salida: 1

${1:?mensaje} se para con código 1 y escribe el mensaje. Si quieres el código 2 de «uso incorrecto», comprueba $# con un if y un exit 2, como en opciones.sh.

Reto 3. Añade al script de la trampa EXIT que, además de borrar el temporal, escriba con qué código terminó el script.

Ver la solución
#!/usr/bin/env bash
set -euo pipefail
temporal=$(mktemp)
limpiar() {
    local codigo=$?
    rm -f -- "$temporal"
    echo "limpieza hecha; el script termina con código $codigo"
}
trap limpiar EXIT
echo "trabajando con un temporal"
cp no-existe.txt "$temporal"
salida con errortrabajando con un temporal
cp: cannot stat 'no-existe.txt': No such file or directory
limpieza hecha; el script termina con código 1
código de salida: 1

Dentro de la función de la trampa, $? todavía es el código con el que el script iba a terminar, siempre que lo leas en la primera línea, antes de ejecutar cualquier otra orden. Y si la función no llama a exit, el script conserva ese código.

Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.