1. Qué es automatizar una compilación
Una compilación o build es el proceso que lleva del código fuente de un proyecto a un resultado que se puede usar o entregar. Automatizarla es reunir todos sus pasos en una sola orden, que cualquiera puede ejecutar y que produce siempre el mismo resultado a partir del mismo código.
El nombre de las etapas varía de un proyecto a otro, pero casi siempre son las mismas cuatro:
Un buen script de compilación tiene las cualidades que vimos a lo largo del curso, aplicadas a este caso: hace todo con una orden; se detiene ante el primer error, en lugar de empaquetar un programa roto; es incremental, es decir, rehace solo lo que cambió desde la última vez; es reproducible, de modo que el mismo código produce el mismo paquete en cualquier máquina; y no depende del directorio desde el que se lo llama ni de la configuración de quien lo ejecuta. Es, además, el mismo script que ejecutará un sistema de integración continua cada vez que alguien suba un cambio.
2. El proyecto de ejemplo
Para no perder de vista la automatización, el proyecto es mínimo: un programa en C llamado mayus, que lee texto de la entrada estándar y lo escribe en mayúsculas. Tiene dos archivos de código, un encabezado, un archivo con la versión y una carpeta de pruebas:
mayus/
├── build.sh # el script de este tema
├── VERSION # una línea: 1.4.0
├── README.md
├── LICENSE
├── src/
│ ├── main.c # lee la entrada y llama a a_mayusculas
│ ├── texto.c # define a_mayusculas
│ └── texto.h # la declara; lo incluyen los dos .c
└── tests/casos/
├── simple.entrada simple.esperado
├── vacio.entrada vacio.esperado
└── varias-lineas.entrada varias-lineas.esperado
#include <stdio.h>
#include <string.h>
#include "texto.h"
int main(int argc, char *argv[])
{
char linea[4096];
if (argc > 1 && strcmp(argv[1], "--version") == 0) {
printf("mayus %s\n", VERSION);
return 0;
}
while (fgets(linea, sizeof linea, stdin)) {
a_mayusculas(linea);
fputs(linea, stdout);
}
return 0;
}
No hace falta saber C para seguir el tema: lo que importa es que hay varios archivos fuente, que cada uno se compila por separado en un archivo objeto (.o), y que luego los objetos se enlazan en un ejecutable. VERSION no está definido en el código: lo pondrá el script, con una opción del compilador, a partir del archivo VERSION. Todo lo que el script genera va a dos directorios, build/ y dist/, que se pueden borrar sin perder nada.
3. Un primer script
La forma más simple de automatizar es escribir en un archivo los comandos que uno tecleaba:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p build
cc -O2 -Wall -c src/main.c -o build/main.o
cc -O2 -Wall -c src/texto.c -o build/texto.o
cc build/main.o build/texto.o -o build/mayus
Ya es una mejora real: los comandos quedan escritos, no se olvida ninguno y, gracias al modo estricto del tema 23, un error de compilación detiene todo. Pero tiene varios problemas, que el resto del tema resuelve uno por uno: recompila todo cada vez, aunque no haya cambiado nada; no prueba ni empaqueta; hay que agregar a mano cada archivo nuevo; solo funciona si se ejecuta desde la raíz del proyecto; y el compilador y sus opciones están fijos.
4. Las etapas como funciones
El primer paso es darle estructura: una función por etapa, como en las bibliotecas del tema 22, y que los argumentos del script digan cuáles ejecutar. ./build.sh compila; ./build.sh probar compila y prueba; ./build.sh limpiar empaquetar borra todo y hace el paquete desde cero.
Como cada etapa necesita la anterior, cada función llama primero a la que la precede. Eso plantea un problema: ./build.sh probar empaquetar ejecutaría probar dos veces, una pedida y otra porque empaquetar la necesita. La solución es una función que recuerde qué etapas ya se hicieron, con un array asociativo del tema 8:
declare -A hecho=()
una_vez() { # ejecuta la etapa $1, si no se ejecutó ya
[[ -n "${hecho[$1]:-}" ]] && return 0
hecho[$1]=1
"$1"
}
probar() {
una_vez compilar
…
}
empaquetar() {
una_vez probar # que a su vez compila, si hace falta
…
}
El nombre de la etapa es también el de la función, de modo que "$1" la ejecuta. Antes de eso, el script comprueba cada argumento contra la lista de etapas válidas con un case, como en el tema 13, para que ./build.sh rm no ejecute rm.
5. Rehacer solo lo que cambió
En un proyecto real, con cientos de archivos, recompilar todo puede llevar minutos. Pero si solo cambió un archivo, solo hace falta recompilarlo a él y volver a enlazar. La regla es la misma que usa make desde hace cincuenta años: un archivo generado hay que rehacerlo si no existe, o si alguno de los archivos de los que depende es más nuevo que él.
Bash tiene la comparación necesaria desde el tema 11: a -nt b es cierto si a se modificó después que b, o si b no existe. Con ella, una función responde la pregunta para cualquier archivo:
necesita() { # ¿hay que rehacer $1? Sí, si falta o algo es más nuevo
local destino="$1" dep
shift
[[ -e "$destino" ]] || return 0
for dep in "$@"; do
[[ "$dep" -nt "$destino" ]] && return 0
done
return 1
}
# Un objeto depende de su fuente; el ejecutable, de todos los objetos
if necesita build/obj/main.o src/main.c; then
cc -O2 -Wall -c src/main.c -o build/obj/main.o
fi
if necesita build/bin/mayus build/obj/main.o build/obj/texto.o; then
cc build/obj/main.o build/obj/texto.o -o build/bin/mayus
fi
La función devuelve 0, «sí, hay que rehacerlo», o 1, y se usa directamente como condición, como las funciones que responden sí o no del tema 19. Recorriendo src/*.c en lugar de nombrar cada archivo, un archivo nuevo se compila sin tocar el script.
6. Las dependencias de los encabezados
La regla anterior tiene un agujero. main.c incluye texto.h: si cambia el encabezado, main.o queda desactualizado, aunque main.c no haya cambiado. Mantener a mano la lista de qué incluye cada archivo es tedioso y propenso a errores. Pero el compilador ya lo sabe, porque lee los #include, y puede escribirlo: con -MMD, al compilar main.c, deja junto al objeto un archivo main.d con sus dependencias:
build/obj/main.o: src/main.c src/texto.h
src/texto.h:
El formato es el de una regla de make: el objeto, dos puntos y los archivos de los que depende, que pueden continuar en varias líneas terminadas en barra invertida. La opción -MP agrega las líneas siguientes, que solo le sirven a make. Para el script basta con la primera regla, y awk, como en el tema 29, la extrae:
dependencias() { # los archivos de los que depende el objeto $1, según su .d
local d="${1%.o}.d"
[[ -f "$d" ]] || return 0
awk 'NR == 1 { sub(/^[^:]*:/, "") } { fin = !sub(/\\$/, ""); printf "%s ", $0 } fin { exit }' "$d"
}
read -ra deps <<< "$(dependencias "$objeto")"
if necesita "$objeto" "$fuente" "${deps[@]}"; then …
El programa de awk quita, en la primera línea, todo hasta los dos puntos; en cada línea, quita la barra final si la hay, y si no la había, es la última de la regla y termina. Hay una sutileza que hace funcionar todo: la primera vez que se compila un archivo no hay .d, pero tampoco hay objeto, así que se compila igual, y en esa compilación nace el .d. El método supone, eso sí, que las rutas del proyecto no tienen espacios.
7. Cuando cambian las opciones
Hay otro cambio que deja los objetos desactualizados sin tocar ningún archivo: compilar con otras opciones. Si ayer se compiló con -O2 y hoy se pide -O0 -g para depurar, los objetos de ayer parecen al día, porque son más nuevos que sus fuentes, pero no son lo que se pidió. Lo mismo pasa al cambiar de compilador o de versión.
La solución es convertir la configuración en un archivo: el script escribe en build/config el compilador, las opciones y la versión, solo si cambiaron, y agrega ese archivo como dependencia de todos los objetos:
local config="$CC ${opciones_cc[*]} -DVERSION=$version"
if [[ ! -f build/config || "$(< build/config)" != "$config" ]]; then
printf '%s\n' "$config" > build/config
fi
…
if necesita "$objeto" "$fuente" "${deps[@]}" build/config; then …
Si la configuración no cambió, el archivo no se toca y su fecha no cambia. Si cambió, se reescribe, queda más nuevo que todos los objetos, y necesita los manda a recompilar. Es un truco sencillo que make, sorprendentemente, no trae de fábrica.
8. Las pruebas
Un programa que compila no es un programa que funciona. La etapa de pruebas ejecuta el programa sobre casos conocidos y compara lo que produce con lo esperado. La forma más simple, y suficiente para muchos programas de línea de comandos, es una carpeta con pares de archivos: NOMBRE.entrada, lo que recibe el programa, y NOMBRE.esperado, lo que debe producir.
for caso in tests/casos/*.entrada; do
nombre=$(basename "$caso" .entrada)
esperado="${caso%.entrada}.esperado"
if "$bin/$programa" < "$caso" > "$obtenido" 2>&1 && cmp -s -- "$esperado" "$obtenido"; then
echo " ok $nombre"
else
echo " FALLA $nombre"
diff -u --label esperado --label obtenido -- "$esperado" "$obtenido" | sed 's/^/ /' || true
fallidas=$(( fallidas + 1 ))
fi
done
Un caso pasa si el programa termina con éxito y su salida es idéntica byte a byte a la esperada, lo que comprueba cmp -s sin mostrar nada. Si falla, diff -u muestra exactamente la diferencia, que es lo primero que se quiere ver. El || true es necesario porque diff termina con 1 cuando los archivos difieren, y con pipefail eso detendría el script antes de contar el fallo. Al terminar, si alguna prueba falló, la etapa termina con error, y el script no llega a empaquetar.
Agregar una prueba es agregar dos archivos. Una buena costumbre: cuando aparece un error, antes de corregirlo, escribir el caso que lo reproduce. Así se comprueba que la corrección funciona, y el error no puede volver sin que alguien se entere.
9. Un paquete reproducible
La última etapa reúne lo que se entrega, el ejecutable y sus documentos, en un directorio con el nombre y la versión, y lo comprime. Pero un tar czf común produce un archivo distinto cada vez, aunque el contenido sea el mismo, porque guarda las fechas de modificación, el dueño de cada archivo y el orden en que el sistema de archivos los devolvió, y gzip agrega su propia fecha. Un paquete reproducible elimina esas diferencias:
| Opción | Elimina |
|---|---|
--sort=name | El orden arbitrario del sistema de archivos. |
--owner=0 --group=0 --numeric-owner | El usuario y el grupo de quien compiló. |
--mtime="@SEGUNDOS" | Las fechas de modificación: todas pasan a ser una fecha fija. |
gzip -n | El nombre y la fecha que gzip guarda en su encabezado. |
local fecha="@${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2> /dev/null || date +%s)}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="$fecha" \
-C build/paquete -cf - "$nombre" | gzip -n > "dist/$nombre.tar.gz"
(cd dist && sha256sum -- "$nombre.tar.gz" > "$nombre.tar.gz.sha256")
La fecha fija sale de la variable SOURCE_DATE_EPOCH, una convención que respetan muchas herramientas de compilación; si no está definida, de la fecha del último cambio en git, que es la misma en cualquier copia del repositorio; y como último recurso, de la hora actual. El @ delante indica a tar que es un número de segundos desde 1970. Con esas opciones, dos compilaciones del mismo código producen un paquete idéntico, con la misma suma de verificación, lo que permite comprobar que un paquete descargado salió realmente de ese código. El ejecutable en sí puede no ser idéntico entre compiladores distintos, pero el paquete ya no agrega diferencias propias.
La última línea guarda la suma SHA-256 del paquete junto a él. Quien lo reciba puede verificarlo con sha256sum -c, como veremos en el tema 38. Se calcula desde dentro de dist para que el archivo de sumas contenga solo el nombre, y no la ruta, y funcione en cualquier directorio.
10. Un script que funciona en cualquier lugar
Un script de compilación lo ejecutan personas distintas, en máquinas distintas, y también un sistema de integración continua. Unas pocas líneas al comienzo lo independizan de todos ellos:
cd "$(dirname -- "$(readlink -f -- "$0")")" # siempre desde la raíz del proyecto
export LC_ALL=C
: "${CC:=cc}"
: "${CFLAGS:=-O2 -Wall -Wextra}"
read -ra opciones_cc <<< "$CFLAGS"
- El directorio: el
cdlleva al directorio del script, la técnica del tema 34, de modo que../build.sho~/proyectos/mayus/build.shfuncionan igual que./build.sh. - El idioma:
LC_ALL=Chace que el orden de los comodines y desort, y los mensajes, sean los mismos en cualquier máquina. - El compilador y sus opciones, configurables:
: "${CC:=cc}"asigna un valor solo si la variable no está definida, con la expansión del tema 6. Así,CC=clang ./build.shoCFLAGS="-O0 -g" ./build.shcambian el compilador o las opciones sin editar el script, la convención que siguenmakey casi todas las herramientas de compilación. - Las opciones, en un array:
CFLAGScontiene varias opciones separadas por espacios. Convertirla en array conread -rapermite usarla como"${opciones_cc[@]}", entre comillas, en lugar de depender de la división en palabras de una variable sin comillas. - Nada interactivo: el script no pregunta nada ni depende de una terminal, de modo que puede ejecutarse en un servidor de integración continua, con cron o con un temporizador.
11. ¿Y make?
Lo que hacen las secciones 5 a 7 es, en esencia, lo que hace make: una herramienta dedicada a rehacer archivos a partir de sus dependencias. Para comparar, este es el equivalente de la etapa de compilación en un Makefile:
CC ?= cc
CFLAGS ?= -O2 -Wall -Wextra
VERSION := $(shell cat VERSION)
OBJ := $(patsubst src/%.c,build/obj/%.o,$(wildcard src/*.c))
build/bin/mayus: $(OBJ)
@mkdir -p $(@D)
$(CC) $(CFLAGS) $^ -o $@
build/obj/%.o: src/%.c
@mkdir -p $(@D)
$(CC) $(CFLAGS) -DVERSION='"$(VERSION)"' -MMD -MP -c $< -o $@
-include $(OBJ:.o=.d)
Es más corto, lee los archivos .d por sí mismo, y con make -j"$(nproc)" compila en paralelo. Para un proyecto con muchos archivos fuente, make, o la herramienta propia del lenguaje, como cargo, Maven o CMake, es la mejor opción para compilar, como adelantaba el tema 1. Pero su lenguaje es incómodo para todo lo demás: decidir etapas, recorrer casos de prueba, calcular versiones, armar un paquete o mostrar un resumen.
Por eso en la práctica conviven: el script de Bash es la puerta de entrada única, ./build.sh empaquetar, y delega la compilación propiamente dicha en make. Pasar de una a otra es reemplazar el cuerpo de la función compilar por make -j"$(nproc)" CC="$CC" CFLAGS="$CFLAGS"; las demás etapas no cambian.
12. Un script completo
Este es build.sh con todas las piezas del tema reunidas:
#!/usr/bin/env bash
#
# build.sh — compila, prueba y empaqueta el proyecto mayus,
# recompilando solo lo que cambió.
#
# Uso: ./build.sh [-v] [ETAPA...]
# ETAPA compilar (por omisión), probar, empaquetar o limpiar
# -v muestra cada comando que ejecuta
#
# Variables: CC, el compilador (cc por omisión), y CFLAGS, sus opciones.
#
set -euo pipefail
shopt -s nullglob
cd "$(dirname -- "$(readlink -f -- "$0")")" # siempre desde la raíz del proyecto
export LC_ALL=C
: "${CC:=cc}"
: "${CFLAGS:=-O2 -Wall -Wextra}"
read -ra opciones_cc <<< "$CFLAGS"
programa=mayus
version=$(< VERSION)
obj=build/obj
bin=build/bin
detallado=0
declare -A hecho=()
uso() { echo "Uso: ${0##*/} [-v] [compilar|probar|empaquetar|limpiar]..." >&2; exit 2; }
error() { echo "${0##*/}: $*" >&2; exit 1; }
etapa() { printf '==> %s\n' "$*"; }
ejecutar() { # ejecuta un comando, mostrándolo con -v
if (( detallado )); then printf ' %s\n' "$*"; fi
"$@"
}
una_vez() { # ejecuta la etapa $1, si no se ejecutó ya
[[ -n "${hecho[$1]:-}" ]] && return 0
hecho[$1]=1
"$1"
}
necesita() { # ¿hay que rehacer $1? Sí, si falta o algo es más nuevo
local destino="$1" dep
shift
[[ -e "$destino" ]] || return 0
for dep in "$@"; do
[[ "$dep" -nt "$destino" ]] && return 0
done
return 1
}
dependencias() { # los archivos de los que depende el objeto $1, según su .d
local d="${1%.o}.d"
[[ -f "$d" ]] || return 0
awk 'NR == 1 { sub(/^[^:]*:/, "") } { fin = !sub(/\\$/, ""); printf "%s ", $0 } fin { exit }' "$d"
}
compilar() {
etapa "Compilando $programa $version"
command -v "$CC" > /dev/null || error "no se encuentra el compilador: $CC"
mkdir -p "$obj" "$bin"
# Si cambian el compilador, sus opciones o la versión, hay que recompilar todo
local config="$CC ${opciones_cc[*]} -DVERSION=$version"
if [[ ! -f build/config || "$(< build/config)" != "$config" ]]; then
printf '%s\n' "$config" > build/config
fi
local fuentes=(src/*.c) fuente objeto deps objetos=() recompilados=0
(( ${#fuentes[@]} )) || error "no hay archivos .c en src/"
for fuente in "${fuentes[@]}"; do
objeto="$obj/$(basename "$fuente" .c).o"
objetos+=("$objeto")
read -ra deps <<< "$(dependencias "$objeto")"
if necesita "$objeto" "$fuente" "${deps[@]}" build/config; then
ejecutar "$CC" "${opciones_cc[@]}" -DVERSION="\"$version\"" -MMD -MP -c "$fuente" -o "$objeto"
recompilados=$(( recompilados + 1 ))
fi
done
if necesita "$bin/$programa" "${objetos[@]}"; then
ejecutar "$CC" "${opciones_cc[@]}" "${objetos[@]}" -o "$bin/$programa"
echo " $bin/$programa enlazado; recompilados $recompilados de ${#objetos[@]} archivos"
else
echo " $bin/$programa está al día"
fi
}
probar() {
una_vez compilar
etapa "Probando"
local caso nombre esperado fallidas=0 total=0
obtenido=$(mktemp)
trap 'rm -f -- "$obtenido"' EXIT
for caso in tests/casos/*.entrada; do
nombre=$(basename "$caso" .entrada)
esperado="${caso%.entrada}.esperado"
total=$(( total + 1 ))
if "$bin/$programa" < "$caso" > "$obtenido" 2>&1 && cmp -s -- "$esperado" "$obtenido"; then
echo " ok $nombre"
else
echo " FALLA $nombre"
diff -u --label esperado --label obtenido -- "$esperado" "$obtenido" | sed 's/^/ /' || true
fallidas=$(( fallidas + 1 ))
fi
done
(( total )) || error "no hay pruebas en tests/casos/"
echo " $(( total - fallidas )) de $total pruebas correctas"
if (( fallidas )); then
error "pruebas fallidas: $fallidas"
fi
}
empaquetar() {
una_vez probar
etapa "Empaquetando"
local nombre="$programa-$version"
local raiz="build/paquete/$nombre"
local fecha="@${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2> /dev/null || date +%s)}"
rm -rf -- build/paquete
mkdir -p "$raiz/bin" dist
cp -- "$bin/$programa" "$raiz/bin/"
cp -- README.md LICENSE "$raiz/"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="$fecha" \
-C build/paquete -cf - "$nombre" | gzip -n > "dist/$nombre.tar.gz"
(cd dist && sha256sum -- "$nombre.tar.gz" > "$nombre.tar.gz.sha256")
echo " dist/$nombre.tar.gz y su suma de verificación"
}
limpiar() {
etapa "Limpiando"
rm -rf -- build dist
}
while getopts ":v" opcion; do
case "$opcion" in
v) detallado=1 ;;
*) uso ;;
esac
done
shift $(( OPTIND - 1 ))
(( $# )) || set -- compilar
for e in "$@"; do
case "$e" in
compilar|probar|empaquetar|limpiar) ;;
*) uso ;;
esac
done
inicio=$SECONDS
for e in "$@"; do
una_vez "$e"
done
etapa "Listo en $(( SECONDS - inicio )) s"
$ ./build.sh
==> Compilando mayus 1.4.0
build/bin/mayus enlazado; recompilados 2 de 2 archivos
==> Listo en 1 s
$ ./build.sh
==> Compilando mayus 1.4.0
build/bin/mayus está al día
==> Listo en 0 s
$ touch src/main.c; ./build.sh
==> Compilando mayus 1.4.0
build/bin/mayus enlazado; recompilados 1 de 2 archivos
==> Listo en 0 s
$ touch src/texto.h; ./build.sh -v
==> Compilando mayus 1.4.0
cc -O2 -Wall -Wextra -DVERSION="1.4.0" -MMD -MP -c src/main.c -o build/obj/main.o
cc -O2 -Wall -Wextra -DVERSION="1.4.0" -MMD -MP -c src/texto.c -o build/obj/texto.o
cc -O2 -Wall -Wextra build/obj/main.o build/obj/texto.o -o build/bin/mayus
build/bin/mayus enlazado; recompilados 2 de 2 archivos
==> Listo en 1 s
$ ./build.sh empaquetar
==> Compilando mayus 1.4.0
build/bin/mayus está al día
==> Probando
ok simple
ok vacio
ok varias-lineas
3 de 3 pruebas correctas
==> Empaquetando
dist/mayus-1.4.0.tar.gz y su suma de verificación
==> Listo en 0 s
$ tar tvzf dist/mayus-1.4.0.tar.gz
drwxr-xr-x 0/0 0 2026-10-14 18:20 mayus-1.4.0/
-rw-r--r-- 0/0 1071 2026-10-14 18:20 mayus-1.4.0/LICENSE
-rw-r--r-- 0/0 412 2026-10-14 18:20 mayus-1.4.0/README.md
drwxr-xr-x 0/0 0 2026-10-14 18:20 mayus-1.4.0/bin/
-rwxr-xr-x 0/0 16184 2026-10-14 18:20 mayus-1.4.0/bin/mayus
Las dos primeras ejecuciones muestran la compilación incremental: la segunda no hace nada. Tocar main.c recompila solo ese archivo; tocar texto.h, que incluyen los dos, recompila ambos, gracias a los archivos .d. En el paquete, todos los archivos tienen dueño 0/0 y la fecha del último cambio en git. Y cuando una prueba falla, el script lo muestra y se detiene antes de empaquetar:
$ ./build.sh empaquetar
==> Compilando mayus 1.4.0
build/bin/mayus está al día
==> Probando
FALLA simple
--- esperado
+++ obtenido
@@ -1 +1 @@
-HOLA MUNDO
+HOLA MUNDO!
ok vacio
ok varias-lineas
2 de 3 pruebas correctas
build.sh: pruebas fallidas: 1
$ echo $?
1
- Las etapas son funciones con dependencias, y
una_vezgarantiza que cada una corre como máximo una vez por ejecución, en el orden correcto, pida lo que pida el usuario. - Es incremental en tres niveles: cada objeto se rehace si cambió su fuente, alguno de sus encabezados según el
.d, o la configuración; el ejecutable, si cambió algún objeto. - Se detiene en el primer error: un error de compilación detiene el script por el modo estricto, porque
ejecutarno está en una condición; una prueba fallida, porerror. En los dos casos el código de salida es distinto de cero, que es lo que mira un sistema de integración continua. -vmuestra los comandos sin cambiar lo que se ejecuta:ejecutarlos escribe antes de lanzarlos. Es una traza a medida, más legible que la deset -xdel tema 26.- El temporal de las pruebas se borra siempre, con el
trapdel tema 24. Por esoobtenidono es una variable local: eltrapse ejecuta al salir del script, cuando la función ya terminó. nullglobhace quesrc/*.cotests/casos/*.entradasin coincidencias den una lista vacía, que el script detecta y explica, en lugar de intentar compilar un archivo llamadosrc/*.c.- El paquete es reproducible y lleva su suma de verificación, y se arma en un directorio temporal dentro de
build/que se borra y se rehace cada vez, para que nunca contenga restos de un paquete anterior.
13. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
Un script sin set -e que compila y empaqueta | Un error de compilación no lo detiene: se empaqueta un programa viejo o roto. | Modo estricto, y pruebas antes de empaquetar. |
| Comparar solo la fuente con el objeto | Un cambio en un encabezado no recompila los archivos que lo incluyen. | -MMD -MP y leer los .d. |
Cambiar CFLAGS sin limpiar | Los objetos viejos parecen al día y se mezclan opciones. | La configuración como dependencia, o ./build.sh limpiar compilar. |
$CC $CFLAGS -c … sin comillas | Funciona hasta que un valor tiene un comodín o un espacio raro. | read -ra a un array y "${opciones_cc[@]}". |
CC=gcc fijo en el script | No se puede cambiar el compilador sin editarlo. | : "${CC:=cc}" |
Rutas relativas sin cd al comienzo | Desde otro directorio, el script no encuentra src/. | cd "$(dirname -- "$(readlink -f -- "$0")")" |
diff esperado obtenido con pipefail en una tubería | La diferencia esperada detiene el script antes de contar el fallo. | … || true |
tar czf común para el paquete | Cada compilación da un archivo distinto, con la misma fuente. | --sort=name --owner=0 --group=0 --mtime y gzip -n. |
sha256sum dist/x.tar.gz > dist/x.sha256 | La suma guarda la ruta dist/, y sha256sum -c falla en otro lugar. | Calcularla desde dentro de dist. |
for e in "$@"; do "$e"; done sin validar | ./build.sh rm ejecuta cualquier comando. | Validar contra la lista de etapas. |
14. Resumen
- Automatizar una compilación es reunir en una orden sus etapas, normalmente compilar, probar y empaquetar, de modo que se detenga ante el primer error y produzca siempre el mismo resultado.
- Cada etapa es una función que llama a la anterior; un array asociativo evita ejecutar una etapa dos veces, y los argumentos se validan contra la lista de etapas.
- Un archivo generado se rehace si no existe o si alguna de sus dependencias es más nueva, lo que comprueba
-nt. - Con
-MMD -MP, el compilador escribe en un.dlos encabezados que usa cada fuente; el script los lee y los agrega a las dependencias. - Guardar la configuración en un archivo que solo se reescribe al cambiar, y hacerlo dependencia de todo, recompila cuando cambian el compilador, las opciones o la versión.
- Las pruebas comparan la salida del programa sobre casos conocidos con la esperada, con
cmp -s, y muestran las diferencias condiff -u. - Un paquete reproducible usa
--sort=name, dueño0, una fecha fija deSOURCE_DATE_EPOCHo git, ygzip -n, y va acompañado de su sumasha256sum. - El script se independiza del entorno con un
cda su directorio,LC_ALL=Cy variablesCCyCFLAGScon valores por omisión. - Para proyectos grandes,
makeo la herramienta del lenguaje compilan mejor; el script de Bash queda como puerta de entrada que las llama y se ocupa del resto.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué significa que una compilación sea incremental?
Que solo rehace los archivos afectados por los cambios desde la última compilación, en lugar de recompilar todo cada vez.
2. ¿Cuándo hay que rehacer un archivo generado?
Cuando no existe, o cuando alguno de los archivos de los que depende se modificó después que él. En Bash se comprueba con [[ dependencia -nt archivo ]].
3. ¿Por qué comparar main.c con main.o no alcanza?
Porque main.o depende también de los encabezados que incluye main.c. Si cambia texto.h, main.o queda desactualizado aunque main.c no haya cambiado.
4. ¿Qué hace la opción -MMD del compilador?
Al compilar, escribe junto al objeto un archivo .d con la lista de archivos de los que depende: la fuente y los encabezados del proyecto que incluye. El script lo lee para saber cuándo recompilar.
5. ¿Para qué sirve el archivo build/config?
Guarda el compilador, las opciones y la versión con que se compiló. Solo se reescribe si cambiaron, y como es dependencia de todos los objetos, un cambio de configuración los hace recompilar.
6. ¿Qué evita la función una_vez?
Que una etapa se ejecute más de una vez en la misma ejecución, cuando la piden el usuario y otra etapa que depende de ella, como en ./build.sh probar empaquetar.
7. ¿Cuándo pasa un caso de prueba en build.sh?
Cuando el programa termina con código 0 y su salida es idéntica byte a byte al archivo .esperado, lo que comprueba cmp -s.
8. ¿Por qué dos tar czf del mismo directorio pueden dar archivos distintos?
Porque guardan las fechas de modificación, el dueño de los archivos y el orden en que los devuelve el sistema de archivos, y gzip agrega su propia fecha. Las opciones --sort=name, --owner=0 --group=0, --mtime y gzip -n eliminan esas diferencias.
9. ¿Qué hace : "${CC:=cc}"?
Si CC no está definida o está vacía, le asigna cc. Así, el usuario puede elegir otro compilador con CC=clang ./build.sh sin editar el script. Los dos puntos son un comando que no hace nada: solo sirve para que la expansión se evalúe.
10. Si make ya hace la compilación incremental, ¿para qué un script de Bash?
Para todo lo que rodea a la compilación y que en el lenguaje de make es incómodo: elegir etapas, ejecutar las pruebas, calcular la versión, armar el paquete y mostrar un resumen. Lo habitual es que el script sea la puerta de entrada y llame a make para compilar.
Para practicar
- Crea el proyecto
mayuscon los archivos de la sección 2 y el script completo. Comprueba que una segunda ejecución no recompila nada, y que tocartexto.hrecompila los dos archivos. - Agrega un archivo
src/contar.ccon su encabezado y comprueba que el script lo compila sin cambios. - Compila con
CFLAGS="-O0 -g" ./build.shy luego sin la variable. ¿Cuántos archivos se recompilan cada vez, y por qué? - Agrega un caso de prueba con un texto que tenga acentos y observa qué hace el programa con ellos. ¿Pasa la prueba si esperabas «ÁRBOL»?
- Empaqueta dos veces con el mismo
SOURCE_DATE_EPOCHy compara las sumas. Luego quita la opción-ndegzipy repite. - Escribe el
Makefilede la sección 11 y cambia la funcióncompilarpara que lo use, conmake -j"$(nproc)". Comprueba que las demás etapas siguen funcionando.