En las tres lecciones anteriores CicloUrbana llegó a Heroku, a AWS y a Kubernetes. En las tres hubo un actor implícito que seguía siendo humano: alguien que ejecutaba git push heroku main, aws ecs update-service o helm upgrade desde su portátil, con sus credenciales personales, confiando en haber pasado las pruebas antes y en no haberse dejado nada a medio commitear.

Ese actor es el último punto artesanal del proyecto, y esta lección lo elimina. Vamos a construir una canalización que, ante cada git push, compile, ejecute la suite completa del módulo 6 —incluidos los Testcontainers—, analice la calidad, construya y escanee la imagen, la publique etiquetada con el SHA del commit, la despliegue en pre, pase una prueba de humo y, tras una aprobación explícita, la lleve a producción. Nadie toca un servidor. Y con ello, las pruebas que escribimos en el módulo 6 dejan de ser un ejercicio de disciplina personal para convertirse en una puerta que nadie puede saltarse.

Contenido

  1. Integración, entrega y despliegue continuos
  2. Por qué la CI es lo que da valor a las pruebas
  3. Anatomía de una canalización
  4. GitHub Actions: los conceptos
  5. El flujo de integración: ci.yml
  6. El flujo de entrega: cd.yml
  7. Secretos en la canalización
  8. Calidad dentro de la canalización
  9. Versionado y publicación
  10. Desplegar en pre y en prod, y revertir
  11. Migraciones de base de datos en la canalización
  12. Que la canalización sea rápida y fiable
  13. Alternativas y métricas DORA
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. Integración, entrega y despliegue continuos

Los tres términos se usan como sinónimos y designan cosas distintas. La diferencia está en hasta dónde llega la automatización.

Integración continua (CI) Entrega continua (CD) Despliegue continuo
Qué automatiza Compilar y probar cada cambio integrado en la rama principal Todo lo anterior + dejar cada cambio listo para desplegar Todo lo anterior + desplegar a producción sin intervención
Dónde acaba Un veredicto: verde o rojo Un artefacto publicado y validado en pre El cambio en manos de los ciudadanos
Quién decide desplegar Nadie: no se despliega Una persona, pulsando un botón Nadie: si está verde, sale
Qué exige del equipo Suite de pruebas fiable y rápida; integrar a diario Además: entornos automatizados y esquema compatible Además: confianza total, observabilidad, reversión automática
Riesgo si falta madurez Bajo Medio Alto

La distinción práctica que conviene retener: entrega continua significa que podrías desplegar en cualquier momento; despliegue continuo significa que lo haces siempre. La diferencia entre ambas es una decisión de negocio y de confianza, no de tecnología: es exactamente el paso de aprobación manual del apartado 10.

Qué construimos para CicloUrbana: integración continua completa, entrega continua hasta pre de forma automática, y despliegue a prod con aprobación. Es la elección sensata para un servicio municipal en su primer año. Cuando la canalización lleve meses sin sorpresas y la observabilidad del módulo 9 esté en su sitio, quitar esa aprobación será un paso pequeño.

  1. Por qué la CI es lo que da valor a las pruebas

En el módulo 6 escribimos una suite considerable: unitarias de las tarifas de Ribalta, dobles de Mockito, rodajas @WebMvcTest y @DataJpaTest, la matriz de acceso con spring-security-test y pruebas *IT contra PostgreSQL 16 real con Testcontainers. Todo eso se ejecuta con ./mvnw verify.

El problema es que ./mvnw verify lo ejecuta quien se acuerda. Y aparecen los patrones conocidos: alguien tiene prisa y sube sin ejecutarlas; alguien las ejecuta pero solo el módulo que ha tocado; una prueba lleva dos semanas fallando y el equipo ha normalizado el rojo; una prueba pasa en el portátil de quien la escribió porque tiene una fila en la base de datos local que nadie más tiene.

La CI corta los cuatro de raíz:

Sin CI Con CI
Las pruebas las ejecuta quien quiere Se ejecutan siempre, en cada push y cada pull request
En el entorno de cada uno En un entorno limpio y reproducible
El resultado lo conoce quien las lanzó El resultado es público y bloquea la fusión
Una prueba rota puede convivir semanas La rama principal no acepta un cambio en rojo
«En mi máquina pasa» Si no pasa en la CI, no pasa

Ese último punto es el cambio cultural de fondo: la CI es el árbitro. Y hay un detalle técnico decisivo para nosotros: el runner de GitHub Actions ya tiene Docker instalado y en marcha, así que los Testcontainers de 06-05 funcionan sin configurar nada. Las pruebas de integración contra PostgreSQL real, que eran la pieza más valiosa del módulo 6, son también las que la CI ejecuta con más fidelidad.

  1. Anatomía de una canalización

flowchart TD
    A[git push / pull request] --> B[checkout]
    B --> C[cache de dependencias Maven]
    C --> D[compilar]
    D --> E[pruebas unitarias · Surefire]
    E --> F[analisis estatico · Sonar/SpotBugs]
    F --> G[pruebas de integracion · Failsafe + Testcontainers]
    G --> H[cobertura · jacoco:check]
    H --> I[construir imagen]
    I --> J[escanear imagen · Trivy]
    J --> K[publicar en el registro]
    K --> L[desplegar en pre]
    L --> M[pruebas de humo]
    M --> N{aprobacion}
    N -->|si| O[desplegar en prod]
    N -->|no| P[fin]
    O --> Q[verificar y vigilar]

Dos principios gobiernan ese orden. El primero: lo rápido y lo que más falla, primero. Compilar tarda segundos y detecta el error más común; las pruebas de integración tardan minutos. Poner lo lento al principio hace que un error tonto cueste diez minutos en vez de treinta.

