Cómo debuggear un pipeline CI/CD con 10 fallas apiladas
En pocas palabras: El 1 de septiembre de 2026, un desarrollador descubrió que su deploy a DigitalOcean nunca corría: diez fallas encadenadas mantenían el CI en rojo en la rama main, y como el deploy estaba gateado a conclusion == 'success', jamás se disparaba pese a verse bien cableado.
Un desarrollador tenía un workflow de GitHub Actions llamado “Deploy to DigitalOcean” configurado hacía semanas, con SSH y secretos referenciados, y aun así cada cambio de backend lo subía a mano por SSH al droplet. El 1 de septiembre de 2026 documentó el caso: no era un bug, eran diez fallas chicas apiladas, cada una tapando a la siguiente. Saber cómo debuggear pipeline CI/CD acá fue pelar capa por capa.
Un pipeline CI/CD es la cadena automatizada que integra, testea y despliega código: primero corre CI (build y tests), y solo si pasa, se dispara el deploy. En GitHub Actions, el deploy suele estar gateado con la condición github.event.workflow_run.conclusion == 'success'. Si ese CI nunca da verde, el deploy nunca corre, aunque en pantalla parezca perfectamente cableado. Ese es el corazón del caso relatado en dev.to.
En 30 segundos
- El deploy nunca corría porque el CI que lo gateaba nunca dio verde en la rama
main. Cero corridas exitosas. - Dos bugs de infra tumbaban CI: un
.python-versiongitignorado que no llegaba al checkout, y Node.js dos majors atrás de lo que pedíajsdom. - Los cuatro secretos SSH no existían. El error “unable to authenticate” no era de clave mala, era de secretos nunca creados.
- El primer deploy verde tiró 502 por falta de health check en Traefik: mandaba tráfico antes de que Gunicorn terminara de bootear.
- Sin
restart: unless-stopped, cualquier reboot del droplet dejaba todos los containers caídos hasta intervención manual.
¿Por qué mi pipeline CI/CD está configurado pero el deploy nunca corre?
Porque el job que gatea al deploy nunca dio verde. Si tu deploy tiene una condición del tipo workflow_run.conclusion == 'success', y el CI referenciado falla siempre, el deploy hace exactamente lo que le pediste: no ejecutarse. Visto de lejos, un pipeline gateado detrás de un CI en rojo permanente parece un pipeline que no existe.
El detalle que lo hacía invisible: los fallos de CI y los deploys ausentes vivían en dos pestañas distintas de la UI de Actions. Nadie conectó los puntos. Lo primero para debuggear un pipeline CI/CD no es mirar el deploy, es abrir el historial del workflow que lo condiciona y ver si alguna vez terminó en success. En este caso, según el relato original en dev.to, cada fila del CI figuraba en rojo y cada deploy figuraba directamente ausente.
Moraleja: si tu deploy “automático” todavía te obliga a hacer pull y rebuild a mano, chequeá primero si lo que lo gatea de verdad tuvo éxito alguna vez. Lo explicamos a fondo en problemas de DNS en tus deploys.
¿Qué errores silenciosos de CI no notás en tu máquina?
Los que dependen de archivos o versiones que existen en local pero no en CI. El caso trajo tres clásicos. El job de backend moría con “The specified python version file at: .python-version doesn’t exist”. El archivo estaba en el repo local, pero seguía gitignorado, así que nunca llegaba al checkout de CI. Un archivo ignorado no viaja, por más real que se vea en tu disco.
- Archivos gitignorados que CI necesita. Un
.python-versiono un.envque existe en tu máquina pero no se commitea. La acción que lo consume necesita el archivo en el checkout, no en tu disco. - Pinning de dependencias desfasado. El frontend crasheaba con
TypeError: webidl.util.markAsUncloneable is not a function. La causa:jsdompedía Node^22.22.2 || ^24.15.0 || >=26.0.0y el runner tenía una versión dos majors atrás. Ningún test llegaba a correr. - Drift de formateo.
black,isortydjLintfallaban en CI porque nadie tenía los git hooks cableados. En local nadie lo veía; en CI reventaba desde hacía quién sabe cuánto.
Lo interesante del formateo: la salida del propio hook en CI ya trae el diff exacto. Aplicarlo verbatim, sin re-derivarlo. Cuando el formateador ya te dijo qué está “bien formateado”, no hay ambigüedad.
¿Cómo verificar si los secretos de GitHub Actions realmente existen?
Mirá el mensaje de error del deploy y contá las filas de secretos en Settings del repo. Cuando CI por fin dio verde, el deploy corrió por primera vez y falló en 12 segundos con “ssh: unable to authenticate, attempted methods [none]”. Ese “[none]” es la pista: el cliente no intentó ningún método porque no tenía con qué. Los cuatro secretos que el workflow referenciaba nunca se habían creado.
Ojo con esto: crear el secreto y verificar que está disponible son cosas distintas. Un workflow puede referenciar ${ secrets.SSH_KEY } sin que ese secreto exista, y GitHub no te avisa en tiempo de edición. Andá a Settings > Secrets and variables > Actions y confirmá que cada nombre referenciado tiene una fila real. Si el deploy nunca había llegado tan lejos, es probable que nunca los hayas necesitado de verdad, hasta ahora. Cubrimos ese tema en detalle en plataformas como GitHub Actions o Jenkins.
¿Cuáles son los dos gotchas fatales con claves SSH?
El primero es correr ssh-copy-id desde adentro del mismo servidor. Si ya estás con SSH dentro del droplet e intentás instalar la clave pública ahí, ssh-copy-id abre una conexión saliente de vuelta al mismo host para instalar la clave, pero esa conexión todavía no tiene clave válida para autenticarse. Huevo y gallina. Ya sentado en la shell del target, salteá ssh-copy-id y appendeá directo:
- Instalación en el mismo host.
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keysy despuéschmod 600 ~/.ssh/authorized_keys. Nada de conexiones circulares. - Contenido del secreto mutilado. Aún con la clave pública confiada, el deploy seguía dando el mismo error de auth. El PEM multilínea se había colapsado al setear el secreto vía interpolación de string en vez de redirección de archivo.
La solución del segundo gotcha: nunca dejes que nada toque el formato. Redirigí el archivo crudo (por ejemplo con gh secret set SSH_KEY --repo org/repo < ~/.ssh/id_ed25519) para garantizar fidelidad byte a byte, incluidas las líneas BEGIN/END y cada salto de línea interno que un string de shell se comería.
¿Por qué el deploy dice que la ruta de checkout no existe?
Porque la ruta estaba copiada de otro proyecto y nunca se había validado. Con la auth por fin andando, el script llegó al droplet y explotó con cd: /home/***/apps/gigglegigs: No such file or directory y fatal: not a git repository. El workflow asumía que el clone vivía en un path que en realidad no existía.
La verificación es de manual: un ls en el droplet mostró el clone real en otra carpeta. El path se había pegado desde la config de deploy de otro proyecto y nadie lo probó nunca, porque el workflow jamás había llegado tan lejos para toparse con él. Fix de una línea, y el script corrió entero: build, collectstatic, migrate. Si trabajás con droplets o VPS y querés hosting y dominios en Argentina, donweb.com resuelve la parte de infraestructura sin vueltas.
¿Por qué el primer deploy verde igual tiró 502?
Porque Traefik mandaba tráfico a un container que todavía booteaba. Dos minutos después del primer deploy completo, el admin devolvía 502. La reacción es entrar en pánico: primer deploy automático y el sitio caído. Pero los logs contaban algo menos dramático. Gunicorn arrancó (espera de Postgres, migrate, fork de workers) y quedó escuchando en 0.0.0.0:5000 a las 12:48:18. El 502 tenía timestamp 12:48:17. Un segundo antes. En elegir entre Jenkins y GitHub Actions profundizamos sobre esto.
Nada crasheó. El refresh cayó justo en la ventana donde docker compose up ya había matado el container viejo y el nuevo todavía no escuchaba. Con el provider estático de Traefik, cada deploy pasa por esa misma ventana. ¿La solución? Un health check activo para que Traefik deje de rutear hasta que el container responda de verdad.
- Endpoint trivial y sin auth. El
/healthtiene que ser una vista mínima (sin tocar la base, sin auth) que devuelva 200. Acoplar la salud de infra a un endpoint de negocio hace que un cambio de permisos te tire el health check. - Host correcto en el check. Sin el header
Hostapuntando al dominio público real, Django rechaza el request comoDisallowedHosty Traefik cree que el container está permanentemente enfermo.
¿Cómo evitar que los containers queden offline tras un reboot?
Agregá restart: unless-stopped a cada servicio de larga duración en el docker-compose. Sin eso, cualquier reboot del droplet (una ventana de mantenimiento del proveedor, un crash) deja todos los containers caídos hasta que alguien lo note y entre por SSH a levantarlos a mano. Con restart: unless-stopped, Docker los revive solo apenas arranca el daemon.
La única excepción es el container de backup one-off, que corre una vez y termina. Todo lo demás (Django, Postgres, Traefik) va con la política de restart. Es un renglón por servicio que te ahorra un incidente de disponibilidad silencioso.
Las 10 fallas apiladas, en una tabla
| Capa | Síntoma | Causa real | Fix |
|---|---|---|---|
| Python setup | ".python-version doesn't exist" | Archivo gitignorado, no llega al checkout | Commitear el archivo |
| Tests frontend | "markAsUncloneable is not a function" | Node dos majors atrás de lo que pide jsdom | Actualizar Node a ^22/^24/>=26 |
| Lint | black/isort/djLint failed | Drift de formateo, hooks no cableados | Aplicar el diff que da CI |
| Cache | Falsos rojos en PRs | Race del merge ref en cache GHA | ignore-error=true en cache-from |
| Corridas dobles | CI corre 2 veces por promoción | Triggers push y pull_request juntos | Dejar solo push a main |
| Secretos | "unable to authenticate [none]" | Los 4 secretos nunca se crearon | Crearlos en Settings |
| Clave SSH | Mismo error tras confiar la pública | PEM colapsado por interpolación de string | Redirección de archivo cruda |
| Ruta deploy | "No such file or directory" | Path copiado de otro proyecto | Corregir con el path real (ls) |
| Traefik | 502 tras deploy verde | Sin health check, rutea a container no listo | healthcheck + Host correcto |
| Reboot | Todo caído tras reinicio | Falta restart policy | restart: unless-stopped |
Errores comunes al debuggear un pipeline
- Asumir que el deploy está roto. Muchas veces el deploy está bien: lo que falla es el CI que lo gatea. Revisá el historial del workflow condicionante antes de tocar el job de deploy.
- Culpar a tu código de test. Un crash tipo
markAsUncloneableno viene de tus tests, viene de una dependencia que exige otra versión de runtime. Leé el mensaje literal antes de reescribir nada. - Re-derivar el formateo a mano. Si
blackodjLintya te imprimieron el diff en CI, aplicalo tal cual. Reinventarlo solo agrega ruido. - Setear secretos multilínea como string. Interpolar un PEM lo mutila silenciosamente. Redirigí siempre el archivo crudo.
Preguntas Frecuentes
¿Por qué mi pipeline está configurado pero nunca corre?
Porque el job que lo gatea (en general el CI) nunca terminó en success. Un deploy con condición workflow_run.conclusion == 'success' queda bloqueado de forma silenciosa mientras el CI referenciado esté en rojo. Abrí el historial de ese workflow y confirmá si alguna vez dio verde. Complementá con configuraciones multi-ambiente del pipeline.
¿Cómo debuggear GitHub Actions si el deploy falla?
Pelá una capa a la vez: arreglá la falla más externa, volvé a correr y confiá en el próximo mensaje de error, por más ajeno que parezca. Cada bug tapa al siguiente, así que un pipeline con diez fallas en serie parece un solo muro. La única salida es fijar el error visible y ver qué revela la capa de abajo.
¿Por qué da "unable to authenticate" en SSH durante el deploy?
Casi siempre porque los secretos SSH no existen o la clave privada se corrompió. El "[none]" en "attempted methods [none]" indica que el cliente no tenía credencial que intentar. Verificá que los secretos estén creados y que el PEM se haya cargado con redirección de archivo, no con interpolación de string.
¿Qué errores de formateo bloquean CI?
Los de black, isort y djLint cuando nadie tiene los git hooks cableados en local. Un import multilínea sin dividir, un archivo sin salto de línea final o un tag sin envolver pasan desapercibidos en tu máquina y fallan en CI. La salida del hook ya trae el diff exacto para aplicar.
¿Por qué un deploy verde igual devuelve 502?
Porque el proxy manda tráfico antes de que la app termine de bootear. Sin un health check activo, Traefik rutea al container apenas se levanta, en la ventana donde el viejo ya murió y el nuevo todavía no escucha. La solución es un healthcheck en el compose con un endpoint trivial y el header Host apuntando al dominio real.
Conclusión
Ninguna de las diez fallas era difícil de arreglar por separado. Lo que convirtió el caso en un via crucis fue que cada bug escondía al siguiente: CI tenía que dar verde antes de que los secretos importaran, los secretos tenían que andar antes de que apareciera la ruta equivocada, y el deploy tenía que correr entero antes de que se viera el problema de timing de Traefik.
Si tu deploy "automático" todavía te pide pull y rebuild a mano, no asumas que el job de deploy está roto: chequeá si lo que lo gatea tuvo éxito de verdad. El fix rara vez es una reescritura grande. Es pelar una capa que falla a la vez, volver a correr, y confiar en el próximo error para que te apunte al siguiente problema real.
Fuentes
- The CI/CD Pipeline That Was Lying to Us - relato original del debugging (dev.to, 1/9/2026)
- GitHub Actions: evento workflow_run y condiciones de gateo (documentación oficial)
- Docker Compose: healthcheck y restart policies (documentación oficial)
- Traefik: health checks de servicios (documentación oficial)






