Saltar al contenido

Pipelines programados y E2E: señal sin bloquear merges

La suite de E2E es demasiado lenta y demasiado inestable para bloquear merges, pero la señal la necesitas igual, junto con un lugar donde ver su historial. Pipelines programados, service tokens para un entorno con puerta de acceso, sharding de Playwright y un archivo de reportes que sobrevive a sus pipelines.

7 min de lectura
  • gitlab
  • ci-cd
  • devops

Llevamos diez partes y todos los pipelines de la serie arrancan igual: alguien hizo push. El lint, el build, el deploy, incluso el trigger entre repos de la parte 7; todo reacciona a un commit que aterriza en algún lado. La suite de E2E es lo primero que no encaja en ese molde. Maneja un navegador real contra un staging real protegido por una puerta de acceso, cruza una red real, tarda lo suficiente como para que nadie la espere sentado en un merge request, y cualquier día normal unas cuantas de sus fallas son culpa de la red y no del código.

Tampoco era opción apagarla: encuentra la categoría de bug que ningún otro job del pipeline puede ver. Lo que esta suite necesitaba era un reloj, no un trigger. Correr cada noche, correr bajo demanda cuando alguien lo pida, y no interponerse jamás entre una persona y su merge.

Un job que corre con reloj

Los schedules no se definen en .gitlab-ci.yml. Viven en la UI, en Build → Pipeline schedules: una expresión cron, una rama objetivo y, si quieres, variables propias de ese schedule. Cuando el cron dispara, GitLab inicia un pipeline normal en esa rama con CI_PIPELINE_SOURCE igual a schedule, y a partir de ahí son tus rules (las de la parte 5) las que deciden qué corre:

e2e:
  stage: e2e
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_PIPELINE_SOURCE == "web"
      when: manual
      allow_failure: true
  script:
    - pnpm exec playwright test

La primera cláusula es la corrida nocturna. La segunda es la salida de emergencia: si alguien lanza un pipeline a mano desde la UI (source web), el job aparece como un botón manual, así que cualquiera puede disparar la suite justo después de arreglar algo, sin esperar a las 2 a. m. Los pipelines de push no cumplen ninguna de las dos condiciones, de modo que los merges del día a día ni se enteran de que este job existe.

Hay una segunda salida de emergencia dentro de los propios schedules: cada uno tiene un botón de ejecutar ahora en su página, limitado a una vez por minuto, y corre con los permisos de quien lo presiona, no con los del dueño del schedule. Y como las variables se pueden definir por schedule, un solo archivo YAML sirve para varias cadencias; nosotros mantenemos un schedule por hora que define SUITE=smoke y uno nocturno que corre todo.

Cruzar la puerta de acceso

Nuestro staging está detrás de un proxy de identidad (Cloudflare Access, en nuestro caso). Una persona pasa con una redirección de SSO. Un navegador headless dentro de un job de CI no puede hacer ese baile, y automatizar un login real contra un proveedor de identidad es el tipo de setup de pruebas que se rompe una vez al mes.

La respuesta no interactiva son los service tokens. Creas uno en el dashboard de Cloudflare y obtienes un client ID y un secret; cualquier request que los presente en dos headers, CF-Access-Client-Id y CF-Access-Client-Secret, pasa sin flujo de login.

La jugada obvia sería decirle a Playwright que agregue esos headers a cada request con extraHTTPHeaders. Funciona, pero deja las credenciales viviendo dentro del contexto del navegador: terminan en los traces, en los archivos HAR, en cualquier cosa que grabe lo que el navegador envió. Nosotros lo resolvimos por otro lado: el navegador nunca toca las credenciales. El job construye la app, sirve el build localmente y le pone delante un reverse proxy diminuto. Las páginas se sirven desde el build local (exactamente el código que produjo este pipeline) y las llamadas al API se reenvían al origen protegido con los headers inyectados del lado del servidor:

// proxy.mjs — reenvía /api/* al origen protegido e inyecta el token
import http from 'node:http';
import https from 'node:https';
 
http
  .createServer((req, res) => {
    const upstream = new URL(req.url, process.env.STAGING_ORIGIN);
    const proxied = https.request(
      upstream,
      {
        method: req.method,
        headers: {
          ...req.headers,
          host: upstream.host,
          'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID,
          'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET,
        },
      },
      r => {
        res.writeHead(r.statusCode, r.headers);
        r.pipe(res);
      },
    );
    req.pipe(proxied);
  })
  .listen(8080);

Ese es el corazón; el script real también enruta los paths que no son API hacia el servidor estático local. Del lado del job son tres líneas:

npx serve dist --listen 4173 &   # la app construida antes en este pipeline
node scripts/proxy.mjs &         # :8080 → /api a staging, el resto a :4173
BASE_URL=http://localhost:8080 pnpm exec playwright test

Hay un bonus escondido aquí: pruebas el build de frontend de tu propia rama contra el backend real de staging, en lugar de probar lo que sea que staging desplegó por última vez. El ID y el secret viven como variables de CI/CD enmascaradas. Una nota operativa: los service tokens expiran según la duración que elegiste al crearlos, y Cloudflare puede avisarte una semana antes; configura esa alerta, porque la alternativa es que la corrida nocturna empiece a fallar un martes cualquiera con un 403 y sin causa evidente.

Repartir la suite entre runners

Una suite de este tamaño en un solo runner se come casi todo el tiempo de reloj del pipeline. El keyword parallel: de la parte 4 calza directo con el flag --shard de Playwright:

e2e:
  parallel: 4
  script:
    - pnpm exec playwright test
      --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
      --reporter blob
  artifacts:
    when: always
    paths:
      - blob-report/

GitLab levanta cuatro copias del job con CI_NODE_INDEX del 1 al 4, y la sintaxis de shard de Playwright pide exactamente eso: --shard=1/4. Existen servicios comerciales de orquestación que reparten los tests entre máquinas usando tiempos históricos en vez de conteo de archivos; a una escala mucho mayor valen la pena, pero el sharding simple nos redujo el tiempo de reloj a más o menos una cuarta parte, gratis.

Cada shard escribe un blob report (el formato intermedio de Playwright, pensado para fusionarse después) como artifact normal. El when: always importa: el shard con tests fallidos es justamente el shard cuyo reporte necesitas, así que la subida tiene que ocurrir también cuando falla.

Los resultados como datos del pipeline

Cuatro shards significan cuatro reportes parciales y ningún lugar que sepa cómo salió la corrida completa. Un job de merge los une y, de paso, destila la corrida en números. Es el patrón de la parte 3: artifacts:reports:dotenv como canal de estado estructurado entre jobs:

merge-report:
  stage: report
  needs: ['e2e']
  script:
    - pnpm exec playwright merge-reports --reporter html ./blob-report
    - node scripts/stats.mjs # lee los resultados fusionados y escribe run.env
  artifacts:
    when: always
    paths:
      - playwright-report/
    reports:
      dotenv: run.env

El archivo run.env son tres asignaciones simples:

E2E_PASSED=182
E2E_FAILED=3
E2E_FLAKY=7

Todos los jobs posteriores del pipeline ven esos valores como variables normales, sin parsear nada ni escarbar en artifacts. El mecanismo está dimensionado precisamente para cargas así: el archivo dotenv tiene un tope de 5 KB y por defecto se heredan alrededor de 20 variables, así que es un canal para el resumen, mientras el reporte completo viaja al lado como artifact común.

Reportes que sobreviven al pipeline

Los artifacts de GitLab son un mal hogar para el historial de tests. Están atados a un pipeline, expiran (en la parte 3 pusimos expiraciones cortas a propósito) y no existe una vista que ponga la corrida de anoche junto a las treinta anteriores. Para ver tendencias, y sobre todo para ver cómo se mueve el conteo de flaky a lo largo de semanas, queríamos almacenamiento append-only, algo que ningún proceso de GitLab limpie jamás.

La forma: un bucket de object storage, un prefijo por corrida y un manifiesto que acumula:

publish-report:
  stage: publish
  needs: ['merge-report']
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_PIPELINE_SOURCE == "web"
      when: manual
  script:
    - RUN_ID=$(date -u +%Y-%m-%d-%H%M)
    - aws s3 cp playwright-report/ "s3://e2e-reports/runs/$RUN_ID/" --recursive
    - node scripts/update-manifest.mjs "$RUN_ID"

El RUN_ID es simplemente la fecha y hora en UTC, que ordena cronológicamente sin esfuerzo extra. El script del manifiesto descarga manifest.json de la raíz del bucket, agrega una entrada (el ID de la corrida más los conteos E2E_*, que llegaron por el dotenv report sin plomería adicional) y lo vuelve a subir. Un index.html estático en la raíz del bucket lee el manifiesto y pinta la lista: cada corrida, sus números de pass/fail/flaky y un link a su reporte HTML completo. Un dashboard de historial de tests sin servidores, cuya factura entera es object storage y unos cuantos kilobytes de tráfico: centavos.

El leer-modificar-escribir sobre manifest.json es, en rigor, una race condition. Con un solo escritor nocturno y alguna corrida manual ocasional nunca muerde; si algún día agregas schedules que se traslapan, envuelve el job de publicación en un resource_group (parte 5) para que las corridas se serialicen.

Por qué esto no bloquea merges

La parte incómoda. Estos tests cruzan una red real hacia un entorno protegido, dependen de datos que otros equipos mutan y pagan la latencia de la puerta de acceso en cada request. El conteo de flaky en run.env no es cero ni en un buen día. Si esta suite fuera un check obligatorio del MR, la secuencia es predecible: MRs en rojo por razones de infraestructura, gente reintentando hasta que salga verde y, en un mes, nadie leyendo las fallas. Una barrera en la que nadie confía es peor que ninguna barrera.

La versión suave de la barrera ya la habíamos probado (el trigger downstream de la parte 7 corre una suite E2E relacionada desde otro repo en cada merge, manual y con permiso de fallar), así que esta decisión se tomó con los ojos abiertos: programado más manual, sin gate. Dicho sin adornos, eso significa que los bugs aterrizan primero en main y se detectan esa misma noche. Es el tradeoff que aceptamos. El trabajo de la suite es encontrar regresiones dentro del día siguiente a que aterricen, con un registro navegable de cuándo apareció cada una; y el número E2E_FLAKY del manifiesto nos dice si la suite va camino de ganarse, algún día, la confianza suficiente para bloquear algo.

Con qué te quedas

La suite de E2E ahora corre cada noche y bajo demanda, cruza la puerta de acceso sin que el navegador toque una credencial, se reparte entre cuatro runners y deja un historial permanente y navegable de cada corrida. Nada de eso bloquea un merge, y es a propósito.

Lo otro que corre en piloto automático está mucho peor. El escaneo de seguridad tarda 21 minutos, marca cosas que nadie lee y todo el mundo lo ignora, que es el peor estado posible para el único job cuyos hallazgos podrían importar de verdad. Hacerlo lo bastante rápido y específico como para que se lo tomen en serio es la parte 12.

Referencias