El segundo: cada etapa es una puerta. Si una falla, no se ejecutan las siguientes. Una canalización que sigue adelante «porque solo era el análisis estático» no es una puerta, es un adorno.

  1. GitHub Actions: los conceptos

Concepto Qué es En CicloUrbana
Workflow Un fichero YAML en .github/workflows/ con un proceso completo ci.yml y cd.yml
Trigger El evento que lo dispara push, pull_request, workflow_dispatch, release
Job Conjunto de pasos que corren en la misma máquina verificar, imagen, desplegar-pre
Step Una acción reutilizable o un comando de shell actions/checkout, ./mvnw verify
Runner La máquina que ejecuta el job ubuntu-latest, con Docker ya disponible
Matriz Repetir un job con combinaciones de parámetros Probar con Java 21 y 25
Artefacto Fichero que un job produce y otro consume El JAR, los informes de pruebas
Secreto Valor cifrado inyectado en tiempo de ejecución Credenciales del registro, del clúster
Environment Destino con reglas propias: aprobación, ramas, secretos pre y prod

Dos propiedades importantes: cada job arranca en una máquina limpia (de ahí la necesidad de cachés y artefactos para pasar cosas entre jobs), y los jobs corren en paralelo salvo que se declare needs:, que es lo que impone el orden de las puertas.

  1. El flujo de integración: ci.yml

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]                    # cada integracion en la rama principal
  pull_request:
    branches: [main]                    # y cada propuesta de cambio, antes de fusionar

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true              # si llegan dos push seguidos, cancela el anterior

permissions:
  contents: read                        # minimo privilegio: solo leer el codigo
  checks: write                         # y publicar el informe de pruebas

jobs:
  verificar:
    name: Compilar, probar y analizar
    runs-on: ubuntu-latest
    timeout-minutes: 25                 # ninguna ejecucion puede colgarse indefinidamente

    steps:
      - name: Descargar el codigo
        uses: actions/checkout@v4
        with:
          fetch-depth: 0                # historial completo: lo necesitan Sonar y git-commit-id

      - name: Preparar Java 21 con cache de Maven
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven                  # cachea ~/.m2/repository segun el hash del pom.xml

      - name: Verificar el formato del codigo
        run: ./mvnw -B spotless:check

      - name: Compilar, probar y empaquetar
        run: ./mvnw -B verify
        env:
          TESTCONTAINERS_RYUK_DISABLED: 'false'
          SPRING_PROFILES_ACTIVE: test

      - name: Publicar el informe de pruebas
        if: always()                    # tambien cuando las pruebas fallan: es cuando mas importa
        uses: mikepenz/action-junit-report@v4
        with:
          report_paths: '**/target/*-reports/TEST-*.xml'

      - name: Publicar la cobertura de JaCoCo
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: cobertura-jacoco
          path: target/site/jacoco/
          retention-days: 14

      - name: Guardar el JAR para los siguientes flujos
        uses: actions/upload-artifact@v4
        with:
          name: ciclourbana-jar
          path: target/ciclourbana.jar
          retention-days: 7

Línea a línea, lo que hay que entender:

  • on: push y pull_request sobre main es la definición operativa de integración continua: se verifica lo que se propone integrar y lo que ya se integró.
  • concurrency con cancel-in-progress evita gastar minutos de runner verificando un commit ya superado: en un repositorio activo ahorra fácilmente un tercio del consumo. Y timeout-minutes: 25 impide que un proceso colgado consuma horas.
  • permissions explícitos. Por defecto el GITHUB_TOKEN puede tener permisos amplios; declararlos al mínimo evita que una acción de terceros comprometida escriba en el repositorio.
  • fetch-depth: 0. Por defecto checkout trae un solo commit; Sonar necesita el historial para atribuir líneas nuevas, y el plugin git-commit-id que alimenta el /actuator/info de 07-01 necesita los datos de Git.
  • cache: maven es el ajuste con mejor relación entre esfuerzo y beneficio: sin él, cada ejecución vuelve a descargar todo el árbol de dependencias —Spring Boot, Hibernate, Spring Security, MapStruct, los drivers— y añade dos o tres minutos a cada construcción.
  • ./mvnw -B verify es el corazón del flujo. -B (batch) desactiva el color y las barras de progreso, que en un log de CI solo generan ruido. Y verify, no test: test ejecuta solo Surefire (las *Test), mientras que verify ejecuta además Failsafe (las *IT), que son las de Testcontainers de 06-05. Usar test en la CI significaría dejar fuera precisamente las pruebas que más se parecen a producción.
  • Los Testcontainers funcionan sin configurar nada porque el runner ubuntu-latest trae Docker instalado y el demonio en marcha. La primera ejecución descarga la imagen postgres:16-alpine (unos 15 segundos) y a partir de ahí las *IT se ejecutan contra PostgreSQL real.
  • if: always() en los informes es sutil y decisivo: sin él, un verify fallido aborta el job y no se publica el informe, que es justo lo que hace falta para saber qué falló. Con always(), el informe de JUnit aparece anotado en el pull request, línea a línea.

Cuándo hacen falta servicios contenedor. GitHub Actions permite declarar servicios auxiliares en el job:

    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: prueba, POSTGRES_DB: ciclourbana }
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready --health-interval 10s --health-retries 5

Con Testcontainers no hace falta, y es importante entender por qué: Testcontainers arranca y para el contenedor desde el propio código de la prueba, con la misma configuración en la CI y en el portátil del desarrollador — que es exactamente la paridad de entornos que buscábamos. Los services tienen sentido cuando las pruebas no usan Testcontainers, o para dependencias que no se levantan desde Java.

