Hasta ahora el pipeline produce un directorio dist/ que se guarda siete días y no va a ninguna parte. En esta lección cierras el circuito: empaquetarás Mini-Reservalia en una imagen de contenedor inmutable, la publicarás en un registro real, la desplegarás en dos entornos con una puerta de aprobación humana entre ellos, comprobarás automáticamente que el despliegue funciona, promocionarás a producción exactamente el mismo artefacto que validaste en staging —sin reconstruirlo— y escribirás el botón de emergencia que te devuelve a la versión anterior en menos de dos minutos. Y, como manda el módulo, romperás algo a propósito: desplegarás una versión que falla su propio health check para ver la puerta cerrarse y el rollback funcionar.

Todo esto sin AWS y sin tarjeta de crédito. El registro es ghcr.io, incluido gratis en tu cuenta de GitHub; el frontend va a GitHub Pages; y el "host de producción" es un contenedor Docker que corre en el propio runner o en tu máquina. Donde el Reservalia real usaría ECS, ECR y OIDC contra AWS, encontrarás una nota con el equivalente exacto. Los conceptos —artefacto inmutable, digest, idempotencia, promoción, smoke test, rollback— son idénticos; solo cambia dónde aterriza el contenedor.

Contenido

  1. Objetivo, requisitos previos y punto de partida
  2. El artefacto inmutable: Dockerfile multi-etapa
  3. Construir y publicar en ghcr.io desde el pipeline
  4. Por qué el digest y no el tag
  5. Separar CI de CD con workflow_run
  6. Environments: staging automático y produccion con revisor
  7. El script de despliegue idempotente
  8. Las tres formas de ejecutarlo (con host, sin host, y en el runner)
  9. El smoke test con reintentos
  10. El cd.yml completo con promoción por digest
  11. El frontend a GitHub Pages
  12. El rollback.yml y el cronómetro
  13. Provocar un despliegue malo
  14. Verificación final
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. Objetivo, requisitos previos y punto de partida

Objetivo. Al terminar, un merge a main construirá una imagen, la publicará en ghcr.io, la desplegará automáticamente en staging, la verificará con un smoke test, esperará tu aprobación y promocionará el mismo digest a produccion; y tendrás un rollback.yml que revierte en menos de dos minutos, cronometrado.

Requisitos previos.

  • Las lecciones 07-01 y 07-02 completadas.
  • Docker instalado en tu máquina (docker --version, 24 o superior) y docker compose version. Ahora sí es obligatorio.
  • El repositorio en GitHub con main protegida y el check CI OK.

Punto de partida. El repositorio tras la 07-02: código con persistencia, tres capas de pruebas, cobertura con umbral, matriz y sharding.

git checkout main && git pull
git checkout -b despliegue

  1. El artefacto inmutable: Dockerfile multi-etapa

La regla de la 02-06: build once, deploy many. Se construye un artefacto una sola vez y ese mismo artefacto —byte a byte— recorre todos los entornos. Si cada entorno reconstruye, cada entorno ejecuta algo distinto y la validación de staging no dice nada sobre producción.

Dockerfile:

# syntax=docker/dockerfile:1.7

# ---------- Etapa 1: dependencias de produccion ----------
# Se aisla para que la capa se cachee mientras el lockfile no cambie.
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# build-essential y python3 son necesarios SOLO si algun modulo nativo
# (better-sqlite3) no encuentra binario precompilado. Se quedan en esta
# etapa y nunca llegan a la imagen final.
RUN apt-get update && apt-get install -y --no-install-recommends \
      python3 make g++ \
    && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# --omit=dev: nada de eslint ni herramientas de prueba en produccion.
RUN npm ci --omit=dev

# ---------- Etapa 2: construccion ----------
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG COMMIT=desconocido
ENV GITHUB_SHA=${COMMIT}
RUN npm run build

# ---------- Etapa 3: imagen final ----------
FROM node:20-bookworm-slim AS runtime
WORKDIR /app

# Usuario sin privilegios. La imagen oficial de Node ya trae el usuario
# `node` (uid 1000); no hace falta crearlo.
ENV NODE_ENV=production \
    PORT=3000 \
    BASE_DATOS=sqlite:/datos/mini.db

# Directorio de datos con permisos para el usuario no-root.
RUN mkdir -p /datos && chown -R node:node /datos

COPY --from=deps  --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./

USER node
EXPOSE 3000
VOLUME ["/datos"]

# Metadatos OCI: quien construyo esto, desde que commit y cuando.
# Se leen con `docker inspect` y son la trazabilidad minima de un artefacto.
ARG COMMIT=desconocido
ARG FECHA=desconocida
LABEL org.opencontainers.image.source="https://github.com/OWNER/mini-reservalia" \
      org.opencontainers.image.revision="${COMMIT}" \
      org.opencontainers.image.created="${FECHA}" \
      org.opencontainers.image.title="mini-reservalia" \
      org.opencontainers.image.description="Calculo de huecos de cita"

# HEALTHCHECK: Docker sondea el contenedor y marca su estado.
# `docker inspect --format '{{.State.Health.Status}}'` devuelve
# starting -> healthy | unhealthy. El script de despliegue lo usara.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

ENV APP_VERSION=${COMMIT}
CMD ["node", "dist/src/servidor.js"]

.dockerignore —tan importante como el Dockerfile, porque determina qué se envía al demonio y qué invalida la caché—:

node_modules
dist
coverage
informes
.git
.github
.gitignore
*.md
*.log
.env
Dockerfile
docker-compose*.yml
test

Excluir test/ y .git no es solo velocidad: es superficie. Una imagen de producción que lleva dentro el historial de Git y las pruebas es una imagen que filtra información y ocupa el triple.

Construye y prueba en local:

docker build \
  --build-arg COMMIT="$(git rev-parse HEAD)" \
  --build-arg FECHA="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t mini-reservalia:local .

docker run --rm -d --name mini-local -p 3000:3000 mini-reservalia:local
sleep 3
curl -s localhost:3000/salud
# {"estado":"ok","version":"8f3c1e2...","activoSeg":3}

# El estado del HEALTHCHECK, que es lo que mirara el despliegue:
docker inspect --format '{{.State.Health.Status}}' mini-local
# healthy    (puede tardar hasta 15 s en pasar de "starting")

# Comprobaciones de endurecimiento basico:
docker exec mini-local whoami       # node   (NO root)
docker image inspect mini-reservalia:local --format '{{.Size}}' | numfmt --to=iec
# ~230M

docker rm -f mini-local

Qué debes ver: estado: ok, Health.Status: healthy y whoami: node. Si whoami devuelve root, la línea USER node no está o está antes de un COPY que la anula.

  1. Construir y publicar en ghcr.io desde el pipeline

ghcr.io es el registro de contenedores de GitHub. Para un repositorio público es gratuito e ilimitado, y lo mejor: no necesitas ninguna credencial nueva. El GITHUB_TOKEN que el runner ya tiene sirve, siempre que le des el permiso packages: write.

