Ir al contenido

Integración Continua

En un proyecto de desarrollo se producen muchos archivos: código, configuraciones, scripts, datos de prueba. Todos estos artefactos los crean y modifican distintas personas a lo largo del tiempo (meses durante el desarrollo inicial, y potencialmente años durante la vida útil del producto). Administrarlos bien es importante: la alternativa es perder información, perder tiempo rehaciendo cosas que no se encuentran, y afectar la calidad de lo que se construye.

Una vez decidida la arquitectura, debe haber un mapeo claro entre sus conceptos y los artefactos concretos: carpetas, archivos, esquemas de nombramiento. Por ejemplo, para el recurso Editorial del modelo del proyecto Book que usamos en las páginas de Backend, un mismo concepto se transforma en varios artefactos:

Clase Ubicación
EditorialDTO co.edu.uniandes.dsw.books.dtos.EditorialDTO
EditorialEntity co.edu.uniandes.dsw.books.entities.EditorialEntity
EditorialRepository co.edu.uniandes.dsw.books.repositories.EditorialRepository
EditorialService co.edu.uniandes.dsw.books.services.EditorialService
EditorialController co.edu.uniandes.dsw.books.controllers.EditorialController
EditorialServiceTest co.edu.uniandes.dsw.books.services.EditorialServiceTest

Editorial tiene un solo DTO, EditorialDTO: la representación completa (DTOCompleto) existe para modelar las asociaciones de cardinalidad múltiple, y Editorial no tiene ninguna.

Con un modelo conceptual de apenas 10 clases y algunas asociaciones entre ellas, fácilmente se llega a más de 100 artefactos distintos que hay que mantener organizados, solo en el backend.

Cuando varios desarrolladores trabajan en el mismo proyecto, hay que dividirse el trabajo (por capas, por conceptos, por historias de usuario) y luego integrar lo que cada quien hizo. Una estrategia posible es esperar a que todos terminen su parte y dedicar una fase aparte, al final, solo para integrar. Suena práctica, pero trae varios problemas:

  1. Se descubren tarde los malentendidos sobre los requerimientos o el diseño: “las piezas no encajan”.
  2. Corregir defectos en esta etapa (sobre problemas que se introdujeron mucho antes) es más costoso y puede generar problemas nuevos.
  3. La calidad del software es difícil de asegurar.
  4. Las pruebas de integración llegan muy tarde en el desarrollo para ser útiles.

A este escenario se lo conoce como Integration Hell, y su consecuencia directa es retrasar las entregas porque hay que rehacer trabajo, comprometiendo la calidad.

La alternativa (hoy parte de casi cualquier metodología de desarrollo) es la Integración Continua: en vez de integrar al final, se integra de forma constante, a medida que avanza el trabajo de cada persona. La práctica nació con Extreme Programming (XP) y fue descrita por primera vez por Martin Fowler en su artículo Continuous Integration.

Según Fowler, estas son las prácticas que hacen posible la Integración Continua:

  1. Usar un repositorio central para el código de todo el proyecto.
  2. Automatizar la construcción del ejecutable (el build).
  3. Ejecutar las pruebas automáticamente.
  4. Cada integrante hace commit/push todos los días.
  5. Cada commit dispara un build y una corrida de pruebas nuevos.
  6. Probar en un clon del ambiente de producción.
  7. Facilitar que cualquiera obtenga los últimos entregables.
  8. Que todos puedan ver el resultado del último build.
  9. Automatizar el despliegue.

Profundicemos en tres de ellas: el repositorio central, el manejo de dependencias, y la automatización del build.

Cada desarrollador trabaja con su propia copia local del repositorio: clona el repositorio central, hace sus commits localmente, y cuando su trabajo está listo, hace push para que los demás lo integren con un pull. Ya viste este flujo (commits, ramas, Pull Requests) con detalle en el taller de Git; la Integración Continua se construye directamente sobre él.

Lo que agrega la Integración Continua es la frecuencia: mientras más rápido y seguido se integra el trabajo de todos al repositorio central, más rápido aparece un conflicto o un error de integración, y más barato es corregirlo.

Todo proyecto depende de librerías de terceros (y no de cualquier versión, sino de una versión específica). Hay que asegurar que todos los desarrolladores usen exactamente las mismas.

Sin importar el lenguaje, las herramientas de manejo de dependencias comparten dos ideas:

  • Existe un repositorio de librerías, distinto al del código fuente del proyecto: puede ser un repositorio público de acceso universal, o uno propio de la organización.
  • Cada proyecto declara, en un archivo, cuáles librerías necesita y en qué versión.