Y la puerta. Un flujo que informa pero no bloquea no sirve de nada. En Settings → Branches → Branch protection rules hay que exigir que el check verificar pase antes de poder fusionar en main. Sin esa configuración, la canalización es un informe; con ella, es una garantía.

  1. El flujo de entrega: cd.yml

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

on:
  push:
    tags: ['v*']                        # v2.4.1 dispara la entrega
  workflow_dispatch:                    # y se puede lanzar a mano desde la interfaz

permissions:
  contents: read
  packages: write                       # publicar en GHCR
  id-token: write                       # OIDC: credenciales temporales, sin claves guardadas

env:
  REGISTRO: ghcr.io
  IMAGEN: ${{ github.repository_owner }}/ciclourbana

jobs:
  imagen:
    name: Construir, escanear y publicar la imagen
    runs-on: ubuntu-latest
    outputs:
      etiqueta: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }

      - name: Empaquetar sin repetir las pruebas
        run: ./mvnw -B -DskipTests package
        # Las pruebas ya pasaron en ci.yml sobre este mismo commit.

      - name: Calcular etiquetas y metadatos
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRO }}/${{ env.IMAGEN }}
          tags: |
            type=semver,pattern={{version}}
            type=sha,prefix=sha-,format=short
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Autenticarse en el registro
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRO }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # temporal, generado para esta ejecucion

      - name: Construir y publicar
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64        # imprescindible para Fargate (08-03)
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Escanear la imagen
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}:${{ steps.meta.outputs.version }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'                # una vulnerabilidad grave DETIENE la entrega

  desplegar-pre:
    needs: imagen
    runs-on: ubuntu-latest
    environment:
      name: pre
      url: https://pre.ciclourbana.ribalta.example
    steps:
      - uses: actions/checkout@v4
      - name: Desplegar en pre
        run: |
          helm upgrade --install ciclourbana ./charts/ciclourbana \
            -n ciclourbana-pre -f values-pre.yaml \
            --set image.tag=${{ needs.imagen.outputs.etiqueta }} \
            --atomic --timeout 8m
      - name: Prueba de humo
        run: |
          curl -fsS --retry 10 --retry-delay 6 --retry-all-errors \
            https://pre.ciclourbana.ribalta.example/actuator/health/readiness
          curl -fsS https://pre.ciclourbana.ribalta.example/api/v1/estaciones | jq -e 'length == 4'

  desplegar-prod:
    needs: [imagen, desplegar-pre]
    runs-on: ubuntu-latest
    environment:
      name: prod                        # entorno PROTEGIDO: exige aprobacion manual
      url: https://ciclourbana.ribalta.example
    steps:
      - uses: actions/checkout@v4
      - name: Desplegar en produccion
        run: |
          helm upgrade ciclourbana ./charts/ciclourbana \
            -n ciclourbana-prod -f values-prod.yaml \
            --set image.tag=${{ needs.imagen.outputs.etiqueta }} \
            --atomic --timeout 10m
      - name: Verificar la version desplegada
        run: |
          curl -fsS https://ciclourbana.ribalta.example/actuator/info \
            | jq -e '.build.version == "${{ needs.imagen.outputs.etiqueta }}"'

Las decisiones de fondo de este fichero:

Se dispara con una etiqueta, no con cada push. v2.4.1 es un acto deliberado: alguien decide que este commit es una versión. Es la frontera entre integración continua (cada cambio) y entrega (versiones).

No se repiten las pruebas. -DskipTests no es una trampa: ci.yml ya las ejecutó sobre este mismo commit. Repetirlas duplicaría el tiempo sin añadir información.

Etiquetado triple. docker/metadata-action produce a la vez 2.4.1 (la versión semántica, legible), sha-9f3a2b1 (el identificador exacto e inequívoco del commit) y latest. Es lo que hace posible responder con certeza a «¿qué código está corriendo en Ribalta?»: se compara el SHA de /actuator/info con el del repositorio.

environment: prod es la aprobación manual. Configurando ese entorno en GitHub con required reviewers, el job se detiene y espera a que una persona autorizada lo apruebe en la interfaz. Es la línea exacta que separa entrega continua de despliegue continuo: quitar la protección del entorno convierte lo uno en lo otro.

El escáner detiene la entrega. exit-code: '1' con severidad HIGH,CRITICAL significa que una vulnerabilidad grave en la imagen impide publicar, enlazando con el endurecimiento de 07-04 y con OWASP Dependency-Check de 05-05.

La prueba de humo con --retry. Tras un helm upgrade, los pods tardan en estar listos; sin reintentos, el curl fallaría por llegar pronto. Y el jq -e 'length == 4' comprueba algo real —las cuatro estaciones de Ribalta— y no solo que el proceso responda.

  1. Secretos en la canalización

La canalización necesita credenciales para publicar imágenes y desplegar. Es, por definición, un sistema con permisos elevados y por tanto un objetivo.

Mecanismo Qué es Valoración
Secreto de repositorio Valor cifrado en secrets.NOMBRE Aceptable; es una credencial de larga duración que hay que rotar
Secreto de entorno Igual, pero atado a pre o prod Mejor: las credenciales de producción solo existen en el job de producción
GITHUB_TOKEN Testigo generado para cada ejecución y revocado al terminar Ideal para GHCR y el propio repositorio
OIDC GitHub emite un testigo firmado que el proveedor cambia por credenciales temporales La mejor opción: no hay ninguna clave guardada

OIDC con AWS, que elimina por completo las claves de larga duración:

      - name: Credenciales temporales de AWS sin claves guardadas
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/ciclourbanaDespliegueRol
          aws-region: eu-west-1

No hay AWS_ACCESS_KEY_ID en ninguna parte. GitHub emite un testigo OIDC firmado que identifica el repositorio, el flujo y la rama; AWS lo valida contra una relación de confianza y devuelve credenciales que caducan en una hora. Y la política de confianza debe restringir qué repositorio y qué rama pueden asumir el rol:

"Condition": {
  "StringEquals": { "token.actions.githubusercontent.com:sub":
      "repo:ayuntamiento-ribalta/ciclourbana:environment:prod" }
}

Sin esa condición, cualquier repositorio de GitHub podría asumir el rol. Es el error de configuración más grave y más frecuente de OIDC.

Advertencia destacada: nunca imprimas un secreto en los logs. GitHub enmascara los valores registrados como secretos, pero el enmascaramiento se rompe con facilidad: si el secreto se transforma (se codifica en base64, se recorta, se concatena), el valor derivado no está enmascarado. Un echo de depuración olvidado, un set -x en un script o una herramienta que vuelca su configuración pueden dejar una credencial en un log que quizá sea público. Y si ocurre, la única respuesta correcta es rotar el secreto: borrar el log no basta, porque pudo ser leído o replicado.

Tres reglas más: mínimo privilegio —el rol de despliegue puede actualizar el servicio, no borrar la base de datos—; permissions explícitos en cada flujo; y fijar las acciones de terceros por SHA (uses: acme/accion@a1b2c3d) en lugar de por etiqueta móvil, porque una etiqueta puede reapuntarse a código malicioso.

  1. Calidad dentro de la canalización

La canalización es el único sitio donde una regla de calidad se cumple siempre. Lo que conviene poner, y en qué orden:

Control Herramienta Cuándo se ejecuta ¿Rompe la construcción?
Formato y errores probables Spotless/Checkstyle; SpotBugs/Error Prone Antes y después de compilar Sí: es objetivo y trivial de arreglar
Calidad y deuda SonarQube / SonarCloud Tras las pruebas Sí, por quality gate sobre código nuevo
Cobertura jacoco:check En verify Sí, con umbral realista
Dependencias vulnerables OWASP Dependency-Check Nocturno + en main Sí para CRITICAL
Actualización de dependencias Dependabot Programado No: abre pull requests
Vulnerabilidades de la imagen Trivy, docker scout Tras construir la imagen Sí para HIGH/CRITICAL

El umbral de cobertura de 06-01, ahora como puerta real:

<execution>
  <id>comprobar-cobertura</id>
  <goals><goal>check</goal></goals>
  <configuration><rules><rule><element>BUNDLE</element><limits>
    <limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
    <limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.60</minimum></limit>
  </limits></rule></rules></configuration>
</execution>

Y la advertencia que ya se hizo en 06-01, aquí más importante porque ahora es obligatoria: un umbral demasiado alto produce pruebas basura. Si para fusionar hay que llegar al 90 %, alguien escribirá pruebas sin asertos que ejecutan código para subir el porcentaje. Un umbral del 70 % en líneas es exigente y honesto. Y el criterio más útil no es la cobertura global sino la del código nuevo: es lo que hace Sonar con su quality gate, y evita que una base heredada con poca cobertura bloquee cualquier avance.

Dependencias vulnerables, retomando 05-05:

  seguridad:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - name: OWASP Dependency-Check
        run: ./mvnw -B org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=9

-DfailBuildOnCVSS=9 rompe la construcción solo ante vulnerabilidades críticas. Un umbral bajo genera tantos falsos positivos que el equipo aprende a ignorarlos, y un control ignorado es peor que no tenerlo. Este análisis conviene ejecutarlo en un job aparte y programado de noche, porque descarga la base de datos de vulnerabilidades y puede tardar varios minutos. Y Dependabot (.github/dependabot.yml) complementa abriendo pull requests que actualizan versiones: como cada uno pasa por ci.yml, actualizar Spring Boot deja de ser un salto de fe.

  1. Versionado y publicación

Versionado semántico (MAYOR.MENOR.PARCHE) aplicado a CicloUrbana:

Cambio Versión Ejemplo en CicloUrbana
Corrección compatible (PARCHE) 2.4.0 → 2.4.1 Arreglar el redondeo de una tarifa
Funcionalidad compatible (MENOR) 2.4.1 → 2.5.0 Nuevo endpoint de incidencias
Cambio incompatible (MAYOR) 2.5.0 → 3.0.0 Eliminar un campo de la respuesta pública

Para una API con clientes externos —la aplicación móvil de Ribalta— la parte MAYOR es un contrato: un cambio incompatible obliga a versionar la API (/api/v2/...) y a mantener la anterior durante un periodo de gracia.

./mvnw versions:set -DnewVersion=2.4.1 -DgenerateBackupPoms=false
git commit -am "Version 2.4.1"
git tag -a v2.4.1 -m "Correccion del redondeo de tarifas"
git push origin main --follow-tags        # la etiqueta dispara cd.yml

Y el cierre del círculo con 07-01: el plugin build-info de Spring Boot y git-commit-id graban la versión, el SHA y la fecha dentro del artefacto, y Actuator los expone:

curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build, .git.commit.id.abbrev'
# { "version": "2.4.1", "time": "2026-09-01T09:14:22Z" }
# "9f3a2b1"

Ese SHA es la respuesta definitiva a «¿qué está desplegado?», y hace verificable el despliegue en lugar de creíble. Las notas de la versión, generadas a partir de los mensajes de commit desde la etiqueta anterior, completan el rastro: alguien que investigue un incidente puede pasar del SHA que responde el servidor a la lista exacta de cambios que entraron.

  1. Desplegar en pre y en prod, y revertir

El flujo del apartado 6 implementa la política: pre automático, prod con aprobación. Lo que la persona que aprueba debe poder ver antes de pulsar: que la CI está verde, que pre lleva un rato funcionando con esa versión, que la prueba de humo pasó y qué cambios entran.

Las estrategias de 08-01, ahora automatizadas:

Estrategia Cómo se implementa en la canalización
Rolling helm upgrade con maxUnavailable: 0 (08-04) o minimumHealthyPercent=100 en ECS (08-03)
Blue-green Dos servicios y un paso que conmuta el destino del balanceador
Canary Un despliegue con peso de tráfico y una pausa que mide métricas antes de continuar

La reversión, que es la parte que más se descuida, tiene tres niveles según la gravedad:

# 1. Automatica, si el despliegue no converge: --atomic ya lo hace
# 2. Manual e inmediata: volver a la etiqueta anterior de la imagen
helm rollback ciclourbana -n ciclourbana-prod
aws ecs update-service --cluster ciclourbana --service ciclourbana-web \
  --task-definition ciclourbana:41 --force-new-deployment
# 3. Por la canalizacion: relanzar cd.yml con la etiqueta anterior
gh workflow run cd.yml -f version=2.4.0

Y la condición de fondo, repetida por tercera vez en el módulo porque es la que más despliegues arruina: volver a la imagen anterior solo funciona si el esquema de base de datos sigue siendo compatible con ella.

  1. Migraciones de base de datos en la canalización

La migración es el único paso de la canalización que no se puede deshacer. Todo lo demás —la imagen, la configuración, el número de réplicas— vuelve atrás con un comando; una migración aplicada se queda aplicada.

De ahí la regla que gobierna todo el módulo: el patrón expand/contract de 04-08 es la condición para poder revertir la aplicación sin revertir el esquema.

flowchart LR
    subgraph D1["Despliegue 1 · expand"]
      A1[Migracion: ANADIR columna nueva] --> A2[Codigo: escribe en las dos]
    end
    subgraph D2["Despliegue 2 · migrar lectura"]
      B1[Sin migracion] --> B2[Codigo: lee y escribe solo la nueva]
    end
    subgraph D3["Despliegue 3 · contract"]
      C1[Migracion: ELIMINAR columna vieja] --> C2[Codigo: sin cambios]
    end
    D1 --> D2 --> D3

En cada uno de los tres despliegues, la versión anterior de la aplicación sigue funcionando contra el esquema resultante. Esa propiedad es lo que hace que helm rollback sea una operación segura en vez de una apuesta.

Cómo se traduce a la canalización:

  1. Un paso propio para la migración, antes de tocar las instancias vivas: el hook pre-upgrade de Helm (08-04), la tarea puntual de ECS (08-03) o la release phase de Heroku (08-02). Si falla, el despliegue se detiene y la versión anterior sigue sirviendo.
  2. Una comprobación automática en el pull request que rechace migraciones con DROP COLUMN, RENAME o cambios de tipo incompatibles cuando llegan junto al código que las usa: diez líneas de grep que evitan un incidente.
  3. ddl-auto: validate en todos los entornos (04-08): una instancia cuyo esquema no corresponde falla al arrancar, clara e inmediatamente, en vez de fallar consulta a consulta.
  4. Verificación en pre con la versión anterior antes de aprobar producción: es la prueba directa de que la reversión será posible.

  1. Que la canalización sea rápida y fiable

Una canalización lenta se evita, y una canalización que falla sin motivo se ignora. Las dos patologías tienen el mismo desenlace: el equipo deja de confiar en ella y vuelve a desplegar a mano.

Objetivo Cómo conseguirlo
CI en menos de 10 minutos Caché de Maven, paralelizar jobs, -DskipTests en los flujos que no prueban
Retroalimentación temprana Formato y compilación primero; integración después
Paralelizar Jobs independientes sin needs: corren a la vez: pruebas, análisis, seguridad
Cachés efectivas cache: maven y cache-from/to: type=gha para las capas de Docker
Nada de pruebas intermitentes Prohibición absoluta: una prueba que falla una de cada veinte veces se arregla o se borra
Fallar rápido timeout-minutes en cada job y cancel-in-progress

Sobre las pruebas intermitentes hay que ser tajante. Una prueba que a veces falla enseña al equipo a relanzar la ejecución en lugar de leer el error, y ese hábito destruye el valor de toda la canalización: el día que falle de verdad, alguien pulsará «reintentar». Las causas habituales son conocidas —dependencias temporales (Thread.sleep en vez de esperar una condición), estado compartido entre pruebas, orden de ejecución asumido, y fechas y zonas horarias— y cuando arreglarla llevará tiempo, la respuesta correcta es @Disabled con un enlace a la incidencia, no dejarla fallando.

Tiempo objetivo: la CI por debajo de 10 minutos y la entrega completa hasta pre por debajo de 20. Por encima de eso, la gente empieza a agrupar cambios para «no gastar una ejecución», y agrupar cambios es exactamente lo contrario de la integración continua.

  1. Alternativas y métricas DORA

Herramienta Modelo Notas
GitHub Actions Alojado, YAML Integrado con el repositorio; enorme catálogo de acciones
GitLab CI Alojado o propio, .gitlab-ci.yml Muy completo, con entornos y registro incluidos
Jenkins Autoalojado, Jenkinsfile Máxima flexibilidad; hay que mantenerlo
CircleCI / Azure DevOps Alojados Rápidos y con buena caché; el segundo, fuerte en entornos Microsoft
Tekton / Argo Workflows Sobre Kubernetes Nativos del clúster; encajan con GitOps (08-04)

Los conceptos son los mismos en todas: disparadores, etapas, pasos, artefactos, cachés, secretos, aprobaciones y entornos. Cambia la sintaxis, no el diseño; lo aprendido con ci.yml y cd.yml se traslada a cualquiera de ellas en una tarde.

Y qué mirar después de desplegar. Las métricas DORA son el marco estándar para medir la salud de la entrega:

Métrica Qué mide Referencia de equipo saludable
Frecuencia de despliegue Cada cuánto llega un cambio a producción Al menos semanal; los mejores, a diario
Tiempo de entrega De commit a producción Menos de un día
Tasa de fallo de cambios Qué porcentaje de despliegues causa un incidente Por debajo del 15 %
Tiempo de restauración Cuánto se tarda en recuperarse Menos de una hora

Lo valioso de estas cuatro es que se equilibran entre sí: desplegar mucho pero rompiendo constantemente sale mal en la tercera; no desplegar nunca «por prudencia» sale mal en las dos primeras y, paradójicamente, también en la cuarta, porque un despliegue grande y raro es mucho más difícil de revertir que uno pequeño y frecuente. La conclusión contraintuitiva del sector es sólida: desplegar más a menudo hace el sistema más estable, no menos, porque cada despliegue es pequeño, comprensible y fácil de deshacer.

Y las señales inmediatas de los diez minutos posteriores a un despliegue son las de 08-01: readiness en verde en todas las instancias, cero reinicios, tasa de 5xx y latencia estables, sin excepciones nuevas en el log y /actuator/info mostrando el SHA esperado. Medirlas bien es el objeto del módulo 9.

Errores Comunes y Consejos

Usar mvn test en lugar de mvn verify. Ejecuta solo Surefire y deja fuera las *IT de Testcontainers, que son las pruebas más valiosas. La canalización queda verde sin haber probado contra PostgreSQL real.

Publicar el informe sin if: always(). Cuando las pruebas fallan —el único momento en que el informe importa— el paso no se ejecuta y no hay nada que leer.

No proteger la rama principal. Sin branch protection, la CI informa pero no impide fusionar en rojo: es un adorno caro.

Imprimir secretos en el log. Un echo de depuración o una transformación del valor rompe el enmascaramiento; si ocurre, rota el secreto, porque borrar el log no basta. Y OIDC sin condición de sub deja un rol asumible desde cualquier repositorio de GitHub.

Recompilar en el flujo de entrega. Si cd.yml reconstruye desde cero, lo que se despliega no es exactamente lo que se probó. Reutiliza el artefacto o construye una vez a partir del mismo commit.

Convivir con pruebas intermitentes. Enseñan al equipo a pulsar «reintentar» y destruyen la confianza en toda la canalización.

Umbral de cobertura irreal. Un 90 % obligatorio produce pruebas sin asertos. Mide la cobertura del código nuevo.

Consejo: la canalización es código. Se revisa en un pull request, se comenta y se refactoriza. Un ci.yml de trescientas líneas con pasos duplicados tiene el mismo problema que una clase de trescientas líneas.

Consejo: act o una rama de pruebas para iterar. Depurar un flujo a base de git push es lento y llena el historial de commits «probando CI»: usa una rama desechable y workflow_dispatch. Y fija las acciones de terceros por SHA, porque una etiqueta como @v4 puede reapuntarse y un SHA no.

Ejercicios

Ejercicio 1

Escribe el flujo ci.yml de CicloUrbana con dos jobs paralelos: uno que compile y ejecute las pruebas unitarias (Surefire) y otro que ejecute las de integración con Testcontainers (Failsafe), más un tercer job que dependa de ambos y publique el resumen. Explica qué se gana y qué se pierde respecto al job único del apartado 5, cómo se comparte el resultado de la compilación entre jobs, y qué habría que configurar en el repositorio para que la canalización sea una puerta y no un informe.

Ejercicio 2

La canalización de CicloUrbana tarda 28 minutos y el equipo ha empezado a agrupar cambios para no esperar. Los tiempos medidos son: descarga de dependencias 4 min, compilación 1 min, pruebas unitarias 2 min, análisis de SonarCloud 3 min, OWASP Dependency-Check 7 min, pruebas de integración con Testcontainers 6 min, construcción de la imagen 4 min, escaneo con Trivy 1 min. Diseña un plan de optimización que baje la retroalimentación a menos de 10 minutos, con el tiempo estimado tras cada medida y una justificación de por qué cada cambio es seguro.

Ejercicio 3

Se despliega la versión 2.6.0 a producción a las 10:00. A las 10:07 las alarmas muestran un 12 % de 5xx en /api/v1/alquileres y latencia p99 disparada. La versión incluye una migración V12__anadir_indice_alquileres.sql y un cambio en AlquilerService. Describe la secuencia exacta de actuación en los primeros quince minutos, decide si se puede revertir y en qué condiciones, y propón las mejoras concretas a la canalización que habrían evitado o acotado el incidente.

Soluciones

Solución 1

name: CI
on:
  push: { branches: [main] }
  pull_request: { branches: [main] }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
permissions: { contents: read, checks: write }

jobs:
  unitarias:
    runs-on: ubuntu-latest
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - name: Compilar e instalar sin pruebas (reutilizable)
        run: ./mvnw -B -DskipTests install
      - name: Pruebas unitarias (Surefire)
        run: ./mvnw -B surefire:test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: informes-unitarias, path: target/surefire-reports/ }

  integracion:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - name: Pruebas de integracion (Failsafe + Testcontainers)
        run: ./mvnw -B verify -DskipUnitTests
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: informes-integracion, path: target/failsafe-reports/ }

  resumen:
    needs: [unitarias, integracion]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - uses: mikepenz/action-junit-report@v4
        with: { report_paths: '**/TEST-*.xml' }

