95% Más Rápido en CI Con 15 Líneas de Bash

Erik Treviño avatar
Erik Treviño
Cover for 95% Más Rápido en CI Con 15 Líneas de Bash

Reduje el tiempo de validación de nuestro CI en un 95%.

No con caché. No con sharding. No con una herramienta externa sofisticada que agrega otra dependencia a tu pipeline.

Con 15 líneas de bash. Cero dependencias. Cero riesgo en la cadena de suministro.

El PR que incluyó este cambio era más pequeño que la mayoría de los mensajes de commit. Y le ahorra al equipo 7-8 horas al mes.

El Problema

Cada PR ejecutaba el pipeline completo de CI. Linting del backend, pruebas unitarias del backend, pruebas unitarias del frontend, pruebas E2E — toda la batería completa. Un desarrollador cambia un solo archivo de prueba E2E, hace push de la rama y espera 8 minutos con 18 segundos para que el linting del backend le diga que su código de Python está bien.

El código de Python que no tocó.

Este es el comportamiento predeterminado en la mayoría de las configuraciones de CI. Un solo archivo de workflow, un solo trigger, ejecutar todo en cada push. Es simple. Es seguro. También está quemando tiempo del desarrollador en validaciones que no pueden fallar para los cambios que se están haciendo.

La Solución: 15 Líneas de Bash

La idea es simple: usar git diff --name-only para determinar qué archivos cambiaron y luego omitir condicionalmente los jobs que son irrelevantes para esos cambios.

#!/bin/bash
# detect-changes.sh — Determine which CI jobs to run based on changed files
# Called by GitHub Actions workflow to set output variables

CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)

RUN_BACKEND=false
RUN_FRONTEND=false
RUN_E2E=false

