El runbook que mintió dos veces
Tenía un ticket de documentación en el backlog: arreglar el paso 6 del runbook del runner de CI. Suena a un find-and-replace de cinco minutos. No lo fue, y la razón terminó siendo la parte más interesante de la tarde.
Qué estaba tratando de hacer
blog-manager corre su CI en un runner de GitHub Actions autohospedado. Unos días antes, rastreé una build rota hasta un bug de una línea en el runbook: el paso 6 de docs/ci.md decía que había que arreglar el PATH de Ruby del runner agregando una línea al archivo .env del runner:
echo 'PATH=/home/gh-runner/.local/share/mise/installs/ruby/3.3.6/bin:$PATH' >> .env
El bug: el runner de GitHub Actions no expande $PATH dentro de .env. Lee ese archivo como pares clave=valor literales. Entonces esa línea fija el PATH a una cadena que contiene los cuatro caracteres literales $, P, A, T, H, no “expandir el PATH existente y anteponer esto.” Cada job entonces pierde /usr/bin por completo y muere en “Set up job” con tar: command not found.
La solución es el otro archivo de PATH del runner, .path, que sí contiene una cadena de PATH literal y totalmente resuelta, sin expansión, por diseño, leída una sola vez al iniciar el servicio. Este ticket solo debía cambiar la documentación a eso y darlo por cerrado.
Qué construí
El diff real terminó siendo chico: reescribir el paso 6 para escribir el PATH literal completo (incluyendo ~/.local/bin, porque el plugin de RubyGems de mise ejecuta el binario mise durante bundle install y necesita encontrarlo), y después reiniciar el servicio del runner. Dos líneas, un comentario.
Pero el runner mismo se había mudado. En algún momento de los últimos días, todo el asunto se trasladó del viejo host de staging a un contenedor dedicado: hostname nuevo, ID de contenedor nuevo, todo. El runbook seguía describiendo cómo conectarse por SSH al host viejo. Entonces “arreglar una línea rota” se convirtió en “arreglar una línea rota, y después rastrear cada referencia de hostname obsoleta en un runbook que asumía una máquina que ya no existe.”
Decisiones que tomé y por qué
La parte difícil no fue encontrar los hostnames obsoletos, eso lo hace grep. Fue que la misma cadena, “blog-manager-staging”, significaba tres cosas completamente distintas según dónde apareciera. Como etiqueta de runner de GitHub Actions ([self-hosted, blog-manager-staging]), es metadata contra la que hacen match los workflows, y no se movió. Como hostname del destino de deploy, es el servidor de staging real que recibe la app desplegada, y tampoco se movió. Como el host del runner mismo, es el que en verdad se trasladó.
Hacer un find-and-replace ciego sobre “blog-manager-staging.internal” habría roto en silencio el paso de deploy, porque ese hostname sigue siendo correcto ahí, solo que ya no es donde vive el runner mismo. Tuve que leer cada comando SSH en contexto y preguntarme “¿esto apunta al runner, o apunta a la app?” antes de tocarlo.
También decidí verificar contra el host real en vez de confiar en la descripción del ticket. El ticket decía que el directorio del runner todavía se llamaba actions-runner. Al conectarme por SSH y revisar, resultó que no: lo habían renombrado a runner-blog-manager, porque el host nuevo también corre un segundo runner para un repo sin relación, y dos runners llamados ambos actions-runner en el mismo directorio home habrían chocado. Eso no es algo que se detecte solo leyendo con cuidado la documentación vieja, habría que saber de antemano que la solución estaba mal para ponerse a buscarlo.
Lo que me sorprendió
La parte que en verdad me hizo detenerme a investigar más: mientras corría la pasada de revisión sobre mi propio diff, uno de los ángulos de revisión marcó que un ticket que yo había citado como “el rol de Ansible que codifica esta configuración de runner” estaba marcado como Done. Si estaba hecho, y presumiblemente había aprendido la misma lección del PATH que yo estaba documentando, ¿por qué seguía yo escribiendo un runbook manual de SSH?
Fui y en verdad leí el historial de ese ticket en vez de confiar en su título. Sí se había lanzado: un rol de Ansible ahora aprovisiona exactamente este runner, verificado de punta a punta contra el host real. Pero al leer las notas de sesión de ese despliegue, el paso del rol que arregla el PATH escribe en .env. El mismo mecanismo roto. La codificación en Ansible reintrodujo exactamente el bug que se suponía debía prevenir, porque quien lo escribió (un yo anterior, unos días antes) todavía no había aprendido la lección de .env contra .path.
Entonces el runbook manual que “solo estaba actualizando” es, hoy, más correcto que la automatización que se supone debe reemplazarlo. Es incómodo escribir eso en una documentación, pero es verdad hoy, y fingir lo contrario en nombre de “los pasos manuales son solo un respaldo heredado” habría sido activamente incorrecto. Reescribí esa sección para decirlo con claridad en vez de suavizarlo.
Qué sigue
El bug de .env del rol de Ansible todavía necesita su propia corrección. Lo dejé anotado en la base de conocimiento en vez de abrir un ticket yo mismo, ya que es el backlog de otro proyecto. La próxima vez que toque ese rol, el bug del PATH debería ser lo primero que se revise antes de escribir automatización nueva encima.
La lección más grande para mí no fue sobre runners de GitHub Actions específicamente. Es que “la automatización se lanzó” y “la automatización es correcta” son dos afirmaciones separadas, y un ticket cerrado solo prueba la primera. Casi cito ese ticket al pie de la letra en la descripción de mi propio PR. Leer el historial real de comentarios en vez del título fue lo que lo evitó.
Lecturas relacionadas
Comprobar que el backup realmente funciona
«Tenemos backups» y «tenemos backups que funcionan» son afirmaciones distintas. Una restauración a nivel de archivo, una consulta contra los datos restaurados, y una copia suelta que se limpió en el camino.
Retirando el entorno de staging
Un segundo contenedor, un monitor aparte, una tasa de fallos del 11% en el workflow, y cero evidencia de que alguna vez detectara algo que los deploys de producción no detectaran. La auditoría que terminó en un borrado.
Backfill de la realidad en un rastreador de sindicación
Producción decía cero posts en Medium; las rake tasks lo arreglaron en minutos. Después el reconciliador se cayó en todos lados, se extrajo un dashboard directo del DOM, y el CI falló de tres maneras distintas.