Qué se gana. El tiempo total pasa de ser la suma a ser el máximo de los dos jobs: si las unitarias tardan 3 minutos y las de integración 8, el resultado llega en 8 en lugar de 11. Y la retroalimentación es más útil: un fallo unitario aparece a los 3 minutos sin esperar a los Testcontainers. Además, cada job tiene su propio timeout ajustado a lo que hace.

Qué se pierde. Cada job arranca en una máquina limpia, así que la compilación se hace dos veces —unos 60-90 segundos duplicados— y la caché de Maven se descarga dos veces (aunque desde la caché de GitHub, que es rápida). El fichero es más largo y hay más piezas que mantener. Con una suite pequeña, el job único del apartado 5 es más simple y no notablemente más lento; la separación empieza a compensar cuando las de integración pasan de 5 minutos.

Cómo se comparte el resultado. Con actions/upload-artifact y download-artifact, que es lo que hace el job resumen. Si se quisiera evitar la doble compilación, el patrón sería un job previo que compile una vez y suba target/ como artefacto; en la práctica, para un proyecto del tamaño de CicloUrbana, el ahorro no compensa la complejidad. El if: always() en resumen es imprescindible: sin él, un fallo en cualquiera de los dos jobs impediría publicar el informe.

Para que sea una puerta, en Settings → Branches: exigir pull request antes de fusionar en main, marcar unitarias, integracion y resumen como checks obligatorios, exigir que la rama esté actualizada respecto a main antes de fusionar (así se prueba realmente el resultado de la integración), y prohibir el push directo, incluidos los administradores. Sin esto último, la canalización tiene una puerta trasera.

