Checks de CI que los linters no pueden hacer: contratos de API, patrones móviles, ratchets de warnings y tests que fijan bugs de Stripe
ESLint estaba en verde. PHPStan estaba en verde. TypeScript compilaba. La suite de tests pasaba. Y el botón “Pagar en línea” de cada factura que Zenpresa envió durante varios meses no hacía nada al pulsarlo, porque la ruta a la que apuntaba ese botón nunca se había registrado. El controlador existía. El frontend llamaba a la ruta correcta. Ninguna herramienta del pipeline miraba ambos lados a la vez.
Este artículo trata de los checks que añadimos a la integración continua después de bugs como ese: bugs sintácticamente perfectos, correctos en tipos y equivocados. Ninguno es un linter. Todos se ejecutan en cada pull request, en el mismo workflow de GitHub Actions que los linters, y cada uno existe porque un bug concreto llegó a producción.
El check de contrato de API
El bug del pago de facturas tenía una forma simple. La app React tenía una función que construía una URL para el endpoint de pago en línea. El backend Laravel tenía un método de controlador que lo manejaba. El archivo de rutas que debía unirlos tenía la línea de un endpoint de pago distinto, y los dos se habían desalineado durante un refactor. Cada capa era internamente consistente; la brecha estaba entre capas.
La solución fue un script que mira ambos lados. Recorre el cliente de API del frontend y extrae cada ruta que puede llamar. Le pide al backend que liste sus rutas registradas. Luego las compara: cada ruta que el frontend llama debe resolver a una ruta registrada con un método compatible, y cada ruta destinada al frontend debe tener al menos un llamador o una anotación explícita de “usada externamente”.
El script tiene menos de doscientas líneas y se ejecuta en segundos. No es un framework completo de contract testing, y deliberadamente no intenta validar la forma de las peticiones o respuestas; para eso están los clientes de API tipados y los feature tests. Responde a una sola pregunta: ¿apunta cada botón a algo que existe? En los meses transcurridos ha hecho fallar pull requests cuatro veces, cada una por una ruta que de otro modo habría llegado muerta a producción.
El check de patrones móviles
Dos defectos llegaron repetidamente a producción en móvil, y ambos eran JSX válido al que ningún linter podía objetar.
El primero fue el recorte por el contenedor. Una tabla de datos dentro de un contenedor con el overflow horizontal oculto, en una pantalla estrecha, pierde silenciosamente sus columnas de la derecha. Los botones de acción de facturas y citas viven en la columna más a la derecha. En un teléfono, no existían. Esto llegó a producción tres veces en tres tablas distintas, porque cada vez era un componente nuevo escrito por alguien que probaba en un portátil.
El segundo fueron los controles solo con hover. Una fila que revela sus botones de editar y eliminar al pasar el cursor es un patrón normal de escritorio. Las pantallas táctiles no tienen hover. Los botones nunca aparecían, y la funcionalidad era invisible para todos los usuarios en un teléfono, que para el dueño de un negocio de servicios entre cita y cita son la mayoría.
El check es un pequeño script que parsea los archivos de componentes del frontend y marca dos patrones: una tabla o un elemento ancho dentro de un contenedor cuyas clases ocultan el overflow horizontal sin un contenedor de scroll interno, y un elemento cuyo único disparador de visibilidad es una variante hover sin fallback de focus o siempre visible. Es una heurística, tiene una lista de permitidos para el puñado de casos legítimos y produce falsos positivos de vez en cuando. También ha atrapado ambos bugs antes del merge todas las veces desde que se añadió, que es la única métrica que importa.
El ratchet de warnings de lint
El job de lint del frontend antes fallaba con cualquier error e ignoraba los warnings. Eso significaba que los warnings se acumulaban, porque nada los frenaba. Cuando probamos la solución obvia, fallar con cualquier warning, el job se puso en rojo en la primera ejecución con un conteo de cientos, nadie tenía tiempo para limpiarlos, y en una semana el job estaba marcado como “se espera que falle” e ignorado por completo. Cero era el objetivo equivocado.
El ratchet es el correcto. El workflow registra el conteo actual de warnings en un archivo, con la fecha en que se fijó. El job de lint falla si el conteo sube. Cualquiera puede bajar el número cuando limpia warnings, y bajarlo se fomenta en la plantilla de pull request. Nadie puede subirlo. El conteo ha caído de forma constante desde entonces, no porque alguien programara una limpieza, sino porque cada ingeniero que toca un archivo con un warning tiende a arreglarlo mientras está ahí, y el ratchet garantiza que ese trabajo nunca se deshace.
El mismo ratchet se aplica al baseline de PHPStan en el backend y al conteo de comentarios @ts-expect-error. El principio es general: cuando el estado actual está lejos del objetivo, pon la puerta en la dirección del avance, no en el destino.
Fijar bugs de destination charges de Stripe con tests
Zenpresa usa Stripe Connect para que los clientes paguen directamente al negocio y la plataforma cobre una comisión. Tres bugs llegaron a producción en esa integración, cada uno sutil, cada uno fijado ahora por un test que falla si alguna vez vuelve.
Acotar Stripe.js a la cuenta conectada. Cuando un cliente paga una factura, el elemento de pago en el navegador debe inicializarse para la cuenta conectada del negocio, no la de la plataforma. Inicializado para la plataforma, el pago tiene éxito y el dinero cae en el lugar equivocado. El test renderiza la página de pago para un tenant con cuenta conectada y verifica que la inicialización del lado del cliente lleva el identificador de la cuenta conectada.
Webhooks sin campo de cuenta. Stripe envía los eventos de cuentas conectadas con un campo que identifica a qué cuenta pertenecen. Los eventos de la propia cuenta de la plataforma no llevan ese campo. El manejador de webhooks asumía que el campo siempre estaba presente, y los eventos a nivel de plataforma hacían caer al manejador, lo que significaba que se perdían los eventos de suscripción de la propia plataforma. El test reproduce ambos tipos de evento a través del manejador y verifica que ambos se procesan.
Reembolsos con alcance de plataforma. Reembolsar un destination charge desde el contexto de la plataforma, en lugar del de la cuenta conectada, produce un error o, peor, un reembolso contra el balance equivocado. El test emite un reembolso para un cargo de cuenta conectada y verifica que la petición se hace con la cuenta conectada en el alcance.
La lección mayor de esos tres bugs reformó cómo funciona el flujo de pago. El webhook es la fuente de verdad. Cuando Stripe dice que un pago tuvo éxito, ese es el evento que marca la factura como pagada. El callback de “pago confirmado” del navegador, que llega primero y parece autoritativo, se trata solo como un acelerador: dispara una comprobación que puede marcar la factura como pagada antes, pero nada depende de que llegue. Ambos caminos convergen en un único compare-and-set: marcar como pagada solo si actualmente está impagada, en una sola sentencia atómica, de modo que el acelerador y el webhook pueden competir y la factura sigue marcándose como pagada exactamente una vez. Un test ejecuta ambos caminos en ambos órdenes y verifica una única transición.
Prueba la rama que despliega
La primera versión del workflow se ejecutaba en pull requests y en pushes a la rama principal. Staging desplegaba desde otra rama, y esa rama no estaba en la lista de triggers. Durante un tiempo, staging corría código que CI nunca había visto, porque un hotfix empujado directamente a la rama de staging se saltaba todos los checks.
Esto es vergonzosamente simple de arreglar y vergonzosamente común. El workflow ahora se dispara en cada rama desde la que despliega cualquier entorno, y un comentario en la lista de triggers nombra el entorno que alimenta cada rama. Si una rama despliega, se prueba. No hay excepciones, ni siquiera para la persona que escribió el workflow.
Dónde vive cada check
El pipeline son once jobs, y los checks anteriores encajan en él en lugar de añadir nuevas etapas de nivel superior:
- Tests de backend, en una matriz de versiones de PHP, incluidos los tests de Stripe y los tests de aislamiento de tenants del artículo sobre base de datos por tenant. Los tests de caminos infelices del asistente de IA del artículo sobre seguridad de herramientas también corren aquí.
- Estilo de código del backend y análisis estático del backend, este último con el baseline con ratchet.
- Type check del frontend, lint del frontend con el ratchet de warnings y el check de patrones móviles, tests del frontend y build del frontend. El check de contrato de API se ejecuta en el job de build, porque necesita tanto el frontend construido como la lista de rutas del backend.
- Widgets embebibles, con type check y build por separado porque se envían a páginas de terceros.
- Validación del build de Docker, que construye la imagen de producción igual que lo hace el deploy y falla si el Dockerfile se ha desalineado de lo que la app necesita.
- Auditoría de dependencias contra vulnerabilidades conocidas en los árboles de Composer y npm.
- Pruebas estáticas de seguridad de aplicaciones sobre todo el repositorio.
Todo excepto la matriz de tests del backend termina en unos pocos minutos. El pipeline completo es la puerta para hacer merge a cualquier rama que despliegue. La estructura sigue el patrón de nuestro artículo sobre CI/CD para un monorepo de SPA en React; los jobs de cadena de suministro siguen el artículo sobre seguridad de la cadena de suministro de software; y el artículo sobre cómo proteger los flujos de trabajo de agentes de código con IA explica por qué el mismo pipeline es la puerta para el código escrito por agentes, no solo por humanos.
Costo frente a cobertura
Cada check anterior es barato de ejecutar y fue caro no tenerlo. El check de contrato y el check de patrones móviles juntos tardan menos de un minuto. El ratchet es una comparación de archivos. Los tests de Stripe corren contra un proveedor falso y tardan segundos.
Lo que cuestan es la escritura: unas pocas horas cada uno, normalmente la tarde después de encontrar el bug, mientras la forma del fallo aún estaba fresca. Ese es el patrón que recomendaríamos a cualquier equipo. Cuando llega a producción un bug que el pipeline debería haber atrapado, no te limites a arreglar el bug. Pregúntate qué check lo habría atrapado, escribe ese check el mismo día y haz que falle en el commit anterior al arreglo. Un pipeline construido así es un registro de todas las formas en que el producto realmente se ha roto, y no deja que lo mismo se rompa dos veces.
La serie que empezó con el lanzamiento de Zenpresa es la historia de un equipo pequeño que lanza una plataforma con mucha superficie: muchos tenants, muchos módulos, un agente que puede actuar y pagos que tienen que ser correctos. El pipeline es la manera en que un equipo pequeño consigue dormir. Si estás construyendo algo con una forma similar, cada uno de los checks de este artículo es una buena tarde de trabajo, y el producto que protegen tiene una prueba gratuita abierta si quieres ver qué es lo que mantienen unido.
FAQ: checks de CI más allá del linting
¿Qué es un check de contrato de API en CI?
Un script que extrae cada ruta de endpoint que el frontend puede llamar, lista las rutas registradas del backend y hace fallar el build si alguna llamada del frontend no tiene ruta correspondiente o alguna ruta destinada al frontend no tiene llamador. No valida la forma de las peticiones ni de las respuestas; solo garantiza que cada botón apunta a algo que existe. Existe porque un botón de pago llegó a producción apuntando a una ruta no registrada.
¿Cómo se aplican automáticamente patrones aptos para móvil en React?
Con un script heurístico sobre los archivos de componentes que marca dos patrones: elementos anchos dentro de contenedores que ocultan el overflow horizontal sin un contenedor de scroll interno, y controles cuyo único disparador de visibilidad es una variante hover sin fallback de focus o siempre visible. Tiene una lista de permitidos y falsos positivos ocasionales, y ha atrapado ambos bugs reales antes del merge todas las veces desde que se añadió.
¿Qué es un ratchet de warnings de lint?
Un conteo de warnings registrado contra el que se compara el job de lint. El build falla si el conteo sube; cualquiera puede bajarlo. Reemplaza una puerta de “cero warnings” que era inalcanzable y por tanto ignorada. El mismo enfoque se aplica a los baselines de análisis estático y a los comentarios de supresión. Pon la puerta en la dirección del avance cuando el destino está lejos.
¿Cómo debería decidir un flujo de pago que una factura está pagada?
Trata el webhook del proveedor de pagos como la fuente de verdad y el callback de confirmación del navegador como un acelerador que puede marcar la factura como pagada antes pero del que nada depende. Ambos caminos convergen en un único compare-and-set atómico para que la factura se marque como pagada exactamente una vez sin importar cuál llegue primero. Fija cada bug que haya llegado a producción con un test que reproduzca el escenario exacto.
¿Por qué CI debería correr en las ramas de staging y no solo en la rama principal?
Porque cualquier rama que despliega a algún sitio es producción para alguien. Si staging despliega desde una rama en la que CI no se dispara, los hotfixes empujados a ella se saltan todos los checks. Lista cada rama que despliega en el trigger del workflow y nombra el entorno que alimenta cada una.
Artículos Relacionados
React SPA Monorepo CI/CD: cómo automatizar pruebas y desplegar solo lo que cambió
Aprende cómo funciona el CI/CD de un monorepo React SPA con 7 puertas de validación, pruebas E2E con Playwright, detección inteligente de cambios, despliegues selectivos y flujos de staging a producción.
Seguridad de herramientas de agente: inyección de prompt, códigos de confirmación fuera de banda y claves de idempotencia para herramientas que mueven dinero
Cómo Zenpresa evita que engañen a su asistente de IA para emitir reembolsos o envíos masivos: modelo de amenazas, códigos fuera de banda e idempotencia.
Construyendo un asistente agéntico en Laravel con el SDK de PHP de Anthropic: herramientas, model lanes, prompt caching y medición por tenant
El asistente de IA de Zenpresa sobre el SDK de PHP de Anthropic en Laravel: contrato de herramientas, model lanes, prompt caching, SSE y medición por tenant.