La imagen aurora-api:2.0.0 es correcta, pero la construyes tú, en tu portátil, con tu caché y tu git status. Esta lección quita al humano de en medio: cada git push dispara una cadena que ejecuta las pruebas, construye para dos arquitecturas, escanea, firma, publica y despliega, sin que nadie tenga que acordarse de nada.

Contenido

  1. CI, delivery y deployment: tres conceptos, dos siglas
  2. Por qué Docker encaja: el artefacto es la imagen
  3. Anatomía del pipeline
  4. Pruebas dentro de contenedores con Compose
  5. La etapa pruebas del Dockerfile y --target
  6. El pipeline de Aurora Libros: disparadores y permisos
  7. Etiquetado automático con metadata-action
  8. Construcción multiarquitectura con caché gha
  9. Escaneo con Trivy que rompe la build
  10. Firma keyless, SBOM y procedencia
  11. El pipeline equivalente en GitLab CI
  12. Docker-in-Docker frente al socket montado
  13. Gestión de credenciales: OIDC y tokens efímeros
  14. Versionado automático desde las etiquetas de Git
  15. El despliegue automatizado

Advertencia. Un runner de CI con acceso a tu registro y a tus servidores es una pieza de infraestructura crítica: quien controle el pipeline puede publicar y desplegar cualquier cosa. Los permisos de los runners, el alcance de los tokens y el acceso SSH a los entornos deben definirse y revisarse con el responsable de infraestructura y de seguridad de tu organización.

  1. CI, delivery y deployment: tres conceptos, dos siglas

Concepto Qué automatiza Dónde acaba Intervención humana
Integración continua (CI) Compilar, analizar y probar cada cambio Un artefacto validado Ninguna
Entrega continua (continuous delivery) Todo lo anterior + dejar el artefacto listo para desplegar El registro, con la imagen publicada Alguien aprueba el despliegue
Despliegue continuo (continuous deployment) Todo lo anterior + desplegar automáticamente Producción Ninguna

Las dos últimas comparten la abreviatura CD y se confunden constantemente. La diferencia práctica es un botón: en delivery, la imagen está lista y espera un apruebo; en deployment, cada merge a main que pase todas las puertas llega a producción sola. Aurora Libros hará delivery hacia producción y deployment hacia staging: es la combinación más habitual y la más sensata mientras la cobertura de pruebas no dé para confiar a ciegas.

  1. Por qué Docker encaja: el artefacto es la imagen

Antes de los contenedores, el artefacto de CI era un .jar, un .zip o un tarball, y el entorno donde corría se preparaba por separado: de ahí venía el "en mi máquina funciona", porque el artefacto viajaba y el entorno no. Con Docker, el artefacto incluye su entorno, y eso desbloquea tres propiedades que hacen fiable un pipeline:

  • Inmutabilidad. El digest identifica un contenido exacto: sha256:a1b2... es la misma cosa en CI, en staging y en producción, para siempre.
  • Promoción sin reconstruir. Se promociona retiquetando. Reconstruir para producción significa desplegar un artefacto que nadie ha probado.
  • Paridad de entornos. El contenedor que pasó las pruebas es, byte a byte, el que atiende a los clientes.

La regla que resume el módulo: construir una vez, desplegar muchas. Un commit produce una imagen, esa imagen tiene un digest, y ese digest es el que se promociona a staging y a producción. Si tu pipeline tiene un docker build por entorno, tiene un fallo de diseño.

  1. Anatomía del pipeline

flowchart TD
    A[checkout] --> B[lint]
    B --> C[pruebas en contenedor]
    C --> D[build multiarquitectura]
    D --> E[escaneo de CVE]
    E --> F[firma + SBOM]
    F --> G[publicación en el registro]
    G --> H{rama?}
    H -->|main| I[deploy staging]
    H -->|etiqueta v*| J[deploy producción]
Fase Qué puede fallar Cuánto tarda ¿Rompe la build?
Checkout + lint Submódulos, estilo, console.log olvidado ~25 s
Pruebas Regresión, flaky por dependencias 1-3 min
Build Fallo de compilación, caché fría 40 s - 4 min
Escaneo CVE crítica en una dependencia ~30 s Sí (críticas)
Firma / SBOM Permisos OIDC mal configurados ~15 s
Publicación Credenciales, cuota del registro ~30 s
Despliegue Red, sondas que no pasan 1-5 min Sí, con rollback

