Tu pipeline de CI te ha estado mintiendo por 13 dias

Erik Treviño avatar
Erik Treviño
Cover for Tu pipeline de CI te ha estado mintiendo por 13 dias

Nuestro pipeline de CI fallo durante 13 dias seguidos.

Nadie lo noto.

Mas de 20 fallos consecutivos. Builds rojos todos los dias, sin excepcion. El pipeline de auto-deploy estaba muerto. Cada ejecucion E2E expiraba despues de 30 minutos, quemaba creditos de CI y producia un badge rojo que nadie miraba.

Durante 13 dias, el equipo estuvo mergeando codigo sin ninguna validacion E2E. Cada PR que se envio durante esas dos semanas llego a produccion sin la suite de tests que se suponia debia detectar regresiones. La red de seguridad tenia un agujero, y el equipo caminaba por la cuerda floja sin mirar hacia abajo.

Dia 0: El PR que rompio todo

Un refactor de frontend se mergeo. Un trabajo necesario — el equipo estaba centralizando las definiciones de endpoints de URL en toda la aplicacion. Claves de rutas que estaban duplicadas en 17 archivos se consolidaron en un solo modulo de configuracion. Refactoring limpio. PR bien delimitado.

Todas las revisiones de codigo pasaron. Todos los tests unitarios pasaron. Los 4 aprobadores dieron su visto bueno. El revisor mas experimentado en el PR incluso senalo el area de mapeo de URLs como un riesgo — y lo reviso cuidadosamente.

Aun asi, la brecha se les escapo.

La infraestructura de tests E2E mantenia su propio mapeo de URLs. Un archivo de configuracion separado que asociaba nombres de rutas con URLs reales. Cuando el frontend refactorizo sus claves de rutas, nadie actualizo el mapeo de la infraestructura de tests. Asi que cada test E2E que navegaba a una ruta — que son todos los tests E2E — intentaba acceder a una URL que ya no existia.

// test-config/routes.ts — THE BROKEN FILE
// These route keys matched the frontend's OLD naming convention.
// The frontend PR renamed them. This file was not updated.
export const ROUTES = {
  dashboard: '/app/dashboard',
  userProfile: '/app/user/profile',
  settings: '/app/settings/general',
  // ... 14 more routes
  reports: '/app/reports/overview',     // OLD: was renamed to 'reporting'
  analytics: '/app/analytics/main',     // OLD: was renamed to 'insights'
} as const;

// Every test used these routes:
test('user can view analytics', async ({ page }) => {
  await page.goto(ROUTES.analytics);  // Navigates to a URL that no longer exists
  // Test times out waiting for a page that will never load
});

Dias 1 a 12: El fallo silencioso

Esto fue lo que paso durante los siguientes 12 dias: nada.

El pipeline E2E se ejecutaba en cada merge a main. Fallaba. Enviaba una notificacion. La notificacion llegaba a un canal que el equipo habia silenciado meses atras porque era demasiado ruidoso. La ejecucion del workflow aparecia en rojo en la pestana de GitHub Actions, que nadie revisaba porque los checks del PR (tests unitarios, linting) pasaban todos.

Cada dia, el pipeline:

  1. Descargaba el codigo
  2. Instalaba dependencias
  3. Lanzaba los navegadores
  4. Intentaba navegar a rutas que ya no existian
  5. Esperaba 30 segundos a que cada pagina cargara
  6. Expiraba por timeout
  7. Reportaba un fallo
  8. Repetia para cada test de la suite

30 minutos de tiempo de CI, quemados. Cada dia. Durante casi dos semanas.

Dia 13: El descubrimiento

Lo encontre de la forma en que se encuentran la mayoria de los fallos silenciosos — por accidente. Estaba investigando un problema diferente y abri la pestana de GitHub Actions. El muro de rojo fue imposible de ignorar una vez que lo vi. Mas de 20 fallos consecutivos en el pipeline E2E de la rama main.

La investigacion empezo con una pregunta simple: cuando empezo esto?

# Find the first failing run on main
gh run list --workflow=e2e.yml --branch=main --limit=30 --json conclusion,createdAt \
  | jq '.[] | select(.conclusion == "failure") | .createdAt' | tail -1

# Result: 13 days ago

Despues: que cambio hace 13 dias?

# Find the merge commit from 13 days ago
git log --oneline --since="13 days ago" --until="12 days ago" --merges

# Cross-reference with the first failure timestamp
git log --oneline --all --after="2026-03-15T00:00:00" --before="2026-03-16T00:00:00"

# Found it: the URL consolidation PR, merged on March 15

Luego: que fue exactamente lo que se rompio?

# Compare the test config routes against the frontend route definitions
# The frontend had renamed keys — the test config still used the old names
git diff HEAD~50..HEAD -- frontend/src/config/routes.ts
git diff HEAD~50..HEAD -- tests/config/routes.ts  # This file had ZERO changes

El diff conto la historia de inmediato. El archivo de rutas del frontend mostraba 17 claves renombradas en una ventana de 50 commits. El archivo de rutas de tests mostraba cero cambios en el mismo periodo. El mapeo se habia desalineado.

El fix

El fix fueron 10 lineas. Actualizar la configuracion de rutas de tests para que coincidiera con las nuevas claves del frontend.

// test-config/routes.ts — FIXED
export const ROUTES = {
  dashboard: '/app/dashboard',
  userProfile: '/app/user/profile',
  settings: '/app/settings/general',
  // Updated to match the frontend refactor
  reporting: '/app/reporting/overview',   // Was 'reports'
  insights: '/app/insights/main',         // Was 'analytics'
  // ...
} as const;

El pipeline paso de timeouts de 30 minutos a ejecuciones verdes de 4 minutos. Instantaneamente.

El fix tomo 10 minutos. Encontrarlo tomo 30 minutos de arqueologia git. Que el problema haya existido durante 13 dias es lo que me quita el sueno.

El contrato de mapeo de URLs

El verdadero fix no es la actualizacion de 10 lineas de las rutas. El verdadero fix es asegurarse de que esta categoria de fallo no pueda pasar desapercibida de nuevo.

Los mapeos de URL entre el enrutamiento de tu frontend y tu infraestructura de tests son un contrato de primera clase. Necesitan su propia validacion — no solo “espero que alguien se acuerde de actualizar la config de tests.”

Asi se ve un contrato entre las rutas del frontend y la infraestructura de tests en codigo:

// tests/contracts/route-sync.spec.ts
// This test validates that the test route config matches the frontend route config.
// It runs in CI on every PR and fails if the mappings drift.

import { ROUTES as TEST_ROUTES } from '../config/routes';
import { ROUTES as APP_ROUTES } from '../../frontend/src/config/routes';

test('test routes must match application routes', () => {
  const testRouteKeys = Object.keys(TEST_ROUTES).sort();
  const appRouteKeys = Object.keys(APP_ROUTES).sort();

  // Every app route should have a corresponding test route
  const missingInTests = appRouteKeys.filter(key => !testRouteKeys.includes(key));
  const staleInTests = testRouteKeys.filter(key => !appRouteKeys.includes(key));

  expect(missingInTests).toEqual([]);
  expect(staleInTests).toEqual([]);
});

test('test route URLs must match application route URLs', () => {
  for (const [key, url] of Object.entries(TEST_ROUTES)) {
    expect(APP_ROUTES[key]).toBeDefined();
    expect(APP_ROUTES[key]).toBe(url);
  }
});

Este test se ejecuta en menos de un segundo. No requiere ningun navegador. Detecta exactamente el fallo que paso desapercibido durante 13 dias. Si el PR del frontend hubiera incluido este test de contrato en el codebase, el PR mismo habria fallado en CI — porque cambiar claves de rutas en el frontend sin actualizar la config de tests habria sido detectado como una violacion de contrato.

El checklist de monitoreo de salud del pipeline

El fallo de 13 dias fue posible por multiples huecos en la observabilidad del pipeline. Esto fue lo que implemente despues:

1. Alertas por fallos consecutivos

# .github/workflows/pipeline-health.yml
# Runs daily and alerts if the main branch E2E pipeline has failed
# more than 3 consecutive times
name: Pipeline Health Check
on:
  schedule:
    - cron: '0 9 * * 1-5'  # Every weekday at 9 AM

jobs:
  check-health:
    runs-on: ubuntu-latest
    steps:
      - name: Check E2E pipeline status
        run: |
          RECENT=$(gh run list --workflow=e2e.yml --branch=main --limit=5 \
            --json conclusion -q '.[].conclusion')
          FAILURES=$(echo "$RECENT" | grep -c "failure" || true)
          if [ "$FAILURES" -ge 3 ]; then
            echo "🚨 E2E pipeline has failed $FAILURES of the last 5 runs"
            # Send alert to team channel
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2. Dashboard de tasa de exito del pipeline

Lleva un seguimiento de la tasa de exito en una ventana movil de 7 dias. Si baja del 80%, algo esta estructuralmente mal — no es solo un test inestable.

3. Duracion maxima aceptable de fallo

Decidan como equipo: cual es el numero maximo de dias consecutivos en los que el pipeline E2E puede fallar antes de que se convierta en una prioridad bloqueante? Para mi equipo, la respuesta ahora es 2 dias. Si el pipeline esta en rojo durante 2 dias consecutivos, se escala.

4. Disciplina en los canales de notificacion

El equipo habia silenciado el canal de notificaciones de CI porque era demasiado ruidoso. Eso es un sintoma de otro problema — el pipeline enviaba alertas por tests inestables junto con fallos reales, y la senal se perdio en el ruido. La solucion: canales separados para “fallo del pipeline” (fallos duros que requieren atencion) e “inestabilidad de tests” (tests inestables que se rastrean por separado).

5. Tests de contrato para configuracion compartida

Cada pieza de configuracion compartida entre el codigo de la aplicacion y la infraestructura de tests necesita un test de contrato. Rutas, feature flags, variables de entorno, URLs de endpoints de API — si la suite de tests depende de que se mantenga sincronizado con la aplicacion, valida esa sincronizacion en CI.

Por que los fallos silenciosos son los mas peligrosos

Los fallos ruidosos se arreglan. Un test que falla en cada PR bloquea el merge. Un deploy que lanza un error se revierte. Un build que explota se investiga de inmediato.

Los fallos silenciosos erosionan la confianza gradualmente. El pipeline esta en rojo, pero los PRs siguen mergeandose (porque la suite E2E corre en main, no como un check de PR). El dashboard muestra fallos, pero el equipo dejo de mirarlo. El auto-deploy esta roto, pero los deploys manuales siguen funcionando, asi que nadie esta realmente bloqueado.

Para el dia 5, el equipo se ha adaptado al estado roto. Para el dia 10, el estado roto es el estado normal. Para el dia 13, alguien lo nota y la reaccion es “ah, eso lleva un rato roto” en lugar de “esto es una emergencia.”

Este es el modo de fallo mas peligroso en automatizacion de tests. No el test que falla intermitentemente — ese es visible y molesto. El pipeline que falla silenciosamente durante semanas, entrenando al equipo a ignorarlo, hasta que la red de seguridad que provee es puramente teorica.

La leccion mas profunda

Si tu pipeline de CI puede fallar durante dos semanas sin que nadie actue, no tienes CI. Tienes un cron job mandando correos a /dev/null.

Integracion continua significa continua. No “continua a menos que la suite E2E este rota, en cuyo caso solo corremos los tests unitarios y cruzamos los dedos.” El punto entero del CI es detectar fallos temprano. Un pipeline que falla silenciosamente durante 13 dias no esta detectando nada. Esta gastando tiempo de computo para producir badges rojos que nadie lee.

Lo mas dificil de arreglar esto no fue la actualizacion de 10 lineas de las rutas. No fue el test de contrato. No fue el workflow de monitoreo. Fue notarlo. Todo lo demas tomo una tarde. Notarlo tomo 13 dias — y habria tomado mas si no hubiera estado revisando la pestana de Actions por una razon totalmente diferente.

Construye sistemas que noten por ti. Contratos que fallen rapido. Alertas que lleguen a las personas correctas. El pipeline nunca deberia poder mentirte durante 13 dias.

Porque el pipeline no era inestable. Estaba roto. Y lo mas dificil no fue arreglarlo — fue notarlo.