Solución 2

Diagnóstico. El total secuencial es 28 minutos, pero no todo pertenece al camino crítico de la retroalimentación. La clave está en separar lo que un desarrollador necesita saber en minutos de lo que puede saberse más tarde.

Medida Cambio Tiempo tras la medida
1. Sacar OWASP a un job nocturno No es información que bloquee un pull request; su base de vulnerabilidades cambia a diario, no por commit 28 → 21 min
2. Caché de Maven cache: maven en setup-java: la descarga baja de 4 min a ~30 s 21 → 17,5 min
3. Paralelizar en tres jobs unitarias+Sonar (1+2+3=6 min) y integracion (6 min) a la vez 17,5 → ~12 min
4. Sacar la imagen a cd.yml Construirla y escanearla no aporta nada al pull request: solo hace falta al entregar 12 → ~7 min
5. Caché de capas de Docker cache-from/to: type=gha en cd.yml: la construcción baja de 4 min a ~1 (mejora cd.yml)
6. cancel-in-progress Deja de gastar minutos en commits ya superados Efecto sobre el conjunto

Resultado: retroalimentación de pull request en unos 7 minutos, por debajo del objetivo, con la entrega completa (imagen + escaneo + despliegue a pre) en otros 6-8 solo cuando se etiqueta una versión.

Por qué cada cambio es seguro:

  • (1) OWASP sigue ejecutándose a diario y en cada push a main, rompiendo la construcción ante CVSS ≥ 9; lo único que cambia es que no bloquea cada pull request, y no debe: una vulnerabilidad publicada esta mañana no la ha introducido el cambio que se revisa.
  • (2) La caché se invalida con el hash del pom.xml, así que un cambio de dependencias fuerza la descarga completa: no hay riesgo de construir contra artefactos obsoletos. (5) Igual con las capas de Docker: si una capa cambia, se reconstruye.
  • (3) Los dos jobs son independientes; la única contrapartida es compilar dos veces, unos 60 segundos que el paralelismo compensa con creces.
  • (4) Es coherente con el diseño del módulo —ci.yml verifica, cd.yml entrega— y el commit etiquetado es el mismo que pasó por ci.yml. (6) Cancelar una ejecución obsoleta no elimina ninguna verificación: el commit más reciente incluye el anterior.

Y una medida cultural, sin la cual las técnicas no bastan: el objetivo de tiempo debe ser explícito y vigilado. Si la CI vuelve a superar los 10 minutos, se trata como una incidencia, no como algo que pasa. La señal de alarma es la que describe el enunciado: cuando la gente agrupa cambios para no esperar, la integración ha dejado de ser continua.

Solución 3

Minutos 0-2: contener, no investigar. La regla de 08-01 es que el criterio de reversión se decide antes y se aplica sin debate. Un 12 % de 5xx supera con holgura cualquier umbral razonable, así que:

kubectl get pods -n ciclourbana-prod                  # ¿estan vivos? ¿reiniciando?
curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build.version'   # confirmar que es 2.6.0

Y antes de nada, comprobar si se puede revertir, que depende enteramente de la migración:

cat src/main/resources/db/migration/V12__anadir_indice_alquileres.sql

Minuto 2: la decisión. V12 añade un índice. Es una migración aditiva y compatible hacia atrás: la versión 2.5.0 funciona perfectamente contra un esquema con un índice de más. Por tanto sí se puede revertir, y se hace de inmediato:

helm rollback ciclourbana -n ciclourbana-prod
kubectl rollout status deploy/ciclourbana -n ciclourbana-prod

Si V12 hubiera sido un DROP COLUMN o un RENAME, la respuesta sería la contraria: revertir habría empeorado la situación —el escenario del ejercicio 3 de 08-01— y habría que arreglar hacia adelante con un despliegue correctivo urgente.

Minutos 3-8: confirmar la recuperación. Vigilar que la tasa de 5xx vuelve a la línea base, que la latencia p99 baja y que /actuator/info muestra 2.5.0. Comunicar el estado: el ayuntamiento debe saber que el servicio está restablecido antes de que pregunte.