El orden no es arbitrario: lo barato y lo que más falla, primero. Un lint de 20 segundos que detecta el error evita gastar cuatro minutos de build multiarquitectura. Es el mismo principio de la caché de capas de 02-02, aplicado al pipeline.

  1. Pruebas dentro de contenedores con Compose

Probar contra una base de datos real, y no contra un mock, es lo que distingue una prueba útil de una que aprueba código roto. Con Compose es trivial y, sobre todo, idéntico en tu portátil y en el runner.

# compose.pruebas.yaml — pila efímera, sin volúmenes: se muere y no deja rastro
services:
  pruebas:
    build: { context: ./api, target: pruebas }   # la etapa del Dockerfile de 06-01
    environment:
      DB_HOST: db-pruebas
      DB_USER: aurora
      DB_PASSWORD: prueba-ficticia
      DB_NAME: aurora_libros_test
      REDIS_HOST: cache-pruebas
    depends_on:
      db-pruebas:    { condition: service_healthy }
      cache-pruebas: { condition: service_healthy }

  db-pruebas:
    image: postgres:16-alpine
    environment: { POSTGRES_USER: aurora, POSTGRES_PASSWORD: prueba-ficticia, POSTGRES_DB: aurora_libros_test }
    volumes: ["./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro"]
    tmpfs: ["/var/lib/postgresql/data"]     # datos en RAM: más rápido y efímero de verdad
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_libros_test"]
      interval: 2s
      retries: 15

  cache-pruebas:
    image: redis:7-alpine
    healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 15 }
docker compose -f compose.pruebas.yaml up \
  --build --abort-on-container-exit --exit-code-from pruebas
docker compose -f compose.pruebas.yaml down -v

Las dos banderas son el corazón del asunto y conviene entenderlas bien:

  • --abort-on-container-exit para toda la pila en cuanto un contenedor termina. Sin ella, las pruebas acaban y PostgreSQL sigue vivo: el comando nunca retorna y el job se queda colgado hasta el timeout.
  • --exit-code-from pruebas hace que el código de salida de docker compose sea el del contenedor de pruebas, no el de la operación de Compose. Sin ella, el comando devuelve 0 aunque las pruebas fallen, y el pipeline se pone verde con el código roto. Este es, con diferencia, el error más frecuente de esta lección.

El tmpfs sobre el directorio de datos de PostgreSQL merece una nota: en pruebas no necesitas durabilidad, así que poner los datos en RAM elimina la escritura a disco y suele reducir la suite entre un 30 % y un 50 %. Nunca, jamás, en producción.

  1. La etapa pruebas del Dockerfile y --target

El Dockerfile de 06-01 ya tenía la etapa preparada. Ejecutarla sola es un --target:

docker build --target pruebas -t aurora-api:pruebas api/
Enfoque Ventaja Inconveniente
Etapa pruebas con --target Mismo Dockerfile, mismas capas cacheadas Necesita los servicios aparte
compose.pruebas.yaml Pila completa con BD y caché reales Un fichero más que mantener
Sin contenedor, en el runner Rapidísimo El runner deja de ser reproducible

Aurora Libros usa las dos primeras a la vez: compose.pruebas.yaml construye con target: pruebas. Y hay un beneficio de caché nada menor: la etapa deps que instala node_modules es común a las pruebas y a la imagen final, así que el build de producción reutiliza esas capas y no reinstala nada.

  1. El pipeline de Aurora Libros: disparadores y permisos

# .github/workflows/ci.yaml
name: CI/CD Aurora Libros

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]        # v2.0.0 dispara la publicación de release
  pull_request:
    branches: [main]

env:
  REGISTRO: ghcr.io
  IMAGEN: ${{ github.repository_owner }}/aurora-api

# Un push nuevo sobre la misma rama cancela el pipeline anterior: ahorra minutos y cuota
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  pruebas:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Lint y pruebas contra PostgreSQL y Redis reales
        run: |
          docker compose -f compose.pruebas.yaml up \
            --build --abort-on-container-exit --exit-code-from pruebas
      - name: Limpiar la pila
        if: always()          # también si las pruebas fallaron
        run: docker compose -f compose.pruebas.yaml down -v

