CI: matriz de versiones, linter y formateador
Ejecutar la suite en GitHub Actions sobre varias versiones de Neovim, decidir qué versiones sostener y cuál puede romperse sin bloquear, cachear dependencias, y añadir análisis estático con luacheck o selene, formato con stylua y comprobación de tipos con el servidor de lenguaje de Lua.
Una suite que solo corre en tu máquina prueba una cosa: que tu máquina funciona. El valor real aparece cuando se ejecuta en un entorno que no controlas, sobre varias versiones del editor y ante cada propuesta de cambio, porque entonces deja de ser una comprobación y pasa a ser un contrato: esto es lo que mi plugin promete, en estas versiones, y aquí está la prueba automática de que lo cumple. A eso se le añade una segunda capa que atrapa una clase de defectos distinta y más barata todavía —variables globales sin declarar, argumentos que sobran, funciones inexistentes— y una tercera que elimina de raíz una categoría entera de discusiones: el formato.
- Diseñar una matriz de versiones que refleje una promesa de compatibilidad explícita y sostenible.
- Escribir un flujo de trabajo que instale Neovim y las dependencias con caché y termine con el código de salida correcto.
- Añadir análisis estático de Lua consciente de la tabla
vimy comprobación de formato no negociable. - Ordenar las etapas para que el fallo llegue pronto y con un mensaje que se entienda sin abrir los registros.
La matriz: una promesa, no una lista
Elegir versiones parece administrativo y es la decisión de producto más importante del capítulo, porque cada entrada de la matriz es una promesa que tendrás que sostener durante años. Tres columnas cubren casi todos los casos con proporciones muy distintas de coste y beneficio.
La versión estable es innegociable: es la que usa la mayoría y un fallo ahí es un fallo real. La versión mínima soportada es la que declaras en tu documentación, y si no la pruebas no la soportas, solo lo esperas; el error más frecuente de todo el ecosistema es usar en el código una función incorporada en una versión posterior a la que se anuncia como mínima. La versión de desarrollo es distinta en naturaleza: sirve como aviso temprano de que una API va a cambiar, y por eso no debe bloquear la fusión de nada.
strategy:
fail-fast: false # que un fallo no cancele las demas: quieres el cuadro completo
matrix:
neovim: [v0.9.5, stable, nightly]
include:
- neovim: nightly
experimental: true
Una versión de desarrollo que falla y bloquea la fusión entrena al equipo a ignorar el rojo, que es el peor resultado posible. Márcala como tolerante a fallo, deja que informe, y trátala como lo que es: una alerta con semanas de antelación sobre un cambio que llegará a la estable.
El flujo de trabajo
La estructura mínima que funciona tiene cuatro pasos: obtener el código, instalar el editor en la versión de la matriz, colocar las dependencias de prueba, y ejecutar la suite en modo headless propagando el código de salida.
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10 # sin esto, un proceso colgado consume la cuota
strategy:
fail-fast: false
matrix:
neovim: [v0.9.5, stable, nightly]
steps:
- uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1
with: { neovim: true, version: "${{ matrix.neovim }}" }
- uses: actions/cache@v4
with:
path: .tests/site
key: deps-${{ runner.os }}-${{ hashFiles('tests/minimal_init.lua') }}
- name: Instalar plenary
run: |
mkdir -p .tests/site/pack/deps/start
test -d .tests/site/pack/deps/start/plenary.nvim || git clone --depth 1 \
https://github.com/nvim-lua/plenary.nvim .tests/site/pack/deps/start/plenary.nvim
- name: Ejecutar la suite
env:
XDG_DATA_HOME: ${{ github.workspace }}/.tests/data
XDG_STATE_HOME: ${{ github.workspace }}/.tests/state
run: |
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"
Dos detalles deciden si esto es fiable o si te dará disgustos. El primero es el tiempo máximo del trabajo: sin él, un proceso sin interfaz que no termina consume la cuota de la organización durante horas antes de rendirse. El segundo es el código de salida: el ejecutor de plenary sale con un valor distinto de cero cuando algún caso falla, y ese valor es lo único que el sistema mira; si envuelves la invocación en una tubería o en un guion que no lo propaga, tendrás una suite que informa fallos en verde, que es peor que no tener suite.
flowchart TD A[Propuesta de cambio] --> B[Formato con stylua en modo comprobacion] B --> C[Analisis estatico con selene o luacheck] C --> D[Suite sobre la matriz de versiones] D --> E[Version minima soportada] D --> F[Version estable] D --> G[Version de desarrollo tolerante a fallo] E --> H[Fusion permitida] F --> H style B fill:#89b4fa,color:#11111b style G fill:#f9e2af,color:#11111b style H fill:#a6e3a1,color:#11111b
Análisis estático y formato
El análisis estático de Lua tiene una particularidad en este dominio: la tabla vim es una global que ninguna herramienta conoce de antemano, así que sin configurarla obtendrás cientos de avisos inútiles sobre una variable no definida y acabarás desactivando la herramienta entera.
Hay dos linters vigentes. luacheck es el veterano, escrito en Lua, exhaustivo y algo lento. selene está escrito en Rust, es mucho más rápido y describe las bibliotecas estándar en archivos declarativos, entre ellos uno específico para Neovim.
# selene.toml
std = "luajit+neovim"
[rules]
mixed_table = "warn"
unused_variable = "warn"
-- .luacheckrc: la alternativa clasica, con la tabla vim declarada a mano
std = "luajit"
globals = { "vim" }
ignore = { "212/self", "631" } -- argumento self sin usar, linea demasiado larga
El formateador es una decisión distinta y más tajante. stylua reformatea el código según una configuración corta y en integración continua se ejecuta en modo comprobación, de modo que falla si algún archivo no está exactamente como el formateador lo escribiría. Eso convierte el estilo en algo que nadie discute nunca más, porque deja de ser una opinión y pasa a ser una propiedad verificable.
lint: # trabajo aparte: no se repite por cada version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with: { version: latest, args: "--check lua/ tests/" }
- run: |
curl -sL https://github.com/Kampfkarren/selene/releases/latest/download/selene-linux.zip \
-o selene.zip && unzip -q selene.zip && chmod +x selene && ./selene lua/
Queda una tercera capa que poca gente añade y que atrapa los errores más caros: el servidor de lenguaje de Lua puede ejecutarse como comprobador en lote y, alimentado con las anotaciones de tipo de la API de Neovim, detecta llamadas con el número de argumentos equivocado, campos inexistentes en tablas y valores de retorno mal usados. Es análisis de tipos sobre un lenguaje dinámico, y encuentra en segundos lo que una suite descubriría solo si ese camino concreto tuviera un caso escrito.
Formato
No opina, decide. En modo comprobación elimina una categoría entera de comentarios de revisión.
Análisis estático
Globales sin declarar, variables muertas, sombras accidentales. Coste casi nulo, cobertura total del código.
Tipos con anotaciones
El servidor de lenguaje en lote verifica firmas de la API sin necesidad de ejecutar esa ruta.
Ordenar para fallar pronto
El orden de las etapas no es cosmético: determina cuánto tarda alguien en enterarse de que se equivocó. Lo barato y universal va primero —formato y análisis estático tardan segundos y cubren todo el código—, y lo caro y parcial va después. Además, separar el análisis en un trabajo propio evita repetirlo una vez por cada entrada de la matriz, que es un desperdicio silencioso muy común.
Tres refinamientos elevan un flujo correcto a uno agradable de usar. Ejecutar el formateador y el linter también antes de confirmar, mediante ganchos locales, para que el rojo remoto sea excepcional en lugar de rutinario. Publicar la salida de los fallos como un resumen legible en la propia propuesta de cambio, en vez de obligar a desplegar registros. Y cancelar automáticamente las ejecuciones anteriores de la misma rama cuando llega un cambio nuevo, porque nadie necesita el resultado de un estado que ya fue reemplazado.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Merece la pena mirar lo que acabas de construir desde una altura mayor que la de la comodidad, porque su efecto real no es el que promete el nombre. Un flujo de trabajo no es una comprobación automática de lo que ya sabías; es la primera definición ejecutable y falsable de tu plugin como producto. Hasta este punto, la respuesta a qué versiones soporta era una frase en el archivo de presentación, es decir, una intención revisada la última vez hace ocho meses y contradicha por una llamada a una función que no existía entonces. A partir de este punto, esa respuesta es una lista de entradas de matriz que o bien están en verde o bien no lo están, y la diferencia entre ambas situaciones no es de rigor sino de naturaleza: has convertido una creencia en un hecho comprobable por cualquiera, incluido tú dentro de un año, cuando ya no recuerdes por qué escribiste ese guardia. Y de ahí sale la consecuencia que cambia cómo se comporta la gente alrededor del proyecto. La barrera principal para que alguien contribuya a un plugin ajeno nunca fue técnica, fue de miedo justificado: quien envía un cambio a un código que no conoce sabe que puede romper algo que no entiende, y no tiene forma de averiguarlo sin instalar el entorno del autor. Un flujo que se ejecuta solo, sobre tres versiones, en diez minutos y con un mensaje comprensible, elimina ese miedo por completo y traslada la carga del revisor a la máquina: el revisor deja de comprobar si funciona y pasa a discutir si es buena idea, que es lo único que un humano aporta mejor que un proceso. Por eso la integración continua es, en un proyecto pequeño y abierto, la decisión con mayor rendimiento sobre el tiempo invertido de todas las que se han descrito en este nivel. No hace tu código mejor por sí sola. Hace que el coste de comprobar sea tan bajo que la gente se atreva a mejorarlo.
- Escribe el flujo de trabajo con las tres versiones y verifica que la de desarrollo puede fallar sin bloquear la fusión.
- Añade el formateador en modo comprobación, ejecútalo sobre tu código actual y observa cuántos archivos no lo cumplen.
- Configura el analizador estático con la biblioteca estándar de Neovim y corrige o silencia de forma justificada cada aviso restante.
- Introduce a propósito una llamada a una función que solo existe en la versión estable y comprueba que la entrada de versión mínima falla.
- Añade la cancelación de ejecuciones anteriores y un gancho local previo a la confirmación. Mide cuánto baja el tiempo medio hasta conocer un fallo.