En nuestro backend en Spring Boot usamos Maven, que declara las dependencias en pom.xml:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

En el frontend en Angular usamos npm, que las declara en package.json:

"dependencies": {
"@angular/core": "^18.0.0"
}

En ambos casos, cualquiera que clone el repositorio y corra mvn install o npm install termina con exactamente las mismas versiones que todos los demás.

3. Automatizar la construcción del ejecutable

Sección titulada «3. Automatizar la construcción del ejecutable»

Sin un proceso de build automatizado y repetible, cada desarrollador puede terminar haciendo las cosas un poco distinto, y aparecen inconsistencias: se usan versiones de librerías que no son, no queda claro qué fuentes se empaquetaron, no se corrieron las pruebas sobre lo que se empaquetó, el servidor de destino no quedó bien configurado.

Automatizar el build significa que un solo comando (no una serie de pasos manuales) resuelve las dependencias, compila las fuentes, empaqueta la aplicación y corre las pruebas. Con Maven, ese comando es mvn clean test; con npm, npm run build y npm test. Esto ya lo puede correr cualquier desarrollador en su máquina local; lo que falta es que ocurra automáticamente, para todos, cada vez que alguien integra.

Repositorio central, dependencias declaradas y build automatizado son los ingredientes. Lo que falta es una herramienta que los conecte: que detecte automáticamente cada push al repositorio central y, sin intervención manual, dispare el build y las pruebas, y que le avise a todo el equipo si algo se rompió.

flowchart LR
    A["git push"] --> B["La herramienta de CI<br/>detecta el push"]
    B --> C["Descarga dependencias<br/>(mvn / npm install)"]
    C --> D["Compila y empaqueta"]
    D --> E["Corre las pruebas"]
    E -->|"Todo pasa"| F["✅ Reporta éxito"]
    E -->|"Algo falla"| G["❌ Reporta el error"]
    F --> H["(Opcional)<br/>Despliega"]

Dos herramientas cubren este rol en el curso:

GitHub Actions

Corre dentro del propio GitHub: se configura con un archivo .yml dentro de .github/workflows/ en el repositorio, y no necesita infraestructura propia: GitHub la ejecuta por ti. Es la opción más simple cuando el código ya vive en GitHub.

Jenkins

Es un servidor de CI aparte, que hay que instalar y mantener (propio o en la nube), configurado con un Jenkinsfile. A cambio de ese costo de infraestructura, es independiente de dónde viva el repositorio y muy configurable para pipelines complejos.

Este mismo sitio de recursos usa GitHub Actions para correr sus pruebas en cada Pull Request. Así se ve su configuración real, en .github/workflows/test.yml:

name: Tests
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
runs-on: estudiantes-ci
container:
image: node:22-slim
steps:
- uses: actions/checkout@v7
- name: Habilitar pnpm
run: corepack enable
- name: Instalar dependencias
run: pnpm install --frozen-lockfile
- name: Correr las pruebas
run: pnpm test
# El contenedor corre como root en un runner self-hosted compartido
# entre todos los proyectos del curso: sin esto, los archivos quedan
# root-owned y el siguiente checkout (incluso el de otro estudiante,
# si le toca la misma máquina) no puede limpiarlos.
- name: Devolver el workspace al usuario del runner
if: always()
run: chown -R "$(stat -c %u "$GITHUB_WORKSPACE/..")":"$(stat -c %g "$GITHUB_WORKSPACE/..")" "$GITHUB_WORKSPACE"

Para un backend en Maven, el equivalente es igual de corto:

name: CI Backend
on:
push:
pull_request:
jobs:
build:
runs-on: estudiantes-ci
container:
image: maven:3.9-eclipse-temurin-21
steps:
- uses: actions/checkout@v4
- name: Compilar y correr pruebas
run: mvn -B test
- name: Devolver el workspace al usuario del runner
if: always()
run: chown -R "$(stat -c %u "$GITHUB_WORKSPACE/..")":"$(stat -c %g "$GITHUB_WORKSPACE/..")" "$GITHUB_WORKSPACE"

on define qué evento dispara el workflow (en ambos casos, cada push y cada Pull Request), y steps es, en esencia, el mismo pipeline que correrías a mano: descargar dependencias, compilar, probar.

runs-on: estudiantes-ci le dice a GitHub que use el runner propio del curso en vez de una máquina alojada por GitHub. container: es igual de importante: hace que todo el job corra aislado dentro de esa imagen, no directo sobre el sistema operativo del runner. El runner se comparte entre todos los proyectos del curso, así que este aislamiento es obligatorio, no opcional.

Un Jenkinsfile describe el mismo tipo de pipeline, pero lo ejecuta el propio servidor de Jenkins en vez de GitHub. Igual que en GitHub Actions, el build corre dentro de un contenedor, no directo en el agente:

pipeline {
agent {
docker {
image 'maven:3.9-eclipse-temurin-21'
}
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Build y pruebas') {
steps { sh 'mvn clean test' }
}
stage('Empaquetar') {
steps { sh 'mvn package' }
}
stage('Analisis SonarQube') {
steps {
withSonarQubeEnv('sonarqube-isis2211') {
sh 'mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dmaven.repo.local=$WORKSPACE/.m2/repository -Dsonar.userHome=$WORKSPACE/.sonar'
}
}
}
stage('Quality Gate') {
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
stage('Desplegar') {
steps { sh './deploy.sh' }
}
}
}

Jenkins necesita que alguien instale y mantenga el servidor (propio, o en un proveedor en la nube), y que el repositorio le avise (por webhook) cuando hay un push nuevo. A cambio, es una herramienta independiente de GitHub: sirve igual si el código vive en GitLab, Bitbucket o un servidor propio.

Los dos stages nuevos conectan el pipeline con el SonarQube propio del curso (sonarqube-isis2211, ya configurado en Jenkins). Analisis SonarQube envía el código a analizar. El nombre completo del plugin (org.sonarsource.scanner.maven:sonar-maven-plugin:sonar) hace falta porque el atajo corto sonar:sonar no siempre resuelve bien, y las dos propiedades (maven.repo.local, sonar.userHome) son necesarias porque el contenedor corre con un usuario sin privilegios de administrador, que no puede escribir en las carpetas por defecto. Quality Gate espera el veredicto de SonarQube (aprobado o no, según cobertura, código duplicado, vulnerabilidades, etc.) y, si no pasa, detiene el pipeline ahí mismo (no llega a Desplegar); el timeout evita que el build se quede esperando para siempre si algo falla en la comunicación con SonarQube.

El ejemplo anterior muestra las piezas de un pipeline. El que vas a usar en el proyecto (Release 5) las junta todas para el repo del grupo: compila y prueba el back, mide la cobertura, compila y prueba el front, analiza ambos con SonarQube y corre las Collections de Postman con Newman. Es un solo archivo, el Jenkinsfile, que va en la raíz del repo del grupo. No lo escribes ni lo editas: lo copias tal cual.

Lo primero que cambia respecto al ejemplo anterior es la primera línea del pipeline:

pipeline {
agent none
// ...
}

agent none significa que el pipeline completo no tiene un contenedor propio, y que cada etapa declara el suyo. Hay una razón práctica: el back necesita Java y Maven, el front necesita Node y pnpm, y SonarQube necesita su propio escáner. Una sola imagen con todo sería enorme y difícil de mantener. Así, cada etapa usa la imagen oficial que necesita:

Etapa Dónde corre Para qué
Checkout agent any (el servidor) Baja el código y calcula el nombre del proyecto en SonarQube a partir del nombre del repo
Backend Pipeline maven:3.9-eclipse-temurin-21 Compila, corre las pruebas con JaCoCo, publica los resultados y empaqueta el .jar
Frontend: build y pruebas node:22-slim Instala con pnpm, compila, corre las pruebas con cobertura
Analisis SonarQube sonarsource/sonar-scanner-cli Envía back y front a SonarQube
Quality Gate agent any Espera el veredicto de SonarQube
API: Postman/Newman agent any + contenedores Levanta el .jar y corre cada Collection contra él

Como cada etapa corre en un agente distinto, los archivos no pasan solos de una a otra. Por eso aparecen stash y unstash: el .jar, las clases compiladas y los reportes de cobertura se guardan en una etapa y se recuperan en la siguiente.

dir('backend') {
sh '''
mvn -B clean \
org.jacoco:jacoco-maven-plugin:0.8.13:prepare-agent \
test \
org.jacoco:jacoco-maven-plugin:0.8.13:report \
-Dmaven.repo.local=$WORKSPACE/.m2/repository
'''
}

dir('backend') entra a la carpeta del back, por eso el repo del grupo tiene que tener esa carpeta con ese nombre. El comando corre las pruebas del proyecto y, en el mismo paso, usa JaCoCo para medir qué porcentaje del código ejecutaron: JaCoCo se invoca desde la línea de comandos, así que no tienes que modificar tu pom.xml. El reporte queda en target/site/jacoco/jacoco.xml y lo lee SonarQube más adelante.

La opción -Dmaven.repo.local=$WORKSPACE/.m2/repository existe porque Jenkins corre el contenedor con un usuario sin privilegios, que no puede escribir en la carpeta por defecto de Maven. Sin ella, Maven termina con Could not create local repository. Los cachés se mandan al workspace.

Después, junit publica los resultados de las pruebas en la página del build, y la etapa de empaquetado genera el .jar con -DskipTests (las pruebas ya corrieron) y lo guarda como artefacto del build.

sh '''
corepack pnpm install --frozen-lockfile
# ... instala @vitest/coverage-v8 solo si el proyecto no lo trae ...
corepack pnpm run build
corepack pnpm test --coverage --coverage-reporters=lcov
'''

Tres detalles de esta etapa:

  • --frozen-lockfile instala exactamente las versiones de pnpm-lock.yaml. Si el lockfile no coincide con package.json, falla en vez de actualizarlo en silencio. Por eso el lockfile tiene que estar versionado.
  • pnpm test --coverage corre las pruebas de tu proyecto (ng test, que usa Vitest) y deja el reporte de cobertura en coverage/, que es lo que lee SonarQube.
  • Los cachés van al workspace (COREPACK_HOME, XDG_CACHE_HOME), por la misma razón que en el back: el usuario del contenedor no puede escribir en las carpetas por defecto. Sin ellos, corepack (que descarga la versión de pnpm que pide tu package.json) termina con EACCES: permission denied, mkdir '/.cache/node/corepack/v1'.
withSonarQubeEnv('sonarqube-isis2211') {
sh '''
sonar-scanner \
-Dsonar.projectKey=$SONAR_PROJECT_KEY \
-Dsonar.sources=backend/src/main/java,frontend/src \
...
'''
}

Una sola ejecución del escáner analiza back y front juntos, con el reporte de JaCoCo y el de lcov para la cobertura. La clave del proyecto en SonarQube es el nombre del repo (SONAR_PROJECT_KEY, la calcula la etapa Checkout), así que cada grupo tiene su propio proyecto sin configurar nada.

waitForQualityGate abortPipeline: false espera el veredicto de SonarQube, pero no detiene el pipeline si no pasa. A diferencia del ejemplo anterior, que lo detenía antes de desplegar, aquí el resultado queda visible en la página del build y el pipeline sigue con Newman.

docker.image('eclipse-temurin:21-jre-alpine').withRun(
"--network ${env.APP_NET} --network-alias ${env.BACKEND_ALIAS} ...",
"sh -c 'java -jar \$(ls *.jar | head -n1)'"
) { backend ->
docker.image('postman/newman:alpine').inside("--network ${env.APP_NET}") {
dir('backend/collections') {
sh '''
# espera hasta 90 s a que el back responda en el puerto 8080
for coleccion in *.postman_collection.json; do
newman run "$coleccion" --env-var baseUrl="http://$BACKEND_ALIAS:8080"
done
'''
}
}
}

Esta etapa arma una red de Docker privada para el build. En un contenedor levanta el .jar con el nombre de host backend, y en otro corre Newman. Newman llega al back en http://backend:8080.

Tres consecuencias para tus Collections:

  • El pipeline corre cada archivo *.postman_collection.json de backend/collections/, uno por uno. No combina las Collections.
  • La URL del back se la pasa a Newman con --env-var baseUrl=...: el pipeline no lee tu archivo Environment. Por eso la variable de tus Collections se tiene que llamar exactamente baseUrl. Si una Collection usa {{baseurl}}, Newman intenta conectarse a un servidor llamado literalmente {{baseurl}} y falla con getaddrinfo ENOTFOUND {{baseurl}}.
  • Si cualquier petición o aserción de cualquier Collection falla, el pipeline termina en rojo.

Cada etapa tiene además un timeout de 5 minutos: si algo se cuelga, Jenkins lo detiene en vez de ocupar el servidor para siempre.

1. ¿Qué es un 'artefacto de software' en el contexto de un proyecto?
2. ¿Por qué la estrategia de 'integrar todo al final' suele terminar en 'Integration Hell'?
3. ¿Qué dos cosas comparten todas las herramientas de manejo de dependencias (Maven, npm, etc.)?
4. ¿Cuál es la diferencia clave entre GitHub Actions y Jenkins?
5. En un pipeline de CI disparado por un push, ¿qué ocurre si las pruebas fallan?