Añade al ci.yml un job publicar que dependa de ci-ok:

  publicar:
    name: Publicar imagen
    runs-on: ubuntu-latest
    needs: [ci-ok]
    # Solo se publica desde main: un PR no debe dejar imagenes en el registro.
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    permissions:
      contents: read
      packages: write      # necesario para escribir en ghcr.io
    outputs:
      digest: ${{ steps.construir.outputs.digest }}
      imagen: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Preparar Buildx
        uses: docker/setup-buildx-action@v3

      - name: Autenticarse en ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # token efimero del run

      - name: Metadatos y etiquetas
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long,prefix=sha-
            type=raw,value=main,enable={{is_default_branch}}

      - name: Construir y publicar
        id: construir
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            COMMIT=${{ github.sha }}
            FECHA=${{ github.event.repository.updated_at }}
          # Cache de capas en el propio backend de Actions: reduce mucho
          # el tiempo de las construcciones siguientes (04-04 y 06-05).
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true    # attestacion de procedencia (SLSA)

      - name: Publicar el digest en el resumen
        run: |
          {
            echo "## Imagen publicada"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Repositorio | \`ghcr.io/${{ github.repository }}\` |"
            echo "| Digest | \`${{ steps.construir.outputs.digest }}\` |"
            echo "| Commit | \`${{ github.sha }}\` |"
            echo ""
            echo "Referencia inmutable para desplegar:"
            echo '```'
            echo "ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

Mergea el PR y observa. Qué debes ver:

  1. En el resumen del run, la tabla con el digest: sha256:3f9a....
  2. En la portada de tu perfil o del repositorio, la sección Packages con mini-reservalia.
  3. La imagen es privada por defecto aunque el repositorio sea público. Ve a Package settings → Change visibility → Public si quieres poder descargarla sin autenticar (útil para el laboratorio). Alternativamente, en Package settings → Manage Actions access, añade el repositorio con rol Write.

Descárgala desde tu máquina para comprobar que existe de verdad:

echo "$GH_TOKEN" | docker login ghcr.io -u TU_USUARIO --password-stdin
docker pull ghcr.io/TU_USUARIO/mini-reservalia@sha256:3f9a...
docker run --rm -p 3000:3000 ghcr.io/TU_USUARIO/mini-reservalia@sha256:3f9a...

Equivalente real en Reservalia. El registro es ECR y la autenticación no usa una contraseña sino OIDC: el workflow pide un token efímero a AWS con aws-actions/configure-aws-credentials@v4 y role-to-assume: arn:aws:iam::...:role/reservalia-ci, sin ninguna clave almacenada en GitHub (03-02 y 04-03). Aquí ghcr.io con GITHUB_TOKEN cumple exactamente el mismo principio —credencial efímera, con alcance mínimo, que caduca al terminar el run—, y por eso el ejercicio no pierde nada pedagógicamente. La 07-05 volverá sobre esto.

  1. Por qué el digest y no el tag

Este apartado es corto y es el más importante de la lección.

Un tag es un puntero mutable. ghcr.io/tu/mini-reservalia:main apunta hoy a una imagen y mañana a otra. Un digest (sha256:...) es el hash del contenido del manifiesto: una referencia por digest siempre resuelve a los mismos bytes, para siempre.

Tag Digest
:main, :latest, :v1.2 Puntero mutable
@sha256:3f9a... Contenido inmutable
¿Puede cambiar bajo tus pies? No
¿Sirve para promocionar? No
¿Sirve para volver atrás? Solo si nadie lo movió Siempre
¿Sirve para hablar con humanos? Regular

El escenario que arruina el día de alguien todos los meses: despliegas :main en staging, lo pruebas, apruebas, y cuando el job de producción hace docker pull, otro merge ya ha movido :main. Producción ejecuta código que nadie validó. No es un fallo teórico; es la razón por la que este pipeline pasa el digest de un job a otro y por la que el job de producción comprueba que el digest que va a desplegar es el mismo que se validó.

Compruébalo tú mismo:

docker buildx imagetools inspect ghcr.io/TU_USUARIO/mini-reservalia:main --format '{{.Manifest.Digest}}'
# sha256:3f9a...   <- ahora
# haz otro merge a main y repite: el digest ha cambiado, el tag no

Regla: los tags son para las personas, los digests para las máquinas. Publica ambos; despliega siempre por digest.

  1. Separar CI de CD con workflow_run

CI y CD son dos ciclos con velocidades y permisos distintos. CI se ejecuta en cada PR, no necesita credenciales de despliegue y debe ser rápido. CD se ejecuta solo tras un merge a main, necesita permisos elevados y puede tardar minutos esperando una aprobación. Meterlos en el mismo workflow obliga a dar a cada PR los permisos del despliegue: exactamente lo contrario del mínimo privilegio.

flowchart LR
    P["push a main"] --> CI["ci.yml<br/>calidad, test, cobertura,<br/>build, publicar imagen"]
    CI -->|workflow_run: completed + success| CD["cd.yml"]
    CD --> S["desplegar staging<br/>(automatico)"]
    S --> SM1["smoke test"]
    SM1 --> G{"Environment<br/>produccion<br/>revisor requerido"}
    G -->|aprobado| PR2["desplegar produccion<br/>MISMO digest"]
    PR2 --> SM2["smoke test"]
    SM2 -->|falla| RB["rollback.yml"]

El disparador:

on:
  workflow_run:
    workflows: ['CI']        # el `name:` del otro workflow, no su fichero
    types: [completed]
    branches: [main]
  workflow_dispatch:          # para poder relanzar un despliegue a mano
    inputs:
      digest:
        description: 'Digest a desplegar (sha256:...). Vacio = ultimo de main'
        required: false

Y la guarda imprescindible en el primer job:

    # `completed` incluye failure y cancelled. Sin esta condicion,
    # desplegarias el resultado de un CI en rojo.
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event.workflow_run.conclusion == 'success'

Dos peculiaridades de workflow_run que confunden a todo el mundo la primera vez:

  1. El workflow debe existir en main para que se dispare. Mientras el cd.yml esté solo en tu rama, no se ejecutará nunca por mucho que el CI pase. Hay que mergearlo primero y probarlo después. Es contraintuitivo y cuesta media tarde a quien no lo sabe.
  2. El contexto es el del workflow disparador, no el del commit. github.sha en un workflow_run es el SHA de la rama por defecto en el momento del disparo. Si necesitas el commit exacto que se construyó, léelo de github.event.workflow_run.head_sha.

  1. Environments: staging automático y produccion con revisor

Los Environments de GitHub son la materialización de la puerta entre Delivery y Deployment de la 03-01: el artefacto está listo, pero alguien decide cuándo entra.

Créalos en Settings → Environments:

staging

  • Sin reglas de protección.
  • Environment URL: la dirección de tu staging (o http://localhost:3001).
  • Variable de entorno (pestaña Variables): PUERTO_APP = 3001.

produccion

  • Required reviewers: añádete a ti mismo. Este es el punto del ejercicio.
  • Wait timer: 0 (o 1 minuto si quieres ver el temporizador).
  • Deployment branches: Selected branchesmain. Impide desplegar producción desde una rama cualquiera.
  • Variable: PUERTO_APP = 3002.

Con gh:

gh api --method PUT "repos/{owner}/{repo}/environments/staging"

gh api --method PUT "repos/{owner}/{repo}/environments/produccion" \
  -F "wait_timer=0" \
  -F "reviewers[][type]=User" \
  -F "reviewers[][id]=$(gh api user --jq .id)" \
  -F "deployment_branch_policy[protected_branches]=true" \
  -F "deployment_branch_policy[custom_branch_policies]=false"

Un job se asocia a un entorno con dos líneas:

    environment:
      name: produccion
      url: ${{ steps.desplegar.outputs.url }}

Lo que hace GitHub con eso: cuando la ejecución llega a ese job, se detiene, marca el run como Waiting, envía una notificación a los revisores y espera. Ningún paso del job se ejecuta —ni siquiera el checkout— hasta que alguien aprueba. Los secretos y variables del entorno solo se materializan después de la aprobación, lo que significa que un job no aprobado no puede tocar las credenciales de producción ni por accidente ni a propósito. Ese es el valor de seguridad, además del de proceso.

  1. El script de despliegue idempotente

Aquí aplicamos la regla de la 06-07: la lógica en scripts, el YAML delgado. El script debe poder ejecutarse desde tu portátil, desde el runner o desde un servidor por SSH, sin cambios. Si solo funciona dentro de GitHub Actions, no lo puedes depurar y no lo puedes usar en una emergencia.

scripts/desplegar.sh:

#!/usr/bin/env bash
# Despliega Mini-Reservalia como contenedor Docker.
#
# IDEMPOTENTE: ejecutarlo N veces con la misma imagen deja el sistema en el
# mismo estado que ejecutarlo una vez. Esa propiedad es lo que permite
# reintentar un despliegue fallido sin miedo (03-02).
#
# Uso:
#   ENTORNO=staging PUERTO=3001 IMAGEN=ghcr.io/x/y@sha256:... ./scripts/desplegar.sh
#
# Variables:
#   IMAGEN   (obligatoria) referencia COMPLETA por digest
#   ENTORNO  (por defecto: staging) sufijo del nombre del contenedor
#   PUERTO   (por defecto: 3001) puerto del host
#   RETENER  (por defecto: 3) cuantas imagenes antiguas conservar

set -Eeuo pipefail   # -E: las trampas heredan; -e: aborta al fallar;
                     # -u: variable no definida es error; -o pipefail: falla la tuberia entera

IMAGEN="${IMAGEN:?Falta IMAGEN (referencia por digest)}"
ENTORNO="${ENTORNO:-staging}"
PUERTO="${PUERTO:-3001}"
RETENER="${RETENER:-3}"

CONTENEDOR="mini-reservalia-${ENTORNO}"
VOLUMEN="mini-reservalia-datos-${ENTORNO}"

log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }

# --- 0. Validaciones tempranas ------------------------------------------------
if [[ "$IMAGEN" != *"@sha256:"* ]]; then
  echo "ERROR: IMAGEN debe ser una referencia por DIGEST (@sha256:...), no por tag." >&2
  echo "       Recibido: $IMAGEN" >&2
  echo "       Un tag es mutable: no garantiza que despliegues lo que validaste." >&2
  exit 2
fi
command -v docker >/dev/null || { echo "ERROR: docker no esta instalado" >&2; exit 3; }

log "Entorno:     $ENTORNO"
log "Contenedor:  $CONTENEDOR"
log "Puerto:      $PUERTO"
log "Imagen:      $IMAGEN"

# --- 1. Descargar la imagen ANTES de tocar nada -------------------------------
# Si el pull falla, el servicio actual sigue vivo. Nunca pares lo que funciona
# antes de tener lo que lo va a sustituir.
log "Descargando imagen..."
docker pull --quiet "$IMAGEN"

DIGEST_LOCAL="$(docker image inspect "$IMAGEN" --format '{{index .RepoDigests 0}}')"
log "Digest verificado: $DIGEST_LOCAL"

# --- 2. Idempotencia: si ya corre ESTA imagen, no hacer nada ------------------
if docker ps --filter "name=^${CONTENEDOR}$" --format '{{.Names}}' | grep -q .; then
  ACTUAL="$(docker inspect "$CONTENEDOR" --format '{{.Image}}')"
  NUEVA="$(docker image inspect "$IMAGEN" --format '{{.Id}}')"
  if [[ "$ACTUAL" == "$NUEVA" ]]; then
    log "El contenedor ya ejecuta esta imagen. Nada que hacer (idempotencia)."
    docker ps --filter "name=^${CONTENEDOR}$" --format 'table {{.Names}}\t{{.Status}}'
    exit 0
  fi
  log "Version en ejecucion distinta; se sustituye."
fi

# --- 3. Guardar la version anterior para el rollback --------------------------
ANTERIOR=""
if docker inspect "$CONTENEDOR" >/dev/null 2>&1; then
  ANTERIOR="$(docker inspect "$CONTENEDOR" --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null || true)"
fi
if [[ -n "$ANTERIOR" ]]; then
  log "Version anterior (para rollback): $ANTERIOR"
  echo "$ANTERIOR" > "/tmp/mini-reservalia-${ENTORNO}.anterior"
  if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
    echo "digest_anterior=$ANTERIOR" >> "$GITHUB_OUTPUT"
  fi
fi

# --- 4. Volumen de datos (idempotente por definicion) -------------------------
docker volume create "$VOLUMEN" >/dev/null

# --- 5. Parar y borrar el anterior --------------------------------------------
# `|| true` porque en el primer despliegue no existe, y eso no es un error.
log "Deteniendo la version anterior (si existe)..."
docker rm -f "$CONTENEDOR" >/dev/null 2>&1 || true

# --- 6. Arrancar la nueva version ---------------------------------------------
log "Arrancando la nueva version..."
docker run -d \
  --name "$CONTENEDOR" \
  --restart unless-stopped \
  -p "${PUERTO}:3000" \
  -v "${VOLUMEN}:/datos" \
  -e "APP_VERSION=${APP_VERSION:-$IMAGEN}" \
  -e "ENTORNO=${ENTORNO}" \
  --label "mini.digest=${IMAGEN}" \
  --label "mini.entorno=${ENTORNO}" \
  --label "mini.desplegado=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --health-cmd "node -e \"fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
  --health-interval 5s --health-retries 6 --health-start-period 3s \
  "$IMAGEN" >/dev/null

# --- 7. Esperar a que el HEALTHCHECK pase a healthy ---------------------------
log "Esperando a que el contenedor este healthy..."
for i in $(seq 1 24); do
  ESTADO="$(docker inspect "$CONTENEDOR" --format '{{.State.Health.Status}}' 2>/dev/null || echo 'sin-datos')"
  case "$ESTADO" in
    healthy)   log "Healthy tras ${i} sondeos."; break ;;
    unhealthy) log "ERROR: el contenedor esta unhealthy."; docker logs --tail 50 "$CONTENEDOR"; exit 4 ;;
    *)         sleep 5 ;;
  esac
  if [[ "$i" -eq 24 ]]; then
    log "ERROR: no llego a healthy en 120 s (estado: $ESTADO)."
    docker logs --tail 50 "$CONTENEDOR"
    exit 5
  fi