Sobre los disparadores: los pull requests ejecutan las pruebas y construyen sin publicar, porque una rama de nadie no debe poder subir una imagen al registro. Los push a main publican con la etiqueta edge y despliegan a staging. Las etiquetas v*.*.* producen la versión SemVer publicable.

  1. Etiquetado automático con metadata-action

  publicar:
    needs: pruebas                                  # no se construye si las pruebas fallaron
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write        # publicar en ghcr.io
      id-token: write        # OIDC para la firma keyless de Cosign
      security-events: write # subir el informe de Trivy a la pestaña Security
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRO }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}     # token efímero, no una contraseña

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRO }}/${{ env.IMAGEN }}
          tags: |
            type=semver,pattern={{version}}         # v2.0.0 -> 2.0.0
            type=semver,pattern={{major}}.{{minor}} # v2.0.0 -> 2.0
            type=semver,pattern={{major}}           # v2.0.0 -> 2
            type=ref,event=branch                   # main   -> main
            type=sha,prefix=sha-,format=short       # siempre -> sha-a1b2c3d
            type=raw,value=latest,enable={{is_default_branch}}
          labels: |
            org.opencontainers.image.title=aurora-api
            org.opencontainers.image.vendor=Aurora Libros S.L.

Esta acción sustituye el guion de sed y git describe que todo el mundo acaba escribiendo. De la etiqueta v2.0.0 deduce sola las cuatro etiquetas móviles y genera además las etiquetas OCI de 06-01 con la fecha, el commit y la URL del repositorio, sin que tengas que pasarlas como --build-arg.

El type=sha es el más importante de la lista aunque parezca el más aburrido: cada build produce una etiqueta única e irrepetible, así que siempre puedes referirte a una construcción concreta aunque latest y 2.0 se hayan movido diez veces.

  1. Construcción multiarquitectura con caché gha

      - uses: docker/setup-qemu-action@v3            # emulación para arm64 (05-05)
      - uses: docker/setup-buildx-action@v3          # builder con driver docker-container

      - id: build
        uses: docker/build-push-action@v5
        with:
          context: ./api
          target: runtime
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}   # los PR construyen, no publican
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=aurora-api
          cache-to: type=gha,scope=aurora-api,mode=max
          provenance: mode=max                       # attestation de procedencia SLSA
          sbom: true                                 # SBOM adjunta al índice de la imagen
          build-args: |
            REVISION=${{ github.sha }}

Los dos parámetros de caché son la diferencia entre un pipeline usable y uno insoportable. Sin ellos, cada ejecución arranca con una caché vacía —el runner es una máquina nueva— y npm ci se ejecuta entero dos veces, una por arquitectura. Con type=gha, BuildKit guarda las capas en la caché de GitHub Actions y las recupera en el siguiente build.

Configuración Build en frío Build con caché Con cambio solo en src/
Sin caché 4 min 10 s 4 min 10 s 4 min 10 s
type=gha,mode=min 4 min 20 s 1 min 05 s 55 s
type=gha,mode=max 4 min 35 s 48 s 41 s

mode=max guarda también las capas intermedias de las etapas previas —incluidas deps y deps-dev—, así que ocupa más caché pero acierta muchísimo más. El scope evita que dos workflows distintos se pisen las entradas. Y un aviso operativo: la caché de GitHub Actions tiene un límite de 10 GB por repositorio y expulsa por antigüedad, así que un mode=max con muchas ramas puede desalojar entradas útiles.

  1. Escaneo con Trivy que rompe la build

      - name: Escaneo de vulnerabilidades
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
          format: sarif
          output: trivy.sarif
          severity: CRITICAL,HIGH
          ignore-unfixed: true       # sin parche disponible, romper la build no arregla nada
          exit-code: "0"             # este paso solo informa...

      - uses: github/codeql-action/upload-sarif@v3
        with: { sarif_file: trivy.sarif }

      - name: "Puerta de calidad: ninguna crítica corregible"
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
          severity: CRITICAL
          ignore-unfixed: true
          exit-code: "1"             # ...y este rompe la build

El escaneo se hace por digest, no por etiqueta: garantiza que analizas exactamente la imagen que acabas de construir y no otra que alguien haya publicado con el mismo nombre entretanto.

La estructura en dos pasos es deliberada. El primero recoge todo (críticas y altas) y lo sube a la pestaña de seguridad para tener visibilidad; el segundo, mucho más estricto, es la puerta: solo rompe ante críticas con parche disponible. Ese ignore-unfixed no es relajación, es pragmatismo: bloquear los despliegues por una CVE que nadie ha corregido todavía no mejora tu seguridad, solo impide que publiques la corrección de otro fallo. Cuál es el umbral aceptable para tu organización lo decide su política de seguridad, no este curso.

  1. Firma keyless, SBOM y procedencia

      - uses: sigstore/cosign-installer@v3

      - name: Firmar la imagen (keyless, sin gestionar claves)
        run: |
          cosign sign --yes \
            ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
        env:
          COSIGN_EXPERIMENTAL: "1"

La firma keyless de 05-03 encaja aquí como un guante. No hay clave privada que guardar, rotar ni filtrar: Cosign pide un token OIDC de corta vida a GitHub, obtiene un certificado efímero de Fulcio y registra la firma en el log público de transparencia Rekor. El certificado caduca en minutos; lo que queda es la prueba, verificable por cualquiera:

cosign verify ghcr.io/auroralibros/aurora-api:2.0.0 \
  --certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Fíjate en lo que verifica esa identidad: no dice "está firmada", dice "la construyó ese workflow, de ese repositorio". Una imagen firmada desde el portátil de alguien no pasa esa comprobación. Junto con el provenance: mode=max y el sbom: true del paso anterior, tienes las tres piezas de la cadena de suministro: qué lleva dentro (SBOM), quién y cómo la construyó (procedencia) y que nadie la ha tocado desde entonces (firma).

  1. El pipeline equivalente en GitLab CI

# .gitlab-ci.yml
stages: [pruebas, build, deploy]

variables:
  IMAGEN: $CI_REGISTRY_IMAGE/aurora-api

.docker: &docker                       # ancla YAML reutilizada por los dos jobs
  image: docker:27-cli
  services: ["docker:27-dind"]         # Docker-in-Docker como servicio del job
  variables: { DOCKER_TLS_CERTDIR: "/certs" }

pruebas:
  <<: *docker
  stage: pruebas
  script:
    - docker compose -f compose.pruebas.yaml up --abort-on-container-exit --exit-code-from pruebas

build:
  <<: *docker
  stage: build
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use --driver docker-container
  script:
    - |
      docker buildx build --platform linux/amd64,linux/arm64 \
        --cache-from type=registry,ref=$IMAGEN:cache \
        --cache-to   type=registry,ref=$IMAGEN:cache,mode=max \
        --tag $IMAGEN:$CI_COMMIT_REF_SLUG --tag $IMAGEN:sha-$CI_COMMIT_SHORT_SHA --push ./api
  rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }, { if: $CI_COMMIT_TAG }]
Concepto GitHub Actions GitLab CI
Unidad ejecutable job con steps job con script
Agrupación / orden needs: entre jobs stages: secuenciales
Fichero .github/workflows/*.yaml .gitlab-ci.yml
Reutilización uses: (acciones del marketplace) include:, extends:, anclas YAML
Contenedor del job container: (opcional) image: (habitual)
Caché de build type=gha type=registry
Registro integrado ghcr.io $CI_REGISTRY_IMAGE
Condiciones if: / on: rules: / only:
Secretos Repository secrets CI/CD variables (protegidas/enmascaradas)
Identidad sin contraseña OIDC nativo OIDC (id_tokens:)

La caché es la diferencia práctica más visible: GitHub tiene un backend propio, mientras que en GitLab lo habitual es guardar la caché en el propio registro con type=registry, que además funciona en cualquier proveedor.

  1. Docker-in-Docker frente al socket montado

Para construir imágenes, el runner necesita acceso a un daemon. Hay tres formas, y no son equivalentes en seguridad.

Opción Cómo Riesgo Rendimiento
DinD (docker:dind) Daemon anidado dentro del job Requiere --privileged: escape al host si algo falla Caché fría cada vez
Socket montado -v /var/run/docker.sock:... Grupo docker = root en el host (05-03) Caché caliente compartida
BuildKit sin daemon buildkitd rootless o Kaniko El menor: sin privilegios Buena, con caché remota

Las dos primeras filas dicen lo mismo con palabras distintas: cualquier job puede tomar el control del runner. Con DinD, porque el contenedor privilegiado tiene todas las capacidades. Con el socket montado, porque quien habla con el daemon puede lanzar docker run -v /:/host --privileged y leer o modificar el disco entero del anfitrión, incluidas las credenciales de los demás pipelines.

En un repositorio privado con colaboradores de confianza, cualquiera de las dos es la práctica común. En cuanto aceptes pull requests de terceros, el socket montado es inaceptable: un PR malicioso que modifique el workflow se lleva tus secretos. Las alternativas son runners efímeros de un solo uso (lo que hacen los runners alojados de GitHub) o construir sin daemon con BuildKit rootless.

  1. Gestión de credenciales: OIDC y tokens efímeros

Práctica Por qué En Aurora Libros
Nunca en el repositorio El historial de Git es para siempre Todo en secretos del proveedor
Token efímero, no contraseña Caduca solo; robarlo sirve de poco GITHUB_TOKEN por ejecución
Alcance mínimo Un token de publicación no despliega permissions: por job
OIDC en vez de claves No hay secreto que rotar ni filtrar id-token: write para Cosign
Rotación y auditoría Detectar el uso indebido Registro de accesos del registro

El GITHUB_TOKEN no es un secreto que hayas creado: GitHub lo genera al empezar la ejecución, con exactamente los permisos del bloque permissions:, y lo invalida al terminar. Robado media hora después, no vale nada.

La regla que nunca se rompe es la de no imprimir un secreto. Los proveedores enmascaran los valores conocidos y muestran ***, pero el enmascarado se salta con facilidad: echo $CLAVE | base64 sale en claro, y un set -x en un script bash imprime cada comando con sus argumentos. Por eso las credenciales se pasan siempre por stdin (--password-stdin) y nunca como argumento de línea de comandos, que además queda visible en la lista de procesos del runner.

  1. Versionado automático desde las etiquetas de Git

git tag -a v2.0.0 -m "Sondas separadas, apagado ordenado y validación de configuración"
git push origin v2.0.0     # esto es, en la práctica, el botón de publicar

metadata-action traduce esa etiqueta a las cuatro de la imagen sin intervención:

Etiqueta de Git Etiquetas de la imagen Se mueve
v2.0.0 2.0.0 Nunca
v2.0.0 2.0 Con cada parche
v2.0.0 2 Con cada versión menor
v2.0.0 latest Con cada versión
(cualquier build) sha-a1b2c3d Nunca

Las dos filas que nunca se mueven son las que sirven para producción; las móviles son cómodas para desarrollo y peligrosas en despliegues. El compose.prod.yaml de Aurora Libros fija el digest, con lo que ni siquiera depende de que 2.0.0 siga apuntando a lo mismo.

  1. El despliegue automatizado

  desplegar-staging:
    needs: publicar
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    environment: staging          # permite exigir aprobación y restringir secretos
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: despliegue
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            set -euo pipefail
            cd /opt/aurora-libros
            export AURORA_API_DIGEST="${{ needs.publicar.outputs.digest }}"
            docker compose -f compose.prod.yaml pull
            docker compose -f compose.prod.yaml up -d --wait --wait-timeout 120
            docker compose -f compose.prod.yaml ps --format '{{.Name}} {{.Status}}'

Cuatro detalles que hacen que esto sea un despliegue y no una ruleta. El --wait (que ya usabas en el onboarding) espera a que los healthcheck pasen y devuelve error si no lo hacen, así que un despliegue roto pone el job en rojo en vez de dejarte un servicio caído en silencio. El set -euo pipefail corta a la primera línea que falle. El usuario despliegue no es root y solo puede tocar ese directorio. Y se despliega por digest, no por etiqueta: el up -d arranca exactamente la imagen que acaba de pasar las pruebas.

Aun así, esto es un despliegue de una sola máquina: durante unos segundos el servicio se recrea y no hay nadie sirviendo. A partir de 06-03 el paso final deja de ser un docker compose up remoto y pasa a ser una instrucción al orquestador, que sustituye las réplicas de una en una sin cortar el servicio.

Errores Comunes y Consejos

  • Olvidar --exit-code-from. El pipeline se pone verde con las pruebas en rojo, que es peor que no tener pruebas: da confianza falsa. Compruébalo a propósito rompiendo una prueba.
  • Reconstruir la imagen en el job de despliegue. Rompe la trazabilidad y despliega algo que nadie ha probado. Se promociona por digest.
  • Construir sin caché remota. Un pipeline de cuatro minutos por cambio hace que la gente deje de hacer push pequeños, y eso empeora todo lo demás.
  • Escanear por etiqueta en vez de por digest. Analizas una imagen que puede no ser la tuya. Usa siempre imagen@sha256:....
  • exit-code: 1 con todas las severidades. El pipeline se rompe cada mañana por una CVE baja sin parche, y la gente aprende a saltarse la puerta. Sé estricto donde importa.
  • Publicar desde pull requests. Cualquiera que abra un PR puede subir una imagen a tu registro: condiciona push: al evento. Y nunca pases secretos como argumentos, que aparecen en ps dentro del runner y en muchos logs; siempre --password-stdin.
  • Consejo: haz el pipeline reproducible en local. Si docker compose -f compose.pruebas.yaml up es lo mismo que ejecuta CI, depurar un fallo del pipeline no requiere veinte commits de prueba.
  • Consejo: usa concurrency con cancel-in-progress. Diez pushes seguidos no deben lanzar diez builds multiarquitectura; solo importa el último.

Ejercicios

Ejercicio 1. Demuestra el peligro de --exit-code-from: rompe deliberadamente una prueba de aurora-api y ejecuta la pila de pruebas con y sin esa bandera, comparando los códigos de salida. Explica qué habría hecho el pipeline en cada caso.

Ejercicio 2. Mide el efecto de la caché remota: ejecuta el pipeline tres veces (frío, con caché y con caché tras un cambio solo en src/) y construye la tabla de tiempos. Explica por qué el tercer caso es el más rápido.

Ejercicio 3. Verifica la cadena de suministro completa de una imagen publicada: comprueba la firma exigiendo que provenga de tu workflow, extrae el SBOM y localiza el commit exacto que la generó.

Soluciones

Solución 1.

# Se rompe una aserción a propósito en api/test/libros.test.js
docker compose -f compose.pruebas.yaml up --build --abort-on-container-exit
echo "sin --exit-code-from: $?"
docker compose -f compose.pruebas.yaml up --build --abort-on-container-exit --exit-code-from pruebas
echo "con --exit-code-from: $?"
pruebas-1  | FAIL  test/libros.test.js > devuelve los 9 titulos del catalogo
pruebas-1  | AssertionError: expected 9 to equal 8
pruebas-1 exited with code 1
sin --exit-code-from: 0
con --exit-code-from: 1

Los dos códigos de salida, ante exactamente la misma prueba fallida, resumen el ejercicio:

Ejecución Código Qué haría CI
Sin --exit-code-from 0 Continúa: construye, firma y publica el código roto
Con --exit-code-from pruebas 1 Se detiene en el job de pruebas; nada se publica

Lo perverso del primer caso es que el fallo sí aparece en el log, con su AssertionError y todo, pero nadie lo lee: la marca verde dice que todo está bien. Sin la bandera, docker compose up informa de si él pudo levantar la pila —y pudo—, no de si el contenedor de pruebas terminó satisfecho.

De ahí una práctica que merece la pena adoptar: la primera vez que montas un pipeline, rómpelo a propósito. Un pipeline que nunca has visto fallar no es un pipeline verde, es un pipeline sin comprobar.

Solución 2.

gh run list --workflow ci.yaml --limit 3 \
  --json displayTitle,conclusion,createdAt,updatedAt \
  --jq '.[] | "\(.displayTitle): \((.updatedAt|fromdate) - (.createdAt|fromdate))s"'
fix: mensaje de error mas claro (solo src/):  41s
chore: subir version de pino (package.json): 108s
ci: activar cache gha (primera ejecucion):   275s
Ejecución Qué cambió Tiempo del job de build Capas reutilizadas
1ª (fría) Todo 4 min 35 s 0
package.json 1 min 48 s Base y sistema
Solo src/ 41 s Base, sistema y npm ci

El tercer caso es el más rápido por la misma razón que estudiaste en 02-02, ahora aplicada a una máquina distinta cada vez. El Dockerfile de 06-01 copia primero package.json y package-lock.json, ejecuta npm ci, y solo después copia src/. Si únicamente cambia el código fuente, la capa de npm ci sigue siendo válida y BuildKit la trae desde la caché de GitHub en lugar de reinstalar 180 paquetes.

Lo que hace posible que un runner nuevo aproveche el trabajo del anterior es cache-from: type=gha: el runner es efímero, pero la caché no. Sin ella, las tres columnas de la tabla darían 4 min 35 s.

Un matiz importante para no engañarte con el número: los 41 segundos son de dos arquitecturas. La construcción arm64 corre bajo emulación QEMU y es unas tres veces más lenta que la nativa; sin caché, ese solo hecho añadiría más de dos minutos.

Solución 3.

IMG=ghcr.io/auroralibros/aurora-api
DIG=$(docker buildx imagetools inspect $IMG:2.0.0 --format '{{.Manifest.Digest}}')

cosign verify $IMG@$DIG \
  --certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com | jq '.[0].optional'
{ "Issuer": "https://token.actions.githubusercontent.com",
  "Subject": "https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@refs/tags/v2.0.0",
  "githubWorkflowSha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
  "Bundle": { "Payload": { "logIndex": 148392017 } } }
cosign download sbom $IMG@$DIG 2>/dev/null | jq -r '.packages[] | "\(.name) \(.versionInfo)"' | head -3
docker buildx imagetools inspect $IMG@$DIG \
  --format '{{index .Image.Config.Labels "org.opencontainers.image.revision"}}'
# express 4.21.2
# pg 8.13.1
# ioredis 5.4.1
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Las comprobaciones responden a preguntas distintas, y juntas cierran el círculo:

Pregunta Mecanismo Evidencia
¿La ha tocado alguien? Firma Cosign Verificación correcta contra Rekor
¿Quién la construyó? Identidad del certificado El workflow ci.yaml en refs/tags/v2.0.0
¿Qué lleva dentro? SBOM 182 paquetes con nombre y versión
¿De qué código sale? Etiqueta OCI revision El commit a1b2c3d…

El dato decisivo es el Subject, y conviene detenerse en él: no dice "alguien firmó esta imagen", dice qué workflow, de qué repositorio y desde qué referencia de Git la construyó. Si un atacante consiguiera publicar una imagen en tu registro con la etiqueta 2.0.0, la firma no verificaría contra esa identidad y el despliegue debería rechazarla. Esa comprobación es la que se automatiza en el clúster con una política de admisión.

Y el par SBOM + revision es lo que convierte una alerta de seguridad en trabajo de diez minutos: cuando se publique la próxima CVE crítica de una librería, cosign download sbom te dice en segundos si tu imagen la incluye y en qué versión, y la etiqueta revision te lleva al commit exacto sobre el que aplicar la corrección.

Conclusión

El camino del commit a la imagen publicada ya no lo recorres tú. Distingues la integración continua de la entrega y del despliegue continuos —tres conceptos y dos siglas— y sabes por qué Aurora Libros hace deployment a staging y delivery a producción. Has interiorizado la regla que sostiene todo el módulo: construir una vez y desplegar muchas, porque el artefacto es la imagen y su digest es el mismo objeto en todos los entornos; un docker build por entorno es un fallo de diseño, no una comodidad.

Las pruebas corren contra un PostgreSQL y un Redis reales en una pila efímera con los datos en tmpfs, y sabes que --abort-on-container-exit sin --exit-code-from produce el peor resultado posible: un pipeline verde sobre pruebas rojas, que has provocado a propósito para verlo. El workflow completo de GitHub Actions encadena lint y pruebas, metadata-action traduciendo v2.0.0 a cuatro etiquetas más el sha- irrepetible, build-push-action construyendo para amd64 y arm64 con cache-to: type=gha,mode=max —que llevó el build de 4 min 35 s a 41 s cuando solo cambia src/—, Trivy informando de todo y rompiendo solo ante críticas con parche disponible, y la firma keyless de Cosign que no obliga a custodiar ninguna clave privada. Has verificado la cadena entera desde fuera: firma válida, Subject que nombra el workflow y la etiqueta de Git que la produjo, SBOM con las versiones de cada paquete y la etiqueta OCI revision con el commit exacto.

Conoces también el equivalente en GitLab CI con su tabla de correspondencias, el compromiso real entre Docker-in-Docker y el socket montado —donde ambas opciones significan, dicho sin rodeos, que un job puede tomar el control del runner—, y las reglas de credenciales: tokens efímeros con permisos por job, OIDC en lugar de contraseñas estáticas y nada de secretos por línea de comandos. El paso final, por ahora, es un docker compose pull && up -d --wait remoto por SSH desplegando por digest.

Y ahí está el límite. Ese despliegue tiene un hueco de segundos en el que nadie sirve, corre en una sola máquina y, si esa máquina se apaga, Aurora Libros desaparece de internet. En la siguiente lección, Orquestando Contenedores con Docker Swarm, montarás tu primer clúster: varios nodos con managers y workers, redes overlay que conectan contenedores de máquinas distintas, servicios que se reprograman solos cuando un nodo cae, y el compose.yaml que ya conoces desplegado como stack con docker stack deploy.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados