La optimización parecía obviamente correcta de entrada: no volver a desplegar lo que no ha cambiado. Un pipeline con varias piezas desplegables de forma independiente estaba gastando tiempo y trabajo en cada ejecución redesplegando piezas cuyos archivos no se habían tocado desde la última vez. La detección basada en rutas es un patrón bien conocido y muy usado precisamente para esto: comparar la ejecución actual contra algún punto de referencia anterior, ver qué rutas cambiaron, y disparar solo los pasos de deploy cuyas rutas aparecen en esa comparación. Es una idea lo bastante estándar como para que las plataformas de CI incorporen soporte nativo para ella, y existen actions de terceros pensadas específicamente para hacerla más robusta que una comparación ingenua.
Construimos una versión propia: en cada ejecución, calcular qué había cambiado respecto al último estado conocido, y ejecutar el paso de deploy de un componente solo si sus rutas aparecían en esa comparación. Todo lo demás que no había cambiado se saltaba. En un día normal, con un historial de commits incremental normal, esto funcionaba exactamente como se anunciaba: un cambio en un componente redesplegaba ese componente y dejaba los demás intactos, y el pipeline se volvía perceptiblemente más ligero gracias a ello.
Cuando la comparación no tiene nada contra qué comparar
El modo de fallo apareció en las ejecuciones que no tenían un historial incremental normal contra el que comparar. Un checkout nuevo, o una re-ejecución en un contexto sin estado previo registrado, no tiene nada contra qué comparar el árbol actual. No hay “último estado conocido”: solo hay el estado actual, y punto. Y una comparación contra la nada no produce “todo ha cambiado”, produce nada que reportar como cambiado, porque no hay ninguna base de referencia contra la que comparar en primer lugar. La lógica de detección por rutas preguntaba “qué rutas difieren respecto a la última vez”, recibía una respuesta vacía o sin sentido, y concluía —de forma correcta según su propia lógica— que no había que desplegar nada.
Esa es la trampa de los triggers basados en rutas en general, no solo de la lógica de comparación concreta que habíamos construido: el mecanismo asume que siempre existe una referencia previa con sentido, y hace silenciosamente lo incorrecto en el momento en que esa suposición se rompe. No es un caso límite hipotético exclusivo de nuestro montaje: es una característica documentada de cómo funciona el filtrado de rutas basado en git diff. La propia documentación de GitHub sobre filtros de rutas describe cómo la comparación que evalúa un trigger de workflow se calcula de forma distinta para pushes a ramas nuevas frente a ramas existentes, y detalla comportamientos explícitos para comparaciones grandes y timeouts de comparación, precisamente porque la comparación subyacente puede fallar en silencio a la hora de representar “todo ha cambiado de verdad” justo en las situaciones en las que menos te lo esperarías. Herramientas de terceros construidas sobre el mismo patrón, como la action paths-filter, documentan su propio manejo explícito para el caso en que no hay un ancestro común o no hay un commit anterior contra el que comparar, precisamente porque dejar ese caso sin manejar significa que el filtro no puede distinguir “no ha cambiado nada” de “no había nada que comparar”.
Nuestra versión no tenía ese manejo explícito. Trataba un resultado de comparación vacío como equivalente a “sin cambios”, punto, tanto si ese resultado vacío venía de una ruta genuinamente sin cambios como si venía de no tener ninguna base de referencia. En un checkout nuevo o en una re-ejecución que había perdido el estado, todos los componentes parecían sin cambios, y el pipeline desplegaba correctamente —según su propia lógica interna— absolutamente nada.
La reversión
La solución que finalmente publicamos no fue una comparación más inteligente, ni una rama especial para el caso “sin base de referencia”, ni una vía de respaldo que despliegue todo solo cuando el cálculo de la comparación parece sospechoso. Todas esas son opciones reales, y existen herramientas maduras que implementan exactamente ese tipo de salvaguarda. Nosotros fuimos por otro camino: eliminar por completo la detección por rutas, y desplegar incondicionalmente todos los componentes en cada ejecución. (Este es el lado containerizado de los dos mecanismos de deploy que ejecuta esta infraestructura; un sitio estático nunca tuvo este modo de fallo desde el principio, por razones que se explican en Dos rutas de deploy, dos artefactos.)
Es una compensación real, no una victoria gratuita. Cada ejecución hace ahora estrictamente más trabajo del que hacía la versión con detección por rutas en su mejor día: componentes que no han cambiado en semanas siguen pasando por un ciclo de deploy completo cada vez que se ejecuta el pipeline. En un montaje pequeño, ese trabajo redundante cuesta unos minutos extra y algo de carga adicional sobre el destino del deploy, no un problema real de capacidad, así que aquí merecía la pena hacer la compensación. En una flota más grande, con pasos de deploy caros o lentos, “desplegar siempre todo” sería un golpe mucho más pesado, y la ruta de comparación más inteligente —con una vía de respaldo explícita y probada para el caso sin base de referencia— se ganaría su complejidad.
Lo que hizo que esta fuera una decisión fácil para nosotros específicamente fue en qué consistía realmente el modo de fallo: no un pipeline lento, sino uno que parecía correcto en silencio. Una ejecución que se salta el deploy de algo porque “no detectó cambios” no produce ningún error, ningún paso fallido, nada que parezca merecer investigación: simplemente, en silencio, no publica un componente que debería haberse publicado. Es una categoría de problema mucho peor que el tiempo de cómputo desperdiciado. Un pipeline que es lento pero honesto sobre lo que hizo es algo con lo que se puede convivir y optimizar más adelante. Un pipeline que es rápido pero incorrecto de una forma que parece un éxito es algo que erosiona la confianza en cualquier otro check verde que haya producido alguna vez, porque ahora todo resultado de “deploy correcto, nada que hacer” lleva un interrogante que antes no tenía.
Por qué costó darse cuenta al principio
Parte de lo que hace que merezca la pena escribir sobre esto es lo poco llamativo que parecía el fallo mientras ocurría. No hubo ningún crash, ningún paso fallido, ninguna X roja en ningún sitio de la salida del pipeline. Cada ejecución reportaba éxito, porque según su propia contabilidad interna, había tenido éxito: calculó correctamente una comparación, encontró correctamente que no había rutas coincidentes en esa comparación, y se saltó correctamente los pasos de deploy que su lógica decía que no hacían falta. Cada decisión individual de la cadena era localmente correcta dado su input. Lo único que fallaba era la premisa que alimentaba la primera decisión —que existiera de entrada una comparación con sentido que calcular— y nada más adelante en la cadena estaba en posición de cuestionar esa premisa, porque para cuando se hacía la pregunta de “qué rutas cambiaron”, el pipeline no tenía forma de distinguir “comparé contra una base de referencia real y no encontré cambios” de “no tenía ninguna base de referencia real contra la que comparar”.
Esa es una forma útil de reconocer en general, más allá de este pipeline concreto: una cadena de pasos individualmente correctos, construida sobre una suposición nunca examinada, produce un resultado que parece exactamente un éxito. No hay ningún paso donde la lógica se rompa de forma visible, porque nada en la cadena se rompe: todo funciona exactamente como se diseñó, ante un input que el diseño nunca contempló. Detectar ese tipo de brecha normalmente no depende de un mejor manejo de errores en ningún paso concreto. Depende de que alguien pregunte, antes de confiar en el mecanismo, cuáles son sus suposiciones no dichas y qué pasa cuando la realidad no le entrega ninguna.
Qué fue lo que realmente sacó esto a la luz
La forma en que esto se notó no fue por el pipeline marcando nada; no marcó nada, por diseño, como se ha descrito arriba. Se notó de la forma habitual en la que suele notarse esta clase de problema: alguien esperaba que un cambio desplegado estuviera en producción y no lo estaba, fue a buscar por qué, y lo rastreó a través de los logs del pipeline hasta una ejecución que había reportado éxito sin hacer nada. Es una vía de detección más lenta y manual de lo que debería ofrecer un buen pipeline, y merece la pena nombrarla como su propia brecha: un pipeline de deploy sano debería idealmente hacer que “no desplegó nada” fuera distinguible de “desplegó, no hacían falta cambios” en su propia salida, en lugar de exigir que un operador note una discrepancia entre el estado esperado y el estado real desde fuera. La reversión a desplegar siempre todo arregla el modo de fallo concreto del falso negativo. No arregla, por sí sola, el hábito más amplio de confiar en un pipeline en verde sin una forma independiente de confirmar qué se ha publicado realmente; esa es una mejora aparte que sigue mereciendo la pena hacer, independientemente de qué estrategia de deploy esté funcionando debajo.
Qué reconsideraríamos
Si la detección por rutas vuelve, no volverá a entrar como una simple comparación-y-salta. La brecha real no era la idea de saltarse trabajo sin cambios; eso es una optimización legítima que muchos montajes de CI serios ejecutan de forma segura. La brecha era tratar “no hay comparación contra qué contrastar” y “la comparación muestra que no hay cambios” como la misma señal, cuando no lo son. Una versión digna de confianza necesitaría una comprobación explícita y separada de si existe siquiera una base de referencia válida, con una regla estricta de que la ausencia de esa base fuerce un deploy completo en lugar de uno vacío: la misma forma de salvaguarda que documenta dorny/paths-filter para el caso sin ancestro común. Hasta que esa salvaguarda exista y esté realmente puesta a prueba por un test, desplegar todo, cada vez, es la versión de este pipeline en la que confiamos.
Fuentes consultadas al escribir esto: la documentación de sintaxis de workflows de GitHub describe cómo se calculan las comparaciones de los filtros de rutas y dónde esa comparación se rinde deliberadamente (comparaciones grandes, timeouts de comparación); el README de dorny/paths-filter documenta su manejo explícito para comparaciones sin base de referencia. Nuestro propio pipeline no es GitHub Actions y no usa esa action; el modo de fallo subyacente (una comparación de rutas sin base de referencia contra la que comparar) es genérico del patrón, no específico de ninguna de las dos herramientas.
La lección más amplia que sacamos de esto no es “los triggers basados en rutas son malos”; son una optimización real y muy usada, y las herramientas que los implementan bien claramente han pensado mucho los casos límite que las hacen seguras. Es que adoptar una optimización construida sobre una comparación significa adoptar la responsabilidad de todos los casos en los que esa comparación puede fallar a la hora de representar la realidad, y esa responsabilidad no desaparece solo porque el caso habitual funcione bien en las pruebas. Si volvemos a traer la detección por rutas, el comportamiento de respaldo ante una base de referencia ausente tiene que estar ahí desde el primer día, no descubrirse de la misma forma en que se descubrió esta brecha: después de que algo dejara de publicarse en silencio.