done

# --- 8. Limpieza: conservar las N ultimas imagenes ----------------------------
# Sin esto, el disco del host se llena en unas semanas. Es la causa numero uno
# de "el despliegue fallo y no sabemos por que" en hosts pequenos.
log "Limpiando imagenes antiguas (conservando $RETENER)..."
docker image prune -f --filter "until=168h" >/dev/null 2>&1 || true

log "Despliegue completado: $CONTENEDOR en el puerto $PUERTO"
docker ps --filter "name=^${CONTENEDOR}$" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
chmod +x scripts/desplegar.sh

Las cuatro propiedades que hacen que este script sea usable en producción:

Propiedad Cómo se consigue Qué pasa sin ella
Idempotente Comprueba si ya corre esa imagen y sale (paso 2) Reintentar un despliegue reinicia el servicio sin necesidad
Falla temprano pull antes de parar nada (paso 1) Paras lo que funciona y descubres que la imagen nueva no existe
Verificable Espera a healthy con límite de tiempo (paso 7) El script termina "bien" con un contenedor que ni siquiera arranca
Rastreable Guarda el digest anterior en una label (pasos 3 y 6) No sabes a qué volver en un rollback

Ese último punto merece énfasis: la label mini.digest convierte al propio contenedor en el registro de qué está desplegado. docker inspect mini-reservalia-produccion --format '{{index .Config.Labels "mini.digest"}}' responde a la pregunta más urgente de un incidente: ¿qué demonios estamos ejecutando?

  1. Las tres formas de ejecutarlo (con host, sin host, y en el runner)

El mismo script, tres destinos. Elige el que puedas.

Opción A (principal, sin coste): el destino simulado dentro del runner

El job de despliegue ejecuta el script contra el Docker del propio runner. El "servidor" vive los tres minutos del job y se destruye después. Es artificial —no hay persistencia entre despliegues— pero ejercita el 100 % del camino: pull por digest, arranque, health check, smoke test, rollback. Es la opción por defecto de este laboratorio.

      - name: Desplegar
        run: ./scripts/desplegar.sh
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
          ENTORNO: staging
          PUERTO: '3001'

Opción B: tu máquina, con un runner autoalojado

Si quieres persistencia real entre despliegues y ver el contenedor vivo en tu portátil, registra un runner autoalojado:

mkdir ~/runner && cd ~/runner
# Copia los comandos exactos de Settings > Actions > Runners > New self-hosted runner
./config.sh --url https://github.com/TU_USUARIO/mini-reservalia --token XXXX --labels casa
./run.sh

Y cambia runs-on: ubuntu-latest por runs-on: [self-hosted, casa]. Ahora http://localhost:3001 y http://localhost:3002 son tus dos entornos de verdad, sobreviven a los despliegues y puedes verlos con docker ps.

Aviso de seguridad, no opcional. No pongas un runner autoalojado en un repositorio público: cualquiera que abra un PR podría ejecutar código arbitrario en tu máquina. La 06-06 lo explicaba y la 07-05 volverá sobre ello. Si vas por la opción B, haz el repositorio privado o usa un contenedor desechable como runner.

Opción C: un host real por SSH

Si tienes una máquina accesible (una VM antigua, una Raspberry Pi, un VPS de un euro), este es el camino más parecido a producción:

      - name: Desplegar por SSH
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
        run: |
          install -m 600 /dev/null clave
          echo "${{ secrets.SSH_CLAVE_PRIVADA }}" > clave
          # StrictHostKeyChecking=accept-new: confia la primera vez y despues
          # detecta cambios de host. NUNCA uses `no`: desactiva la proteccion
          # contra suplantacion para siempre.
          scp -i clave -o StrictHostKeyChecking=accept-new \
            scripts/desplegar.sh "${{ secrets.SSH_USUARIO }}@${{ secrets.SSH_HOST }}:/tmp/"
          ssh -i clave -o StrictHostKeyChecking=accept-new \
            "${{ secrets.SSH_USUARIO }}@${{ secrets.SSH_HOST }}" \
            "IMAGEN='$IMAGEN' ENTORNO=produccion PUERTO=3002 bash /tmp/desplegar.sh"
          shred -u clave