for file in $CHANGED_FILES; do
  case "$file" in
    backend/*|api/*|*.py)        RUN_BACKEND=true ;;
    frontend/*|src/*|*.ts|*.tsx) RUN_FRONTEND=true ;;
    tests/e2e/*|playwright/*)    RUN_E2E=true ;;
    .github/*|docker-compose*|Makefile)
      # Infrastructure changes: run everything as a safety net
      RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;
  esac
done

echo "run-backend=$RUN_BACKEND" >> "$GITHUB_OUTPUT"
echo "run-frontend=$RUN_FRONTEND" >> "$GITHUB_OUTPUT"
echo "run-e2e=$RUN_E2E" >> "$GITHUB_OUTPUT"

Eso es todo. El script lee el diff, clasifica cada archivo modificado por directorio y establece flags de salida que los jobs posteriores verifican antes de ejecutarse.

El Workflow de GitHub Actions: Antes y Después

Antes: Ejecutar Todo Siempre

# .github/workflows/ci.yml — BEFORE
name: CI
on: [push, pull_request]

jobs:
  backend-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    runs-on: ubuntu-latest
    needs: [backend-test, frontend-test]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Cada job se ejecuta en cada PR. Una corrección de typo en un archivo de prueba E2E dispara linting del backend, pruebas del backend, pruebas del frontend y pruebas E2E. Tiempo total: 8 minutos 18 segundos.

Después: Ejecutar Solo lo Relevante

# .github/workflows/ci.yml — AFTER
name: CI
on: [push, pull_request]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      run-backend: ${{ steps.changes.outputs.run-backend }}
      run-frontend: ${{ steps.changes.outputs.run-frontend }}
      run-e2e: ${{ steps.changes.outputs.run-e2e }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for accurate git diff
      - id: changes
        run: bash .github/scripts/detect-changes.sh

  backend-lint:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-e2e == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

La única adición es el job detect-changes (se ejecuta en ~5 segundos) y los condicionales if: en cada job posterior. Todo lo demás es idéntico.

Los Resultados

Tipo de PRAntesDespuésAhorro
Cambios solo en E2E8 min 18 seg39 seg95%
Cambios solo en frontend6 min 13 seg1 min 32 seg75%
Cambios solo en backend5 min 45 seg2 min 10 seg62%
Cambios full-stack8 min 18 seg8 min 18 seg0% (ejecuta todo)
Cambios en CI/infra8 min 18 seg8 min 18 seg0% (red de seguridad)

En una semana típica — aproximadamente 40% PRs solo de E2E, 30% solo de frontend, 20% solo de backend, 10% full-stack — esto se proyecta a 7-8 horas al mes de tiempo de espera ahorrado para los desarrolladores.

La red de seguridad es importante: cualquier cambio en los workflows de .github/, configuraciones de Docker o el Makefile dispara el pipeline completo. Nunca te saltas la validación de la infraestructura que ejecuta tu validación.

¿Por Qué No Usar una GitHub Action Para Esto?

Aquí es donde la historia se pone interesante. Audité tres GitHub Actions populares para filtrado de CI basado en rutas antes de escribir el script de bash. Lo que encontré me convenció de que cero dependencias era la decisión correcta.

Action 1: Reporta a una API Comercial

Una action popular de filtrado por rutas hace una llamada API a un servicio comercial en cada ejecución de CI. Lee eso otra vez: tu pipeline de CI reporta a un tercero cada vez que se ejecuta. La action funciona correctamente cuando el servicio está activo y tu suscripción está vigente. Cuando no lo está — y encontré GitHub Issues de usuarios reportando esto — la action puede hacer fallar tu build por completo. Tu pipeline de CI ahora tiene una dependencia de disponibilidad con un producto SaaS que no controlas.

Action 2: Objetivo de Ataque a la Cadena de Suministro (CVE-2025-30066)

En marzo de 2025, la ampliamente utilizada GitHub Action tj-actions/changed-files — usada por más de 23,000 repositorios — fue comprometida en un ataque a la cadena de suministro. El atacante modificó tags de versión existentes para apuntar a código malicioso que volcaba la memoria del runner de CI, exponiendo secretos del workflow incluyendo API keys, tokens y credenciales. CISA emitió un aviso formal (CVE-2025-30066). El ataque fue rastreado hasta un token de acceso personal comprometido perteneciente a una cuenta bot con acceso de escritura al repositorio.

Esto no es un riesgo teórico. Esto pasó. A una action que hace exactamente lo mismo que mi script de bash de 15 líneas.

Action 3: Funciona Bien, Pero Es Innecesaria

La tercera action que evalué está bien mantenida, es open-source y no tiene problemas de seguridad conocidos. Funciona correctamente. También agrega una dependencia que necesita ser fijada a una versión, monitoreada para actualizaciones y auditada periódicamente. Para 15 líneas de bash que hacen lo mismo, la dependencia no se justifica.

El Argumento de Seguridad a Favor de Cero Dependencias

Cada GitHub Action que agregas a tu workflow es código que ejecutas con acceso a los secretos de tu repositorio, tu código fuente y tu entorno de CI. Cada action es una decisión de confianza. El incidente de tj-actions/changed-files demostró que incluso actions populares y ampliamente utilizadas pueden ser comprometidas.

El enfoque del script de bash tiene un perfil de seguridad diferente:

  • Sin llamadas de red. El script lee la salida de git diff. No descarga nada, no reporta a nadie ni depende de nada fuera del repositorio.
  • Sin ejecución de código de terceros. El script está registrado en tu repositorio y se revisa en el mismo proceso de PR que tu código de aplicación.
  • Sin juegos de version pinning. No hay un tag @v4 de qué preocuparse. Sin vector de ataque a la cadena de suministro. El código es tuyo.
  • Auditoría completa. Cualquier persona del equipo puede leer 15 líneas de bash y entender exactamente qué hace.

A veces la mejor dependencia es no tener dependencia.

Casos Extremos y Redes de Seguridad

Algunas cosas que manejo y que una implementación ingenua pasaría por alto:

Archivos nuevos sin una categoría clara

# If a file doesn't match any known pattern, run everything.
# Better to over-run than under-run.
*)
  RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;

El caso por defecto dispara una ejecución completa. Un tipo de archivo no reconocido nunca debería omitir la validación silenciosamente.

Archivos eliminados

git diff --name-only incluye archivos eliminados. Un archivo de backend eliminado aún necesita que la suite de pruebas del backend se ejecute — la eliminación podría romper un import.

Merge commits

El fetch-depth: 0 en el paso de checkout es crítico. Sin el historial completo de git, el git diff contra la rama base produce resultados inexactos. He visto configuraciones de CI con fetch-depth: 1 (el valor predeterminado) que no detectan cambios porque el clon superficial no contiene la base del merge.

Lo Que Puedes Hacer el Lunes por la Mañana

  1. Mide tu línea base actual. ¿Cuál es la duración promedio de CI para tu tipo de PR más común? Si no lo sabes, revisa las últimas 20 ejecuciones del workflow.

  2. Categoriza tus jobs de CI por directorio. ¿Qué jobs corresponden a qué partes del codebase? La mayoría de los pipelines tienen un mapeo natural.

  3. Escribe el script de bash. Empieza con la versión de arriba y adapta los patrones de directorios para que coincidan con la estructura de tu repositorio.

  4. Agrega el job detect-changes. Conéctalo a tu workflow con condicionales if:.

  5. Agrega la red de seguridad. Cualquier cambio en la configuración de CI, archivos de Docker o scripts de infraestructura debe disparar una ejecución completa. Siempre.

  6. Mide el resultado. Compara los mismos tipos de PR antes y después. Los números van a hacer el caso mejor que cualquier argumento.

La Lección Más Amplia

El pipeline de CI es la infraestructura más descuidada en la mayoría de las organizaciones de ingeniería. Los equipos gastan semanas optimizando consultas a la base de datos que ahorran 200ms por request, y luego ven a cada desarrollador sentado durante 8 minutos de validación de CI irrelevante 5 veces al día sin cuestionarlo.

La solución aquí no es ingeniosa. No es novedosa. Son 15 líneas de bash que hacen una pregunta simple: ¿los cambios en este PR realmente afectan el código que este job valida?

Si la respuesta es no, omítelo. Eso no es tomar atajos. Es respetar el tiempo de tu equipo.

15 líneas. Cero dependencias. 95% más rápido. A veces la solución más simple es la que nadie intenta porque parece demasiado simple para funcionar.