Mudar el CI a un runner propio después de que GitHub rompiera la facturación
El CI de blog-manager llevaba días muerto. No inestable, no lento. Muerto. Cada job en ubuntu-latest fallaba en unos cuatro segundos con una anotación de facturación antes de que corriera un solo paso. Los minutos de GitHub Actions en runners alojados necesitan un método de pago funcional, y en ese momento el propio no lo era, así que todo el flujo de CI simplemente se negaba a arrancar.
La parte que de verdad preocupaba: los despliegues a staging seguían funcionando todo el tiempo. El pipeline de despliegue dependía de que terminara el flujo de build, no del CI. El build ya corría en un runner propio configurado meses atrás por un motivo completamente distinto (el registro de Gitea solo resuelve en la red interna, así que los runners alojados por GitHub no pueden alcanzarlo). Así que los builds seguían corriendo, staging seguía desplegando, y nada estaba probando el código antes. Ese es el tipo de configuración cuya rotura no se nota hasta que se va a buscarla.
Lo que se construyó
La solución fue mudar el resto del CI al mismo runner propio y hacer que el pipeline realmente dependiera de él. En concreto:
- Se fusionaron los cinco jobs viejos de CI (dos escaneos de seguridad, lint, tests, tests de sistema) en el mismo archivo de flujo que el build de Docker, como tres jobs:
checks,test,system-test. buildahora tieneneeds: [checks, test]. Si alguno falla, el build se salta, y el despliegue a staging, que se dispara cuando todo el flujo termina con éxito, nunca se activa.system-testcorre Chrome sin interfaz en un contenedor LXC de un solo núcleo. No había confianza en que fuera confiable desde el primer día, así que llevacontinue-on-error: true, visible si falla, pero sin bloquear nada todavía. Eso se cambiará una vez que demuestre estabilidad en unas cuantas corridas.
Decisiones tomadas y por qué
Descartar por completo la maquinaria de caché de GitHub alojado en vez de intentar preservarla. Los jobs viejos usaban ruby/setup-ruby con bundler-cache: true y un paso de actions/cache para RuboCop. Ambos existen para compensar que los runners alojados por GitHub son efímeros, cada job arranca desde una VM en blanco, así que hace falta traer una caché para no reinstalar todo. El disco de un runner propio ya es persistente. Las gemas de la corrida anterior simplemente… siguen ahí. Así que se borró todo eso y se dejó que bundle install corriera contra el directorio de gemas ya poblado del runner.
Esto causó un problema casi de inmediato, de una forma inesperada. bundler-cache: true no solo cachea gemas, también pone a Bundler en un modo que falla ruidosamente si Gemfile.lock no coincide con Gemfile. Quitarlo a favor de un bundle install a secas hace que esa protección desaparezca en silencio; Bundler simplemente vuelve a resolver y sigue adelante. Esto solo se detectó porque se buscó el comportamiento equivalente. Se solucionó con BUNDLE_FROZEN=true bundle install en su lugar, mismo efecto, sin depender del andamiaje de los runners alojados por GitHub.
Preinstalar solo lo que el runner realmente necesita una vez, no lo que cada job insiste en reinstalar. Los jobs viejos test y system-test corrían sudo apt-get install libvips / chromium como un paso, en cada corrida, porque en una VM nueva esa es la única opción. Se instalaron ambos como paquetes de sistema de una sola vez en el host del runner y se borraron los pasos de apt del flujo. Es un detalle menor, pero sigue el mismo tema: buena parte de lo que hace el CI alojado por GitHub es compensar no tener memoria entre corridas.
Lo que sorprendió
La primera corrida real de CI en semanas se convirtió en una auditoría de seguridad inesperada. bundler-audit falló con una pila de CVEs de nokogiri nunca vistos localmente, porque la base de datos de avisos local estaba desactualizada y la corrida fresca en el runner propio trajo la actual. Después de actualizarla localmente y volver a revisar, aparecieron cuatro más detrás: msgpack, faraday (uno de severidad alta), crass, concurrent-ruby. Nada de esto lo causó la migración del CI, las dependencias simplemente se habían ido desactualizando en silencio durante las semanas en que el CI no podía correr para detectarlo. Actualizar las cinco fue un pequeño commit aparte antes de siquiera poder volver a verificar los cambios del pipeline. Es un argumento bastante directo de por qué todo el ejercicio importaba: el portón había estado apagado, y ya había dejado pasar vulnerabilidades reales, aunque aburridas.
La otra sorpresa fue casi un no evento, y hubo alivio en haber revisado en vez de asumir. Mientras se rastreaba cómo se dispara deploy-staging, surgió un candidato a bug que parecía serio: ¿el disparador workflow_run de GitHub coincide con la rama base de un pull request o con su rama de origen? Si fuera la rama base, entonces cada PR en verde contra main dispararía espontáneamente un despliegue a staging usando un commit que nunca se subió realmente al registro. La documentación es genuinamente ambigua en este punto. En vez de confiar en la intuición o en un resultado de búsqueda, se revisó el historial real de corridas del flujo de despliegue después de dos corridas de PR en verde ese mismo día. No se disparó nada. Solo se activa con pushes a main, exactamente como se pretendía. Buen recordatorio de que, para cualquier cosa con un radio de impacto real, “la documentación dice” y “es bastante probable” son ambos más débiles que simplemente mirar lo que pasó de verdad.
Lo que sigue
Los tests de sistema necesitan unas cuantas corridas limpias antes de confiar en ellos lo suficiente como para volverlos bloqueantes. Y el runner mismo sigue configurado a mano, Ruby vía mise, el binario del runner, y ahora libvips y chromium, todo instalado entrando por SSH y escribiendo comandos. Eso está bien para una sola máquina levantada una vez, pero significa que reconstruirla desde cero implicaría rehacer todo eso de memoria. Se registró un pendiente para codificarlo en Ansible.
Lecturas relacionadas
Un manual de fallas de CI para un proyecto Rails de una sola persona
Escribir las reglas de qué hacer cuando el CI se pone en rojo en un proyecto Rails en solitario, y la limitación de GitHub que convirtió la puerta de fusión en un comentario que sostiene todo el peso.
Desplegando Rails 8 a staging automáticamente con Kamal y un runner autoalojado de GitHub Actions
Hacer que cada merge despliegue staging automáticamente: un runner autoalojado, cuatro obstáculos seguidos, y la trampa de los secretos de Kamal que más costó resolver.
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.