Minutos 8-15: empezar a investigar, ya sin presión. Las dos hipótesis, por probabilidad:

  1. La creación del índice bloqueó la tabla. CREATE INDEX sin CONCURRENTLY toma un bloqueo que impide las escrituras en alquileres mientras se construye. Con una tabla grande son minutos durante los cuales todo POST /api/v1/alquileres espera y acaba agotando el connection-timeout del pool → 5xx y latencia disparada. Encaja perfectamente con el síntoma, y explica por qué afecta a /alquileres y no al resto.
  2. Una regresión en AlquilerService: una consulta N+1 nueva (04-06), una transacción demasiado larga (04-07) o una excepción no contemplada.

La forma de distinguirlas: los logs de la ventana 10:00-10:07 y las métricas del pool (hikaricp.connections.pending, 09-03). Si hay conexiones en espera y bloqueos en PostgreSQL, es la primera; si hay excepciones de aplicación, la segunda.

Mejoras concretas a la canalización:

Mejora Qué evita
Regla de revisión: todo CREATE INDEX en PostgreSQL debe llevar CONCURRENTLY y ejecutarse fuera de transacción, y toda migración con DROP/RENAME debe justificar su compatibilidad La causa raíz más probable
Comprobación automática en el pull request que busque CREATE INDEX sin CONCURRENTLY y DROP COLUMN en db/migration Que la regla dependa de que alguien se acuerde
Ensayo de la migración en pre con volumen realista Un índice que tarda 20 ms con 100 filas y 4 minutos con 2 millones
Despliegue canary o por fases en lugar de rolling completo Que el 100 % del tráfico sufra el fallo desde el primer minuto
Reversión automática por alarma: si los 5xx superan el 5 % durante 3 minutos tras un despliegue, revertir sin intervención Los 7 minutos que tardó alguien en darse cuenta
Ventana de despliegue fuera de la hora punta de alquileres El impacto sobre el número de ciudadanos afectados
Prueba de humo con carga en pre, no solo un curl Que el problema aparezca por primera vez en producción

Y una observación de fondo que resume el módulo entero: la canalización funcionó exactamente como estaba diseñada —construyó, probó, escaneó, desplegó en pre, pidió aprobación y desplegó—. Lo que faltó no fue automatización, sino una verificación que representara las condiciones de producción: volumen de datos real en pre y una regla que capturase un patrón de migración conocidamente peligroso. Las canalizaciones no evitan los errores por sí solas; evitan los errores que alguien se ha molestado en codificar como comprobación, y cada incidente es la oportunidad de añadir uno más.

Conclusión

El camino del commit hasta los ciudadanos de Ribalta está completo y automatizado. Distingues con precisión integración continua, entrega continua y despliegue continuo, y sabes que lo que separa a las dos últimas no es tecnología sino una decisión de confianza —el environment: prod protegido—. Has visto por qué la CI es lo que convierte la suite del módulo 6 en una garantía real: deja de ejecutarla quien se acuerda, en el entorno de cada uno, para ejecutarse siempre, en una máquina limpia, con un veredicto público que bloquea la fusión.

Tienes la canalización completa de CicloUrbana en dos ficheros: un ci.yml comentado línea a línea, con caché de Maven, ./mvnw -B verify que ejecuta Surefire y Failsafe con Testcontainers sobre el Docker que el runner ya trae, informes publicados con if: always() y la protección de rama que lo convierte en puerta; y un cd.yml que se dispara con una etiqueta, construye y escanea la imagen con Trivy, la publica con triple etiquetado —versión semántica, SHA del commit y latest—, despliega en pre, pasa una prueba de humo real contra las cuatro estaciones y espera una aprobación humana antes de tocar producción.

Sabes gestionar los secretos con entornos, GITHUB_TOKEN y OIDC sin claves de larga duración, con la condición de sub que evita que cualquier repositorio asuma tu rol, y con la advertencia de que un secreto impreso en un log se rota, no se borra. Tienes la calidad como parte de la canalización —Spotless, SpotBugs, Sonar sobre código nuevo, jacoco:check con un umbral honesto, OWASP Dependency-Check nocturno, Dependabot y el escaneo de la imagen—, el versionado semántico enlazado con el build-info de Actuator que responde qué SHA corre en Ribalta, la reversión en sus tres niveles y el patrón expand/contract como condición para que esa reversión sea posible. Y sabes qué hace que una canalización se use o se ignore: por debajo de diez minutos, sin pruebas intermitentes, con cada etapa fallando rápido.

CicloUrbana está en producción y llega sola. La pregunta ya no es si funciona ni cómo se despliega, sino cómo se comporta: qué latencia tiene realmente un alquiler en hora punta, qué consulta consume el 40 % del tiempo de base de datos, cuántas veces se recalcula lo mismo, qué está pasando dentro de la JVM cuando la memoria sube y no baja. El módulo 9, Rendimiento y Monitoreo, responde a todo eso: ajuste de rendimiento y del pool, caché con Spring Cache, métricas de negocio con Micrometer sobre el Actuator de 07-01, Prometheus y Grafana, gestión de logs y trazabilidad distribuida. Hasta ahora hemos construido y entregado la red de Ribalta; a partir de ahora vamos a verla funcionar y a hacer que vaya rápido.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados