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
- Integración, entrega y despliegue continuos
- Por qué la CI es lo que da valor a las pruebas
- Anatomía de una canalización
- GitHub Actions: los conceptos
- El flujo de integración:
ci.yml - El flujo de entrega:
cd.yml - Secretos en la canalización
- Calidad dentro de la canalización
- Versionado y publicación
- Desplegar en
prey enprod, y revertir - Migraciones de base de datos en la canalización
- Que la canalización sea rápida y fiable
- Alternativas y métricas DORA
- Errores Comunes y Consejos
- Ejercicios
- 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.
- 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.
- 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.
- 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.
- El flujo de integración:
ci.yml
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: 7Línea a línea, lo que hay que entender:
on: pushypull_requestsobremaines la definición operativa de integración continua: se verifica lo que se propone integrar y lo que ya se integró.concurrencyconcancel-in-progressevita gastar minutos de runner verificando un commit ya superado: en un repositorio activo ahorra fácilmente un tercio del consumo. Ytimeout-minutes: 25impide que un proceso colgado consuma horas.permissionsexplícitos. Por defecto elGITHUB_TOKENpuede tener permisos amplios; declararlos al mínimo evita que una acción de terceros comprometida escriba en el repositorio.fetch-depth: 0. Por defectocheckouttrae un solo commit; Sonar necesita el historial para atribuir líneas nuevas, y el plugingit-commit-idque alimenta el/actuator/infode 07-01 necesita los datos de Git.cache: mavenes 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 verifyes el corazón del flujo.-B(batch) desactiva el color y las barras de progreso, que en un log de CI solo generan ruido. Yverify, notest:testejecuta solo Surefire (las*Test), mientras queverifyejecuta además Failsafe (las*IT), que son las de Testcontainers de 06-05. Usartesten 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-latesttrae Docker instalado y el demonio en marcha. La primera ejecución descarga la imagenpostgres:16-alpine(unos 15 segundos) y a partir de ahí las*ITse ejecutan contra PostgreSQL real. if: always()en los informes es sutil y decisivo: sin él, unverifyfallido aborta el job y no se publica el informe, que es justo lo que hace falta para saber qué falló. Conalways(), 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 5Con 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.
- El flujo de entrega:
cd.yml
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.
- 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-1No 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.
- 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.
- 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.ymlY 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.
- Desplegar en
pre y en prod, y revertir
pre y en prod, y revertirEl 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.0Y 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.
- 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:
- Un paso propio para la migración, antes de tocar las instancias vivas: el hook
pre-upgradede 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. - Una comprobación automática en el pull request que rechace migraciones con
DROP COLUMN,RENAMEo cambios de tipo incompatibles cuando llegan junto al código que las usa: diez líneas degrepque evitan un incidente. ddl-auto: validateen todos los entornos (04-08): una instancia cuyo esquema no corresponde falla al arrancar, clara e inmediatamente, en vez de fallar consulta a consulta.- Verificación en
precon la versión anterior antes de aprobar producción: es la prueba directa de que la reversión será posible.
- 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.
- 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
pushamain, rompiendo la construcción anteCVSS ≥ 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.ymlverifica,cd.ymlentrega— y el commit etiquetado es el mismo que pasó porci.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.0Y antes de nada, comprobar si se puede revertir, que depende enteramente de la migración:
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-prodSi 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:
- La creación del índice bloqueó la tabla.
CREATE INDEXsinCONCURRENTLYtoma un bloqueo que impide las escrituras enalquileresmientras se construye. Con una tabla grande son minutos durante los cuales todoPOST /api/v1/alquileresespera y acaba agotando elconnection-timeoutdel pool →5xxy latencia disparada. Encaja perfectamente con el síntoma, y explica por qué afecta a/alquileresy no al resto. - 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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