Con docker-compose.yml en el host para tener también el reverse proxy:

# docker-compose.yml - el "host de produccion" simulado en tu maquina
services:
  api-staging:
    image: ${IMAGEN_STAGING:-ghcr.io/OWNER/mini-reservalia:main}
    container_name: mini-reservalia-staging
    restart: unless-stopped
    ports: ['3001:3000']
    environment:
      ENTORNO: staging
      BASE_DATOS: sqlite:/datos/mini.db
    volumes: ['datos-staging:/datos']
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3

  api-produccion:
    image: ${IMAGEN_PRODUCCION:-ghcr.io/OWNER/mini-reservalia:main}
    container_name: mini-reservalia-produccion
    restart: unless-stopped
    ports: ['3002:3000']
    environment:
      ENTORNO: produccion
      BASE_DATOS: sqlite:/datos/mini.db
    volumes: ['datos-produccion:/datos']
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3

volumes:
  datos-staging:
  datos-produccion:

Equivalente real en Reservalia. El despliegue es aws ecs update-service con una nueva definición de tarea que apunta al digest, y ECS hace un rolling update respetando el health check del target group del ALB (03-02 y 03-04). El script desplegar.sh de Reservalia hace lo mismo que el tuyo —comprobar idempotencia, aplicar, esperar estabilización, informar— con aws ecs wait services-stable en lugar del bucle de docker inspect. La forma del script es idéntica; cambia la primitiva.

  1. El smoke test con reintentos

Un despliegue que termina no es un despliegue que funciona. El smoke test es la diferencia.

scripts/smoke.sh:

#!/usr/bin/env bash
# Smoke test posterior al despliegue.
# No prueba funcionalidad exhaustiva (para eso esta la suite): comprueba que
# el sistema DESPLEGADO responde y sirve su funcion principal.
#
# Uso: BASE=http://localhost:3001 ./scripts/smoke.sh [digest_esperado]

set -Eeuo pipefail

BASE="${BASE:?Falta BASE (ej. http://localhost:3001)}"
ESPERADO="${1:-}"
INTENTOS="${INTENTOS:-20}"
ESPERA="${ESPERA:-3}"

log() { printf '[smoke] %s\n' "$*"; }
fallo() { echo "[smoke] FALLO: $*" >&2; exit 1; }

# --- 1. Espera de estabilizacion ---------------------------------------------
# El servicio puede tardar en aceptar conexiones. Reintentar NO es tapar un
# problema: es reconocer que el arranque no es instantaneo. Lo que si seria
# tapar el problema es reintentar SIN limite o ignorar el resultado final.
log "Esperando a que $BASE responda (max $((INTENTOS * ESPERA))s)..."
for i in $(seq 1 "$INTENTOS"); do
  if curl -fsS --max-time 5 "$BASE/salud" >/dev/null 2>&1; then
    log "Responde tras $((i * ESPERA))s aprox."
    break
  fi
  [[ "$i" -eq "$INTENTOS" ]] && fallo "no respondio a /salud en $((INTENTOS * ESPERA))s"
  sleep "$ESPERA"
done

# --- 2. /salud devuelve estado ok --------------------------------------------
SALUD="$(curl -fsS --max-time 5 "$BASE/salud")"
log "/salud -> $SALUD"
echo "$SALUD" | grep -q '"estado":"ok"' || fallo "/salud no devuelve estado ok"

# --- 3. La version desplegada es la esperada ---------------------------------
# Esta comprobacion es la que caza el fallo mas silencioso de todos:
# el despliegue "funciono" pero el trafico sigue yendo a la version antigua.
if [[ -n "$ESPERADO" ]]; then
  VERSION="$(echo "$SALUD" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
  if [[ "$VERSION" != *"$ESPERADO"* ]]; then
    fallo "version desplegada '$VERSION' != esperada '$ESPERADO'"
  fi
  log "Version verificada: $VERSION"
fi

# --- 4. La funcionalidad principal responde ----------------------------------
RESPUESTA="$(curl -fsS --max-time 10 "$BASE/api/huecos?fecha=2026-03-02&duracion=60")"
echo "$RESPUESTA" | grep -q '"huecos"' || fallo "/api/huecos no devuelve huecos: $RESPUESTA"
TOTAL="$(echo "$RESPUESTA" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')"
[[ "${TOTAL:-0}" -gt 0 ]] || fallo "/api/huecos devuelve 0 huecos en un dia que deberia tenerlos"
log "/api/huecos -> $TOTAL huecos"

# --- 5. Los errores siguen siendo errores ------------------------------------
# Una API que responde 200 a todo tambien "pasa" un smoke test ingenuo.
CODIGO="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/huecos")"
[[ "$CODIGO" == "400" ]] || fallo "una peticion sin fecha deberia dar 400, dio $CODIGO"
log "Validacion de errores OK (400 sin fecha)"

# --- 6. Latencia razonable ---------------------------------------------------
MS="$(curl -s -o /dev/null -w '%{time_total}' --max-time 10 "$BASE/api/huecos?fecha=2026-03-02" \
      | awk '{printf "%d", $1 * 1000}')"
log "Latencia de /api/huecos: ${MS} ms"
[[ "$MS" -lt 2000 ]] || fallo "latencia de ${MS} ms, por encima del umbral de 2000 ms"

log "TODAS LAS COMPROBACIONES OK"
chmod +x scripts/smoke.sh

El paso 5 es el que separa un smoke test útil de uno decorativo: comprueba que algo que debe fallar, falla. Un servidor mal configurado que devuelve 200 con una página de error para cualquier ruta pasaría los pasos 1 a 4 sin despeinarse.

  1. El cd.yml completo con promoción por digest

# .github/workflows/cd.yml
name: CD

on:
  workflow_run:
    workflows: ['CI']
    types: [completed]
    branches: [main]
  workflow_dispatch:
    inputs:
      digest:
        description: 'Digest a desplegar (sha256:...). Vacio = ultimo publicado en main'
        required: false
        type: string

# NUNCA cancelar un despliegue a mitad: puede dejar el sistema inconsistente.
# Se encolan, no se cancelan. Diferencia clave respecto al `concurrency` del CI.
concurrency:
  group: cd-mini-reservalia
  cancel-in-progress: false

permissions:
  contents: read

env:
  REGISTRO: ghcr.io
  IMAGEN_BASE: ghcr.io/${{ github.repository }}

jobs:
  # ---------------------------------------------------------------
  # 1. Resolver QUE se va a desplegar. Una sola vez, para todos.
  # ---------------------------------------------------------------
  preparar:
    name: Resolver artefacto
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event.workflow_run.conclusion == 'success'
    permissions:
      contents: read
      packages: read
    outputs:
      digest: ${{ steps.resolver.outputs.digest }}
      imagen: ${{ steps.resolver.outputs.imagen }}
      commit: ${{ steps.resolver.outputs.commit }}
    steps:
      - name: Autenticarse en el registro
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolver el digest a desplegar
        id: resolver
        run: |
          set -Eeuo pipefail
          ENTRADA="${{ inputs.digest }}"
          if [[ -n "$ENTRADA" ]]; then
            DIGEST="$ENTRADA"
            echo "Digest indicado a mano: $DIGEST"
          else
            # Resolvemos el tag movil :main a su digest UNA sola vez.
            # A partir de aqui, todo el pipeline usa el digest fijo.
            DIGEST=$(docker buildx imagetools inspect "${{ env.IMAGEN_BASE }}:main" \
                       --format '{{.Manifest.Digest}}')
            echo "Digest resuelto desde :main -> $DIGEST"
          fi

          IMAGEN="${{ env.IMAGEN_BASE }}@${DIGEST}"
          COMMIT=$(docker buildx imagetools inspect "$IMAGEN" --format \
                    '{{json .Image}}' | grep -o '"org.opencontainers.image.revision":"[^"]*"' \
                    | cut -d'"' -f4 || echo "${{ github.sha }}")

          {
            echo "digest=$DIGEST"
            echo "imagen=$IMAGEN"
            echo "commit=$COMMIT"
          } >> "$GITHUB_OUTPUT"

          {
            echo "## Artefacto a desplegar"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Imagen | \`$IMAGEN\` |"
            echo "| Commit | \`$COMMIT\` |"
            echo "| Origen | ${{ github.event_name }} |"
          } >> "$GITHUB_STEP_SUMMARY"

  # ---------------------------------------------------------------
  # 2. Staging: automatico, sin aprobacion.
  # ---------------------------------------------------------------
  staging:
    name: Desplegar en staging
    runs-on: ubuntu-latest
    needs: [preparar]
    timeout-minutes: 15
    environment:
      name: staging
      url: http://localhost:3001
    permissions:
      contents: read
      packages: read
    outputs:
      digest_validado: ${{ steps.marcar.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Desplegar
        id: desplegar
        run: ./scripts/desplegar.sh
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
          ENTORNO: staging
          PUERTO: ${{ vars.PUERTO_APP || '3001' }}
          APP_VERSION: ${{ needs.preparar.outputs.commit }}

      - name: Smoke test
        run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
        env:
          BASE: http://localhost:${{ vars.PUERTO_APP || '3001' }}

      - name: Marcar el digest como validado
        id: marcar
        run: |
          echo "digest=${{ needs.preparar.outputs.digest }}" >> "$GITHUB_OUTPUT"
          echo "### ✅ Staging validado: \`${{ needs.preparar.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"

      - name: Diagnostico si algo falla
        if: failure()
        run: |
          echo "::group::Contenedores"
          docker ps -a
          echo "::endgroup::"
          echo "::group::Logs"
          docker logs --tail 100 mini-reservalia-staging || true
          echo "::endgroup::"

  # ---------------------------------------------------------------
  # 3. Produccion: MISMO digest, con aprobacion humana.
  # ---------------------------------------------------------------
  produccion:
    name: Desplegar en produccion
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    timeout-minutes: 30
    environment:
      name: produccion          # <- aqui se detiene esperando aprobacion
      url: http://localhost:3002
    permissions:
      contents: read
      packages: read
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # LA COMPROBACION CLAVE DE LA PROMOCION.
      # Producción despliega EXACTAMENTE lo que staging valido. Si por
      # cualquier motivo el digest no coincide, abortamos: mejor no desplegar
      # que desplegar algo distinto de lo validado.
      - name: Verificar que es el MISMO artefacto validado en staging
        run: |
          set -Eeuo pipefail
          VALIDADO="${{ needs.staging.outputs.digest_validado }}"
          A_DESPLEGAR="${{ needs.preparar.outputs.digest }}"
          echo "Validado en staging: $VALIDADO"
          echo "A desplegar en prod: $A_DESPLEGAR"
          if [[ "$VALIDADO" != "$A_DESPLEGAR" ]]; then
            echo "::error::El digest de produccion NO coincide con el validado en staging."
            exit 1
          fi
          echo "✅ Mismo artefacto. No se reconstruye nada." >> "$GITHUB_STEP_SUMMARY"

      - name: Desplegar
        id: desplegar
        run: ./scripts/desplegar.sh
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
          ENTORNO: produccion
          PUERTO: ${{ vars.PUERTO_APP || '3002' }}
          APP_VERSION: ${{ needs.preparar.outputs.commit }}

      - name: Smoke test de produccion
        run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
        env:
          BASE: http://localhost:${{ vars.PUERTO_APP || '3002' }}

      - name: Registrar el despliegue
        run: |
          {
            echo "## 🚀 Desplegado en produccion"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
            echo "| Commit | \`${{ needs.preparar.outputs.commit }}\` |"
            echo "| Aprobado por | @${{ github.actor }} |"
            echo "| Hora (UTC) | $(date -u +%Y-%m-%dT%H:%M:%SZ) |"
            echo ""
            echo "**Digest anterior (para rollback):** \`${{ steps.desplegar.outputs.digest_anterior || 'ninguno' }}\`"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Diagnostico si algo falla
        if: failure()
        run: |
          docker ps -a
          docker logs --tail 100 mini-reservalia-produccion || true
          echo "::error::Despliegue de produccion fallido. Lanza el workflow Rollback con el digest anterior."

Haz el PR, mergéalo y observa la secuencia completa:

  1. El ci.yml corre y publica la imagen.
  2. Unos segundos después arranca el cd.yml solo (fíjate en que el run dice "triggered by CI").
  3. Resolver artefacto imprime el digest.
  4. Desplegar en staging se ejecuta y el smoke test pasa.
  5. El run se detiene. El job Desplegar en produccion aparece con un aviso amarillo: "Deployment protection rules — Review required" y un botón Review deployments.
  6. Pulsa el botón, marca produccion, escribe un comentario y pulsa Approve and deploy.
  7. El job arranca, verifica el digest, despliega y publica el resumen.

Ese momento en el que el pipeline te espera es la puerta de la 03-01 hecha realidad. Vale la pena mirarlo un segundo antes de aprobar.

  1. El frontend a GitHub Pages

Mini-Reservalia también tiene web. Crea web/index.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mini-Reservalia</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
    .hueco { display: inline-block; padding: .4rem .8rem; margin: .2rem; border: 1px solid #888; border-radius: .4rem; }
    #meta { color: #666; font-size: .85rem; margin-top: 2rem; }
  </style>
</head>
<body>
  <h1>Mini-Reservalia</h1>
  <label>Fecha <input type="date" id="fecha" value="2026-03-02"></label>
  <label>Duracion <input type="number" id="duracion" value="60" min="15" step="15"></label>
  <button id="buscar">Buscar huecos</button>
  <div id="resultado"></div>
  <div id="meta"></div>
  <script src="app.js"></script>
</body>
</html>

web/app.js — con la configuración en tiempo de ejecución, no incrustada en el build (05-01):

// web/app.js
// La configuracion se lee de config.json EN TIEMPO DE EJECUCION.
// Asi el MISMO artefacto estatico sirve para staging y para produccion:
// lo unico que cambia es el config.json que escribe el despliegue.
// Si la URL de la API estuviera incrustada en el build, cada entorno
// necesitaria su propio build y se rompe "build once, deploy many".
let CONFIG = { apiBase: 'http://localhost:3001', entorno: 'desconocido', version: 'dev' };

async function cargarConfig() {
  try {
    CONFIG = { ...CONFIG, ...(await (await fetch('config.json', { cache: 'no-store' })).json()) };
  } catch {
    console.warn('config.json no disponible; se usa la configuracion por defecto');
  }
  document.getElementById('meta').textContent =
    `Entorno: ${CONFIG.entorno} · Version: ${CONFIG.version} · API: ${CONFIG.apiBase}`;
}

async function buscar() {
  const fecha = document.getElementById('fecha').value;
  const duracion = document.getElementById('duracion').value;
  const salida = document.getElementById('resultado');
  salida.textContent = 'Buscando...';
  try {
    const respuesta = await fetch(`${CONFIG.apiBase}/api/huecos?fecha=${fecha}&duracion=${duracion}`);
    const datos = await respuesta.json();
    if (!respuesta.ok) throw new Error(datos.error ?? 'error desconocido');
    salida.innerHTML = datos.total === 0
      ? '<p>No quedan huecos ese dia.</p>'
      : `<p>${datos.total} huecos:</p>` +
        datos.huecos.map((h) => `<span class="hueco">${h.inicio}–${h.fin}</span>`).join('');
  } catch (error) {
    salida.textContent = `Error: ${error.message}`;
  }
}

document.getElementById('buscar').addEventListener('click', buscar);
cargarConfig();

Job de despliegue de Pages, en el cd.yml:

  web:
    name: Desplegar web (Pages)
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    permissions:
      contents: read
      pages: write            # publicar en Pages
      id-token: write         # OIDC hacia el servicio de Pages
    environment:
      name: github-pages
      url: ${{ steps.publicar.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4

      - name: Generar config.json del entorno
        run: |
          cat > web/config.json <<JSON
          {
            "apiBase": "${{ vars.API_BASE || 'http://localhost:3002' }}",
            "entorno": "produccion",
            "version": "${{ needs.preparar.outputs.commit }}",
            "digest": "${{ needs.preparar.outputs.digest }}"
          }
          JSON
          cat web/config.json

      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: web/
      - id: publicar
        uses: actions/deploy-pages@v4

Activa Pages en Settings → Pages → Source: GitHub Actions. Qué debes ver: tras el despliegue, https://TU_USUARIO.github.io/mini-reservalia/ con el formulario y, al pie, la línea Entorno: produccion · Version: 8f3c1e2 · API: ....

Un detalle honesto del laboratorio: la web pública no podrá llamar a tu API en localhost (y si lo hiciera, el navegador bloquearía la petición por CORS). Eso está bien: lo que ilustra este job es el despliegue atómico de un artefacto estático con configuración en tiempo de ejecución, que es la parte trasladable. En Reservalia, este mismo patrón sube el dist/ de Vite a S3 y hace una invalidación de CloudFront, con el mismo config.json generado en el despliegue (05-01).

  1. El rollback.yml y el cronómetro

Volver atrás no debe requerir pensar. Un rollback que exige recordar comandos es un rollback que no se usará a las tres de la madrugada.

# .github/workflows/rollback.yml
name: Rollback

on:
  workflow_dispatch:
    inputs:
      entorno:
        description: 'Entorno a revertir'
        required: true
        default: 'produccion'
        type: choice
        options: [staging, produccion]
      digest:
        description: 'Digest al que volver (sha256:...)'
        required: true
        type: string
      motivo:
        description: 'Motivo (queda registrado en el resumen)'
        required: true
        type: string

concurrency:
  group: cd-mini-reservalia    # MISMO grupo que el cd.yml: un rollback y un
  cancel-in-progress: false    # despliegue nunca deben solaparse

permissions:
  contents: read

jobs:
  rollback:
    name: Revertir ${{ inputs.entorno }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      packages: read
    # Ojo: para que un rollback sea RAPIDO, el entorno de rollback NO debe
    # tener revisor requerido, o volveras a esperar una aprobacion en plena
    # incidencia. Usamos un entorno distinto, sin puerta.
    environment:
      name: ${{ inputs.entorno }}-rollback
    steps:
      - name: Cronometro - inicio
        id: inicio
        run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"

      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Validar el digest recibido
        run: |
          set -Eeuo pipefail
          DIGEST="${{ inputs.digest }}"
          [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || {
            echo "::error::'$DIGEST' no es un digest valido (sha256: + 64 hex)"; exit 2; }
          # Comprobar que existe ANTES de tocar produccion.
          docker buildx imagetools inspect "ghcr.io/${{ github.repository }}@${DIGEST}" >/dev/null
          echo "Digest verificado y disponible en el registro."

      - name: Ejecutar el rollback
        run: ./scripts/desplegar.sh
        env:
          IMAGEN: ghcr.io/${{ github.repository }}@${{ inputs.digest }}
          ENTORNO: ${{ inputs.entorno }}
          PUERTO: ${{ inputs.entorno == 'produccion' && '3002' || '3001' }}

      - name: Verificar con el smoke test
        run: ./scripts/smoke.sh
        env:
          BASE: http://localhost:${{ inputs.entorno == 'produccion' && '3002' || '3001' }}

      - name: Cronometro - fin y registro
        if: always()
        run: |
          SEGUNDOS=$(( $(date +%s) - ${{ steps.inicio.outputs.t }} ))
          {
            echo "## ⏪ Rollback de ${{ inputs.entorno }}"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Digest restaurado | \`${{ inputs.digest }}\` |"
            echo "| Motivo | ${{ inputs.motivo }} |"
            echo "| Ejecutado por | @${{ github.actor }} |"
            echo "| **Duracion** | **${SEGUNDOS} s** |"
            echo "| Resultado | ${{ job.status }} |"
            echo ""
            echo "> Tiempo de restauracion del servicio (metrica DORA #4)."
          } >> "$GITHUB_STEP_SUMMARY"
          echo "Rollback completado en ${SEGUNDOS} s"

Ejecútalo:

# Averigua el digest anterior (el penultimo publicado)
gh api "/user/packages/container/mini-reservalia/versions" \
  --jq '.[1] | "\(.name)  \(.created_at)"'

gh workflow run rollback.yml \
  -f entorno=produccion \
  -f digest=sha256:ANTERIOR... \
  -f motivo="Prueba cronometrada del procedimiento de rollback"

gh run watch

Qué debes ver: el resumen con la duración. En este laboratorio el rollback tarda 60-110 segundos, la mayoría en el docker pull. Reservalia tarda 4 minutos porque ECS hace un rolling update con drenaje de conexiones. Los dos están muy por debajo del objetivo de 10 minutos, y los dos están medidos, que es lo que importa: un procedimiento de rollback cuyo tiempo nadie ha cronometrado es una suposición.

  1. Provocar un despliegue malo

Ahora la parte divertida: comprobar que la puerta cierra.

git checkout -b romper-salud

Edita src/servidor.js y sabotea /salud:

       if (req.method === 'GET' && url.pathname === '/salud') {
+        // FALLO DELIBERADO: simula una dependencia critica caida
+        if (process.env.ENTORNO) {
+          return responderJson(res, 503, { estado: 'degradado', error: 'base de datos no disponible' });
+        }
         return responderJson(res, 200, {

La condición process.env.ENTORNO hace que las pruebas locales y el CI sigan en verde (no definen ENTORNO) pero el contenedor desplegado falle. Es una simulación bastante fiel de la categoría de bug más peligrosa: el que solo aparece con la configuración de un entorno real.

npm test        # verde: las pruebas NO detectan esto
git commit -am "fix: comprobacion adicional en /salud"
git push -u origin romper-salud
gh pr create --fill && gh pr merge --squash --delete-branch --auto

Qué debes ver, en orden:

  1. CI en verde. Las 38 pruebas pasan. La imagen se publica. Este es el punto: el CI no lo detecta.
  2. cd.yml arranca. Resolver artefacto OK.
  3. Desplegar en staging falla. Y falla en dos sitios, lo cual es interesante:
    • Primero, dentro de desplegar.sh, el paso 7: el HEALTHCHECK del contenedor nunca llega a healthy.
      [10:42:31] Esperando a que el contenedor este healthy...
      [10:44:31] ERROR: no llego a healthy en 120 s (estado: unhealthy)
      
    • Si el health check fuera más laxo, lo cazaría smoke.sh:
      [smoke] FALLO: /salud no devuelve estado ok
      
  4. El paso de diagnóstico vuelca los logs del contenedor, gracias al if: failure().
  5. Desplegar en produccion no se ejecuta. Ni siquiera pide aprobación: su needs: [staging] no se cumplió. Producción nunca ve este código.

Esa es exactamente la promesa del despliegue continuo bien montado: un bug que el CI no detecta se para en el primer entorno real, automáticamente, sin que nadie tenga que mirar nada.

Ahora simula que hubiera llegado a producción. Fuerza el despliegue de la versión mala en producción y ejecuta el rollback cronometrando:

# 1. Desplegar a mano la version mala en produccion
gh workflow run cd.yml -f digest=sha256:MALO...
# aprobar en la web cuando lo pida; el smoke test de produccion fallara

# 2. Rollback, con el cronometro en marcha
date +%s
gh workflow run rollback.yml \
  -f entorno=produccion \
  -f digest=sha256:BUENO... \
  -f motivo="Incidente: /salud devuelve 503 tras el despliegue de sha256:MALO"
gh run watch
date +%s

Anota los tres números: tiempo de detección (cuánto tardó el smoke test en fallar), tiempo de decisión (cuánto tardaste en encontrar el digest bueno) y tiempo de ejecución (lo que dice el resumen del rollback). En un incidente real, el segundo suele ser el mayor de los tres, y es el que se reduce anotando el digest anterior en el resumen de cada despliegue, que es exactamente lo que hace nuestro cd.yml.

Revierte el sabotaje:

git checkout main && git pull
git checkout -b arreglar-salud
git revert --no-edit <sha-del-commit-malo>
git push -u origin arreglar-salud
gh pr create --fill && gh pr merge --squash --delete-branch --auto

  1. Verificación final

# Comprobación Cómo Esperado
1 La imagen no corre como root docker run --rm IMAGEN whoami node
2 El HEALTHCHECK funciona docker inspect --format '{{.State.Health.Status}}' healthy
3 La imagen está en ghcr.io Pestaña Packages mini-reservalia con versiones
4 El despliegue es idempotente Ejecutar desplegar.sh dos veces La 2.ª dice "Nada que hacer"
5 El script rechaza un tag IMAGEN=ghcr.io/x/y:main ./scripts/desplegar.sh Salida 2, mensaje sobre el digest
6 CD se dispara solo tras CI Merge a main Run de CD "triggered by CI"
7 Producción espera aprobación Ver el run Review required + botón
8 Se promociona el mismo digest Log del paso de verificación Los dos digests idénticos
9 No se reconstruye para producción Log del job No hay ningún docker build
10 El smoke test detecta el fallo Sabotear /salud Staging en rojo, producción sin ejecutar
11 El rollback está cronometrado Resumen del run Duración en segundos, < 10 min
12 El digest anterior queda registrado Resumen de cada despliegue Línea "Digest anterior"

Errores Comunes y Consejos

Síntoma: denied: installation not allowed to Create organization package al hacer push a ghcr.io. Causa: falta permissions: packages: write en el job, o el repositorio tiene restringido el permiso por defecto del GITHUB_TOKEN a solo lectura. Arreglo: añade el bloque permissions al job (no basta con tenerlo a nivel de workflow si el job lo sobrescribe) y revisa Settings → Actions → General → Workflow permissions.

Síntoma: Error response from daemon: unauthorized al hacer docker pull de tu propia imagen desde otro sitio. Causa: el paquete es privado por defecto, aunque el repositorio sea público. Arreglo: Packages → mini-reservalia → Package settings → Change visibility → Public, o Manage Actions access para dar acceso al repositorio.

Síntoma: el cd.yml no se dispara nunca, aunque el CI termina en verde. Causas posibles, en orden de frecuencia: (1) el fichero aún no está en mainworkflow_run solo se dispara con la versión de la rama por defecto—; (2) el workflows: ['CI'] no coincide con el name: del otro workflow (es sensible a mayúsculas); (3) el CI terminó con conclusion: failure y la guarda del if lo bloqueó correctamente. Arreglo: mergea primero, comprueba el name: exacto y mira el conclusion en la API: gh run list --workflow=ci.yml --json conclusion,name.

Síntoma: desplegar.sh falla con unbound variable en la línea de GITHUB_OUTPUT. Causa: set -u con una variable no definida al ejecutar el script fuera de Actions. Arreglo: ya está previsto con ${GITHUB_OUTPUT:-}. Si escribes tus propios scripts, la expansión con valor por defecto es obligatoria en todo lo que venga del entorno.

Síntoma: el contenedor arranca y muere inmediatamente, sin logs útiles. Causa habitual: permisos del volumen. El usuario node (uid 1000) no puede escribir en /datos si el volumen se creó con propietario root. Arreglo: el RUN mkdir -p /datos && chown -R node:node /datos del Dockerfile lo resuelve para volúmenes nuevos. Si el volumen ya existía con otros permisos: docker volume rm mini-reservalia-datos-staging y vuelve a desplegar. Diagnóstico: docker logs mini-reservalia-staging (por eso el paso if: failure() los vuelca).

Síntoma: el smoke test pasa pero está probando la versión antigua. Causa: el contenedor nuevo no arrancó y el reverse proxy sigue enrutando al viejo, o el puerto responde otra cosa. Arreglo: por eso smoke.sh compara la versión devuelta por /salud con la esperada. Sin esa comprobación, un despliegue que no hizo nada pasa el smoke test perfectamente. Es el fallo más silencioso de todos.

Síntoma: el disco del host se llena al cabo de unas semanas. Causa: cada despliegue deja una imagen sin usar de ~230 MB. Arreglo: el docker image prune del paso 8. En un host real, además, una tarea semanal de docker system prune -af --filter "until=720h" y una alerta de espacio en disco. La 07-04 pondrá esa alerta.

Consejo — nunca latest. docker pull imagen:latest es la forma más eficiente de no saber qué estás ejecutando. Nuestro script lo rechaza explícitamente. Que tu tooling impida el error es mucho mejor que documentar que no se debe cometer.

Consejo — el concurrency del CD es distinto del de CI. En CI, cancel-in-progress: true (nadie quiere el resultado de un commit superado). En CD, false (cancelar un despliegue a mitad deja el sistema en un estado que nadie ha diseñado). Y el rollback.yml comparte grupo con el cd.yml para que no se pisen.

Ejercicios

Ejercicio 1: rollback sin buscar el digest a mano

En un incidente, encontrar el digest anterior es el paso lento. Haz que el rollback.yml acepte el input digest vacío y, en ese caso, resuelva automáticamente la versión inmediatamente anterior a la desplegada.

Ejercicio 2: despliegue canary por porcentaje

Implementa un despliegue canary: levanta la versión nueva junto a la antigua y envía solo el 10 % del tráfico a la nueva durante 2 minutos; si el smoke test extendido pasa, promociona al 100 %; si no, retira la canary. Usa un contenedor Nginx como balanceador.

Ejercicio 3: bloquear el despliegue fuera del horario laboral

Añade una regla que impida desplegar a producción los viernes por la tarde y los fines de semana, con una vía de escape explícita para emergencias que quede registrada.

Soluciones

Solución 1.

      - name: Resolver el digest anterior si no se ha indicado
        id: resolver
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -Eeuo pipefail
          DIGEST="${{ inputs.digest }}"

          if [[ -z "$DIGEST" ]]; then
            echo "Sin digest indicado: se busca la version anterior a la desplegada."

            # 1. Que esta corriendo AHORA (la label que puso desplegar.sh)
            ACTUAL=$(docker inspect "mini-reservalia-${{ inputs.entorno }}" \
                       --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null \
                     | sed 's/.*@//' || echo "")
            echo "Desplegado actualmente: ${ACTUAL:-(desconocido)}"

            # 2. Las versiones del paquete, mas recientes primero
            mapfile -t VERSIONES < <(
              gh api "/user/packages/container/mini-reservalia/versions" \
                --jq '.[] | select(.metadata.container.tags | length > 0 or true) | .name' \
              | head -20
            )

            # 3. La primera que NO sea la actual
            for v in "${VERSIONES[@]}"; do
              if [[ "$v" != "$ACTUAL" ]]; then DIGEST="$v"; break; fi
            done

            [[ -n "$DIGEST" ]] || { echo "::error::No se encontro una version anterior"; exit 1; }
            echo "Version anterior resuelta: $DIGEST"
          fi

          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

Y usa ${{ steps.resolver.outputs.digest }} en los pasos siguientes. Cambia también el input a required: false.

Una versión más robusta no consulta el registro sino un historial de despliegues que el propio cd.yml mantiene: un fichero despliegues.jsonl en una rama estado, o la API de Deployments de GitHub (gh api "repos/{owner}/{repo}/deployments?environment=produccion"), que ya registra cada despliegue con su ref. La diferencia importa: el registro te dice qué imágenes existen; el historial te dice qué se desplegó y en qué orden, que es la pregunta real.

Solución 2.

nginx-canary.conf:

upstream mini_reservalia {
    # El peso reparte las peticiones: 9 de cada 10 a la estable.
    server host.docker.internal:3002 weight=9;   # estable
    server host.docker.internal:3003 weight=1;   # canary (10 %)
}

server {
    listen 8080;
    location / {
        proxy_pass http://mini_reservalia;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        # Si la canary falla, nginx la saca del pool y reintenta en la estable:
        # el usuario no ve el error. Es el "circuit breaker" del pobre.
        proxy_next_upstream error timeout http_502 http_503;
    }
}
  canary:
    name: Despliegue canary
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    environment: produccion
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }

      - name: 1. Levantar la canary junto a la estable
        run: ./scripts/desplegar.sh
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
          ENTORNO: canary
          PUERTO: '3003'

      - name: 2. Balanceador al 10 %
        run: |
          docker rm -f balanceador 2>/dev/null || true
          docker run -d --name balanceador -p 8080:8080 \
            --add-host host.docker.internal:host-gateway \
            -v "$PWD/nginx-canary.conf:/etc/nginx/conf.d/default.conf:ro" \
            nginx:alpine
          sleep 3

      - name: 3. Observar 2 minutos y medir la tasa de error
        id: observar
        run: |
          set -Eeuo pipefail
          FIN=$(( $(date +%s) + 120 ))
          TOTAL=0; ERRORES=0
          while [ "$(date +%s)" -lt "$FIN" ]; do
            CODIGO=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
                      "http://localhost:8080/api/huecos?fecha=2026-03-02" || echo 000)
            TOTAL=$((TOTAL+1))
            [[ "$CODIGO" == "200" ]] || ERRORES=$((ERRORES+1))
            sleep 1
          done
          TASA=$(awk "BEGIN {printf \"%.2f\", $ERRORES*100/$TOTAL}")
          echo "Peticiones: $TOTAL · Errores: $ERRORES · Tasa: ${TASA}%"
          echo "tasa=$TASA" >> "$GITHUB_OUTPUT"
          echo "### Canary: ${TASA}% de error en $TOTAL peticiones" >> "$GITHUB_STEP_SUMMARY"
          # Umbral del 1 %: por encima, no promociona.
          awk "BEGIN {exit !($TASA > 1.0)}" && { echo "::error::Tasa de error por encima del 1 %"; exit 1; }

      - name: 4a. Promocionar al 100 %
        if: success()
        run: |
          ./scripts/desplegar.sh
          docker rm -f mini-reservalia-canary balanceador || true
        env:
          IMAGEN: ${{ needs.preparar.outputs.imagen }}
          ENTORNO: produccion
          PUERTO: '3002'

      - name: 4b. Retirar la canary
        if: failure()
        run: |
          echo "::warning::Canary retirada. Produccion sigue con la version anterior."
          docker logs --tail 100 mini-reservalia-canary || true
          docker rm -f mini-reservalia-canary balanceador || true

Lo que este ejercicio enseña, y que la 03-04 explicaba en teoría: el canary no es "desplegar despacio", es "desplegar y medir". Sin el paso 3 —una métrica, un umbral y una decisión automática— tienes un despliegue lento, no un canary. Y fíjate en que el fallo del paso 3 deja producción intacta: el 90 % del tráfico nunca vio la versión nueva.

Solución 3.

      - name: Ventana de despliegue
        if: inputs.emergencia != true
        run: |
          set -Eeuo pipefail
          # El runner va en UTC; convertimos a la hora local del equipo.
          export TZ='Europe/Madrid'
          DIA=$(date +%u)      # 1=lunes ... 7=domingo
          HORA=$(date +%H)
          AHORA=$(date '+%A %H:%M %Z')

          bloquear() {
            {
              echo "## ⛔ Despliegue bloqueado por la ventana"
              echo ""
              echo "**Momento:** $AHORA"
              echo "**Motivo:** $1"
              echo ""
              echo "La ventana permitida es **lunes a jueves de 09:00 a 17:00** y"
              echo "**viernes de 09:00 a 13:00**."
              echo ""
              echo "Para una emergencia real, relanza el workflow con"
              echo "\`emergencia: true\` y un motivo. Quedara registrado."
            } >> "$GITHUB_STEP_SUMMARY"
            echo "::error::Fuera de la ventana de despliegue: $1"
            exit 1
          }

          [ "$DIA" -ge 6 ] && bloquear "fin de semana"
          [ "$DIA" -eq 5 ] && [ "$HORA" -ge 13 ] && bloquear "viernes por la tarde"
          { [ "$HORA" -lt 9 ] || [ "$HORA" -ge 17 ]; } && bloquear "fuera del horario laboral"

          echo "✅ Dentro de la ventana de despliegue ($AHORA)." >> "$GITHUB_STEP_SUMMARY"

      - name: Registrar el uso de la via de escape
        if: inputs.emergencia == true
        run: |
          {
            echo "## 🚨 DESPLIEGUE DE EMERGENCIA"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Autorizado por | @${{ github.actor }} |"
            echo "| Motivo | ${{ inputs.motivo_emergencia }} |"
            echo "| Momento (UTC) | $(date -u) |"
            echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
            echo ""
            echo "> Este despliegue se ha saltado la ventana. Debe revisarse en la retrospectiva."
          } >> "$GITHUB_STEP_SUMMARY"
          echo "::warning::Despliegue de emergencia fuera de ventana por @${{ github.actor }}"

Con los inputs correspondientes:

  workflow_dispatch:
    inputs:
      emergencia:
        description: 'Saltarse la ventana de despliegue (queda registrado)'
        type: boolean
        default: false
      motivo_emergencia:
        description: 'Obligatorio si emergencia = true'
        type: string

La discusión que este ejercicio abre es más interesante que el código, y conviene tenerla clara. Las ventanas de despliegue son un antipatrón en un equipo con buen CD: si desplegar da miedo el viernes, el problema no es el viernes, es el despliegue. Con rollback en 90 segundos y smoke tests automáticos, un viernes es un día como otro, y los datos del informe State of DevOps son consistentes en esto: los equipos de élite despliegan cuando hace falta.

Dicho eso, la ventana es una medida de transición razonable mientras el equipo construye confianza, y también es un requisito de negocio legítimo en algunos contextos (una plataforma de reservas puede no querer tocar nada un sábado por la mañana). Si la pones, ponle fecha de revisión y una vía de escape registrada como la de arriba: una restricción sin escape se acaba saltando de formas peores (desplegar a mano por SSH), y una restricción sin fecha de revisión se vuelve permanente por inercia.

Reto opcional

Sustituye la aprobación manual de producción por una puerta automática basada en datos: que producción se despliegue sola si el smoke test de staging pasa y la tasa de error de staging en los últimos 10 minutos es inferior al 0,5 % y el cambio no toca ficheros marcados como sensibles (migraciones, configuración de seguridad); y que pida aprobación humana solo en caso contrario. Es el paso de "despliegue continuo con puerta" a "despliegue continuo con puerta condicional", y es lo que hacen los equipos que despliegan 50 veces al día sin que nadie apruebe nada. Necesitarás las métricas de la 07-04, así que guárdalo para después.

Qué has construido

  • Un Dockerfile multi-etapa con usuario no-root, HEALTHCHECK, metadatos OCI y .dockerignore.
  • Publicación en ghcr.io desde el pipeline con credencial efímera, caché de capas y attestación de procedencia.
  • Separación CI/CD con workflow_run y la guarda que impide desplegar un CI en rojo.
  • Dos Environmentsstaging automático y produccion con revisor requerido— y la experiencia de ver el pipeline esperándote.
  • Un script de despliegue idempotente, ejecutable en tres destinos distintos sin cambios, que rechaza los tags, guarda el digest anterior y espera a healthy.
  • Un smoke test con reintentos que verifica la versión desplegada y comprueba que los errores siguen siendo errores.
  • Promoción por digest con verificación explícita de que producción despliega exactamente lo validado, sin reconstruir.
  • Un rollback.yml cronometrado que revierte en menos de dos minutos.
  • La comprobación de que un bug que el CI no detecta se para en staging y nunca llega a producción.

Conclusión

El circuito está cerrado: un commit puede llegar a producción sin que nadie ejecute un comando a mano, y puede volver atrás igual de rápido. Has visto la pieza que hace que todo esto sea seguro y que se resume en una frase: el mismo artefacto, identificado por su contenido, recorre todos los entornos, y cada paso comprueba que efectivamente es el mismo. Sin esa cadena, staging no valida nada y el rollback es una lotería.

Pero hay una pregunta que tu pipeline todavía no sabe responder, y es la que más importa. El smoke test te dice que el servicio respondió correctamente durante los treinta segundos posteriores al despliegue. ¿Y a los veinte minutos? ¿Y cuando entra el tráfico de verdad? ¿Y si la versión nueva funciona pero es tres veces más lenta? ¿Y cuántas veces has desplegado esta semana, cuánto tardó cada cambio desde el commit hasta producción, y cuántos de esos despliegues acabaron en rollback? Ahora mismo la única respuesta honesta es "no lo sé", y un pipeline que no sabe si mejoró las cosas es un pipeline que no se puede defender ante nadie.

En la 07-04 instrumentas el sistema. Añadirás a src/servidor.js la medición de latencia y el conteo por código de estado, expondrás /metricas en formato Prometheus sin dependencias, levantarás Prometheus y Grafana con docker compose con un panel versionado en el repositorio, definirás un SLO con su presupuesto de error calculado con aritmética explícita, escribirás una alerta por síntoma y la dispararás a propósito metiendo un sleep en un endpoint, marcarás los despliegues en el panel para ver la correlación entre "desplegamos" y "empeoró", calcularás las cuatro métricas DORA de tu propio repositorio con un job programado, y —cerrando el bucle que esta lección deja abierto— harás que el cd.yml sondee las métricas después de desplegar y dispare el rollback solo si el error supera el umbral.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados