Módulo 11: Laboratorio — hooks, seguridad y CI/CD

Unidad: GIT-13
Capa: laboratorio avanzado.
Público: alumnado de DAW, DAM, ASIR y grados de informática con el tronco completo.
Itinerario: posterior al trabajo diario con remotos, recuperación y proyecto final.
Prerrequisitos: módulos 5, 6 y 8; una cuenta de GitHub y un repositorio propio.
Objetivo observable: distinguir los tres niveles de control —validación local, política del servidor y automatización de la forja—, configurar un hook, explicar por qué no es una medida de seguridad y hacer que una comprobación automática impida integrar código roto.
Fuentes técnicas: githooks, gitignore, rulesets de GitHub y GitHub Actions.
Duración orientativa: 3 horas.

Idea clave: tres niveles, tres garantías distintas

Nivel Dónde vive Quién lo puede saltar Para qué sirve
Hook local Tu .git/hooks Tú mismo, con --no-verify Ayudarte a no cometer un error
Política del servidor Configuración del repositorio en GitHub Nadie, salvo quien tenga bypass Impedir que algo entre en main
CI/CD Ejecución en los servidores de GitHub Nadie Comprobar de verdad que el código funciona

La confusión que este laboratorio quiere eliminar: un hook local no protege el repositorio. Es una comodidad para quien lo tiene instalado. Lo que protege es la política del servidor. Todo lo del nivel 1 es Git; los niveles 2 y 3 no son Git, son GitHub.


Laboratorio A — Hooks locales

Un hook es un guion que Git ejecuta automáticamente en un momento del ciclo de trabajo. Si el guion devuelve un código de salida distinto de 0, Git aborta la operación.

Los que ya tienes

mkdir hooks-practica && cd hooks-practica
git init -b main
ls .git/hooks
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
…

Git instala ejemplos con extensión .sample, inactivos precisamente por esa extensión. Un hook se activa cuando existe con el nombre exacto (pre-commit) y permiso de ejecución.

Un primer hook

printf '#!/bin/sh\nif git diff --cached --check; then exit 0; else exit 1; fi\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

printf 'línea con espacio    \n' > prueba.txt
git add prueba.txt
git commit -m "test: prueba de hook"
prueba.txt:1: trailing whitespace.
+línea con espacio    

El commit no se ha creado. El archivo sigue preparado y el historial no ha avanzado:

git log --oneline
git status --short
fatal: your current branch 'main' does not have any commits yet
A  prueba.txt

Corrige y repite:

printf 'línea sin espacio\n' > prueba.txt
git add prueba.txt
git commit -m "test: prueba de hook"
[main (root-commit) 6a0b0e1] test: prueba de hook
 1 file changed, 1 insertion(+)

Aquí se reutiliza git diff --cached --check, que ya conoces del módulo 12: detecta espacios sobrantes y marcadores de conflicto.

Un hook útil: detectar credenciales

cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
# Rechaza el commit si detecta algo que parezca una credencial.
if git diff --cached -U0 | grep -nE '^\+.*(AKIA[0-9A-Z]{16}|api[_-]?key\s*=|password\s*=|BEGIN [A-Z ]*PRIVATE KEY)'; then
    echo ""
    echo "pre-commit: posible credencial en los cambios preparados. Commit abortado."
    exit 1
fi
exit 0
EOF
chmod +x .git/hooks/pre-commit

printf 'api_key = "AKIAIOSFODNN7EXAMPLE"\n' > config.py
git add config.py
git commit -m "feat: añade configuración"
7:+api_key = "AKIAIOSFODNN7EXAMPLE"

pre-commit: posible credencial en los cambios preparados. Commit abortado.

Solo inspecciona las líneas añadidas de lo preparado (-U0 y el filtro ^\+), que es lo que estás a punto de confirmar.

Y ahora la lección del laboratorio

git commit --no-verify -m "feat: añade configuración"
git log --oneline -1
b8cb704 feat: añade configuración

El secreto ha entrado en el historial y el hook no ha dicho nada. --no-verify salta todos los hooks, y no hay forma de impedirlo: el hook se ejecuta en tu equipo, con tus permisos.

Y hay un segundo agujero:

cd .. && git clone hooks-practica clon
ls clon/.git/hooks/pre-commit
ls: clon/.git/hooks/pre-commit: No such file or directory

Los hooks no viajan al clonar. Están en .git/, que no se versiona. Quien clone tu proyecto no tendrá ninguna de tus protecciones.

De ahí la conclusión que hay que llevarse: un hook es un despertador, no una cerradura.

Compartir hooks con el equipo: core.hooksPath

Se pueden versionar en una carpeta normal del proyecto:

mkdir -p .githooks
cp .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git add .githooks && git commit -m "chore: añade hooks del proyecto en .githooks"

git config core.hooksPath .githooks

Compruébalo:

printf 'password = "hunter2"\n' > otro.py
git add otro.py
git commit -m "feat: otro"
7:+password = "hunter2"

pre-commit: posible credencial en los cambios preparados. Commit abortado.

Ahora el hook está en el repositorio y le llega a todo el mundo… pero cada persona debe ejecutar git config core.hooksPath .githooks una vez. Git no lo activa solo, y con razón: ejecutar automáticamente guiones que vienen en un repositorio clonado sería un problema de seguridad grave.

Es un buen sitio para documentarlo en el README, junto a las instrucciones de instalación.


Laboratorio B — Un secreto publicado por error

Es la emergencia más común del curso, y el orden de los pasos importa más que los comandos.

El protocolo

  1. Revocar o rotar la credencial en su proveedor. Este paso es el primero y no es negociable. Borrarla del repositorio no la invalida: si alguien la copió, sigue funcionando.
  2. Avisar al equipo y detener cualquier uso de esa credencial.
  3. Eliminarla del estado actual: quitar el archivo, añadir la regla a .gitignore (módulo 4) y hacer un commit correctivo.
  4. Solo entonces, valorar si hay que borrarla del historial.

Por qué el orden es ese

Recupera el repositorio del laboratorio A, donde el secreto entró con --no-verify:

git log -S "AKIAIOSFODNN7EXAMPLE" --oneline
git grep -n "AKIA" $(git rev-list --all) -- config.py
b8cb704 feat: añade configuración
73f2607…:config.py:1:api_key = "AKIAIOSFODNN7EXAMPLE"
b8cb704…:config.py:1:api_key = "AKIAIOSFODNN7EXAMPLE"

Aunque borres el archivo hoy, el valor sigue siendo recuperable desde cualquier clon. Es lo mismo que demostró el módulo 6 con revert: corriges el estado, no el pasado.

Reescribir el historial para eliminarlo (con git filter-repo o git lfs migrate-style rewriting) cambia todos los hashes posteriores y obliga a que el equipo entero vuelva a clonar. Es una operación coordinada, con responsable y aviso previo — nunca un push --force improvisado.

Y aun haciéndolo bien, la credencial estuvo expuesta. Por eso el paso 1 es rotarla: es el único que realmente cierra el problema.

Prevención, en los tres niveles

Nivel Medida
Local .gitignore desde el primer commit; hook pre-commit
Servidor Protección de main; secret scanning de GitHub
CI Un paso que busque patrones de credencial y falle

Ninguno basta solo. El .gitignore es el más barato y el que más disgustos evita.


Laboratorio C — Política del servidor y CI en GitHub

Este laboratorio necesita un repositorio propio en GitHub. Todo lo que sigue está reproducido sobre uno real.

Proteger main

En Settings → Rules → New ruleset, con objetivo la rama por defecto y estas reglas:

  • Require a pull request before merging — nada entra en main sin PR.
  • Block force pushes (non_fast_forward) — impide reescribir lo publicado.
  • Restrict deletions — impide borrar la rama.

Un detalle que cuesta un rato descubrir: un ruleset recién creado puede quedar en Disabled, es decir, guardado pero sin aplicarse. Hay que ponerlo en Active. Compruébalo intentando saltártelo:

git switch main
printf "\nLinea de prueba.\n" >> README.md
git commit -am "test: comprueba la proteccion de main"
git push origin main
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote:
remote: - Changes must be made through a pull request.
remote:
 ! [remote rejected] main -> main (push declined due to repository rule violations)
error: failed to push some refs to 'github.com:usuario/repo.git'

Compara esta protección con el hook del laboratorio A: esto no se salta con --no-verify. Se aplica en el servidor, no en tu equipo. Ese es el salto de nivel.

Conviene además no añadirse a uno mismo como actor de bypass: si quien imparte el curso puede saltarse la regla, el alumnado aprende que la regla es opcional.

Un workflow de CI

Crea .github/workflows/ci.yml en una rama, nunca directamente en main:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  comprobar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Comprobaciones del proyecto
        run: ./comprobar.sh

      - name: Buscar credenciales en los cambios
        run: |
          if grep -rnE '(AKIA[0-9A-Z]{16}|BEGIN [A-Z ]*PRIVATE KEY)' --include='*' . ; then
            echo "Se ha detectado una posible credencial."
            exit 1
          fi
          echo "Sin credenciales detectadas."

Con el comprobar.sh del módulo 12:

#!/bin/sh
test -f README.md || { echo "FALLO: falta README.md"; exit 1; }
echo "OK: comprobaciones superadas"

Publica y abre la pull request:

git switch -c lab/ci-ejemplo
git add .github/workflows/ci.yml comprobar.sh
git commit -m "ci: añade workflow de comprobaciones"
git push -u origin lab/ci-ejemplo

GitHub ejecuta el workflow automáticamente al abrir la PR:

gh run list --limit 1
completed  success  ci: añade workflow de comprobaciones  CI  lab/ci-ejemplo  pull_request  7s

Fíjate en que es el mismo contrato de código de salida que usan el hook del laboratorio A, comprobar.sh del módulo 12 y git bisect run del módulo 9: 0 pasa, distinto de 0 falla. Aprendido una vez, sirve en los tres sitios.

Cuando falla

Añade a la rama un commit con una credencial simulada y observa:

./config.py:1:AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
Se ha detectado una posible credencial.
##[error]Process completed with exit code 1.
gh run list --limit 1
completed  failure  feat: añade configuración  CI  lab/ci-ejemplo  pull_request  8s

El paso que casi todo el mundo olvida

Con la CI en rojo, mira si la PR se puede integrar:

gh pr view 1 --json mergeable,mergeStateStatus
{"mergeStateStatus":"UNSTABLE","mergeable":"MERGEABLE"}

MERGEABLE. La CI ha fallado y aun así la pull request se puede fusionar. La protección exigía una PR, no exigía que las comprobaciones pasaran. Son dos reglas distintas, y sin la segunda la CI es solo un semáforo decorativo.

Añade al ruleset Require status checks to pass, indicando el nombre del job (comprobar), y repite:

{"mergeStateStatus":"BLOCKED","mergeable":"MERGEABLE"}
gh pr merge 1 --merge
X Pull request #1 is not mergeable: the base branch policy prohibits the merge.

Ahora sí. Corrige el problema, la CI pasa a verde y el estado cambia a CLEAN:

{"mergeStateStatus":"CLEAN","mergeable":"MERGEABLE","checks":[{"name":"comprobar","conclusion":"SUCCESS"}]}

Esa secuencia UNSTABLE → BLOCKED → CLEAN es el resumen del laboratorio.

Lo que la CI no hace

Comprueba lo que le has pedido comprobar, nada más. Una CI en verde no significa que el código sea correcto, seguro ni legible: significa que pasó tus comprobaciones. No sustituye la revisión humana de la pull request (módulo 8), la complementa.

Errores frecuentes

  • Confiar en un hook local como control de seguridad. Se salta con --no-verify y no llega a quien clona. Lo que protege es la política del servidor.
  • Versionar hooks en .githooks y creer que ya están activos. Cada persona debe ejecutar git config core.hooksPath .githooks una vez. Documéntalo en el README.
  • Crear el ruleset y dejarlo en Disabled. Parece configurado y no aplica nada. Compruébalo siempre intentando un push directo a main.
  • Exigir pull request pero no exigir que la CI pase. La PR se puede fusionar en rojo. Son dos reglas separadas.
  • Añadir .env al .gitignore después de haberlo confirmado y creer que desaparece del historial. No desaparece (módulo 6).
  • Rotar la credencial al final, o no rotarla. Es el primer paso, no el último: el resto no la invalida.
  • Copiar un token real en un ejemplo, una incidencia o un ejercicio. Usa valores de documentación, como AKIAIOSFODNN7EXAMPLE.
  • Darse permisos de bypass sobre las propias reglas. Convierte la política en una sugerencia.

Ejercicio y criterios

Sobre un repositorio propio en GitHub:

  1. Escribe un hook pre-commit que rechace credenciales. Demuestra el rechazo y el commit corregido.
  2. Demuestra que el hook no es una protección: sáltalo con --no-verify y comprueba que el clon no lo hereda.
  3. Versiónalo en .githooks, actívalo con core.hooksPath y documenta el paso en el README.
  4. Protege main con un ruleset activo y captura el mensaje GH013 de un push directo rechazado.
  5. Añade un workflow de CI con las comprobaciones del proyecto y ábrelo en una pull request.
  6. Provoca un fallo de CI y captura el estado de la PR antes y después de añadir «Require status checks to pass».
  7. Documenta el protocolo de respuesta ante una clave expuesta, justificando el orden de los pasos.

Criterios de revisión:

  • Se distingue con evidencia propia qué es Git (hooks), qué es la política de GitHub y qué es la CI.
  • Aparece el rechazo GH013 y se explica por qué --no-verify no lo evita.
  • Se muestran los dos estados de la PR (UNSTABLE y BLOCKED) y se explica la diferencia.
  • El protocolo de la credencial empieza por rotarla, y se justifica por qué.
  • Ningún ejemplo contiene una credencial real.
  • No se ha usado push --force sobre main ni permisos de bypass.

Resumen

Al finalizar, el alumnado debe saber:

  • Escribir y activar un hook, y explicar el contrato de código de salida.
  • Argumentar por qué un hook local no es una medida de seguridad: --no-verify y la no distribución al clonar.
  • Compartir hooks con core.hooksPath, sabiendo que requiere una activación manual por persona.
  • Proteger una rama en GitHub y reconocer el rechazo GH013.
  • Escribir un workflow de CI sencillo y leer su resultado.
  • Distinguir «exigir PR» de «exigir que la CI pase», y saber que hacen falta las dos.
  • Aplicar el protocolo ante una credencial expuesta, empezando por rotarla.

Estado editorial: listo.

Registro de revisión técnica

Revisado el 5 de agosto de 2026 con Git 2.47.1, gh 2.97.0 y GitHub Actions. Los laboratorios A y B se han reproducido en local desde carpetas vacías; el laboratorio C, sobre un repositorio real de GitHub, con su ruleset y sus ejecuciones de Actions.

Laboratorio A. Verificado el rechazo del hook pre-commit con git diff --cached --check (trailing whitespace, historial sin avanzar, archivo aún preparado) y el commit correcto tras corregir. Se ha añadido, y verificado, lo que faltaba para que el laboratorio tenga sentido:

  • Un hook de detección de credenciales que aborta con su mensaje propio.
  • Que git commit --no-verify lo salta por completo y el secreto entra en el historial. Sin esta demostración, el laboratorio daba a entender que un hook protege el repositorio.
  • Que un clon no hereda los hooks (.git/hooks/pre-commit no existe en el clon).
  • core.hooksPath como forma de versionarlos, comprobando que actúa desde .githooks y que exige una activación manual por persona.

Laboratorio B. Verificado con git log -S y git grep sobre git rev-list --all que la credencial confirmada sigue siendo recuperable desde cualquier clon aunque se borre el archivo. Se ha reordenado el protocolo para dejar la rotación de la credencial como paso 1 explícito y justificado.

Laboratorio C, reproducido sobre GitHub. Se ha comprobado que:

  • Un ruleset creado queda en enforcement: disabled hasta activarlo; en ese estado no impide nada. Documentado como error frecuente porque parece configurado.
  • Con el ruleset activo, un push directo a main se rechaza con GH013: Repository rule violations found y Changes must be made through a pull request.
  • El workflow se ejecuta al abrir la PR (success, 7 s) y falla cuando el paso de búsqueda de credenciales encuentra AKIAIOSFODNN7EXAMPLE, con ##[error]Process completed with exit code 1.
  • El hallazgo principal del laboratorio: con la CI en rojo y solo la regla de pull request, la PR queda en mergeStateStatus: UNSTABLE y mergeable: MERGEABLE, es decir, se puede fusionar igualmente. Tras añadir required_status_checks con el contexto comprobar, el estado pasa a BLOCKED y gh pr merge responde the base branch policy prohibits the merge. Al corregir, CLEAN. La secuencia UNSTABLE → BLOCKED → CLEAN se ha incorporado como columna vertebral de la sección.

Todos los valores de credencial usados son el ejemplo de documentación de AWS (AKIAIOSFODNN7EXAMPLE), nunca claves reales. Los hashes y tiempos de las salidas corresponden a esta ejecución.