Las cuatro métricas DORA están en verde y el módulo anterior se cerró diciendo que todas las decisiones tomadas hasta aquí fueron razonadas pero ninguna es universal. Este módulo las somete a contextos distintos, y empieza por el más cercano: una aplicación web de frontend. Es el caso ideal para el puente porque el protagonista lleva cinco lecciones entre bastidores. apps/web ha aparecido en el ci.yml de la 02-02, se ha construido con npm run build en la 02-03, se ha subido a S3 con invalidación de CloudFront en la 03-02 y ha viajado en el matrix de la 04-04, pero siempre como acompañante de la API: nunca hemos mirado qué tiene de propio. Y tiene bastante. Un frontend no despliega un proceso, despliega ficheros; su configuración se incrusta en el momento de compilar y no cuando arranca —lo que choca de frente con "construir una vez, desplegar muchas veces" de la 02-06—; su rendimiento y su accesibilidad son parte del producto y se pueden medir en el pipeline; y su rollback funciona de una forma que la API no puede permitirse. En esta lección recorremos su pipeline de extremo a extremo, añadimos las cuatro puertas de calidad que solo tienen sentido en el navegador, resolvemos el conflicto de la configuración, y terminamos mirando el otro caso web —una aplicación renderizada en servidor— para ver qué se recupera y qué se pierde.
Contenido
- Qué tiene este contexto que la API no tenía
- El pipeline de
apps/webde extremo a extremo - El build de Vite: qué produce exactamente
- Presupuesto de bundle como puerta de calidad
- Configuración: build-time contra runtime
- Caché e invalidación: assets con hash e
index.htmlsin caché - Pruebas E2E con Playwright contra la previsualización
- Regresión visual, accesibilidad y Lighthouse CI
- Despliegue atómico y rollback de frontend
- El otro caso web: renderizado en servidor
- Ficha del caso
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué tiene este contexto que la API no tenía
Antes de escribir una línea de YAML conviene ser preciso sobre las diferencias, porque de ellas sale todo lo demás:
apps/api (lo conocido) |
apps/web (este caso) |
|
|---|---|---|
| Qué se despliega | Un proceso en ejecución (tarea ECS) | Un conjunto de ficheros estáticos |
| Dónde se ejecuta el código | En un servidor que controlamos | En el navegador del usuario, versión y red desconocidas |
| Configuración | Variables de entorno leídas al arrancar | Incrustada al compilar, salvo que se haga algo |
| Convivencia de versiones | Minutos, durante el rolling update | Horas o días: pestañas abiertas con la versión vieja |
| Rollback | Redesplegar digest anterior: 4 min | Repuntar a la carpeta anterior: segundos |
| Secretos | Los hay, en el gestor de secretos | No puede haber ninguno: todo es público |
| Qué mide "que funciona" | Latencia p95, tasa de error 5xx | Eso y además tamaño, rendimiento percibido, accesibilidad |
Dos filas merecen un comentario inmediato. La de secretos es absoluta: cualquier cosa que entre en el bundle es legible por quien abra las herramientas del navegador, así que una clave de API "solo para el frontend" es una clave publicada. Y la de convivencia de versiones es la que más sorprende: cuando rollback.yml devuelve la API a un digest anterior, en cuatro minutos no queda ninguna tarea vieja; pero un usuario con la pestaña abierta desde ayer sigue ejecutando el JavaScript de ayer contra tu API de hoy. El frontend es un cliente antiguo que no puedes obligar a actualizarse, y esa es exactamente la idea que la lección siguiente llevará al extremo con la app móvil.
- El pipeline de
apps/web de extremo a extremo
apps/web de extremo a extremoEl grafo completo, con las etapas nuevas marcadas frente a lo que ya existía:
flowchart TD
PR["Pull request<br/>toca apps/web"] --> L["lint + tsc + unitarias<br/>(02-04, 02-05)"]
L --> B["build Vite<br/>+ presupuesto de bundle"]
B --> P["Publicar previsualizacion<br/>pr-482.preview.reservalia.app"]
P --> E2E["Playwright E2E"]
P --> VIS["Regresion visual"]
P --> A11Y["axe + Lighthouse CI"]
E2E --> G{"Puerta de calidad"}
VIS --> G
A11Y --> G
G -->|verde| M["Merge a main"]
M --> S["Deploy staging<br/>S3 + invalidacion"]
S --> PROD["Deploy prod<br/>por promocion del mismo build"]
Lo que ya conoces sigue valiendo tal cual y no lo repetimos: los triggers y el runner (02-02), la instalación con npm ci y caché (02-03, 04-02), la composite action preparar-node (04-05), el paths y concurrency que evitan ejecutar esto cuando el PR solo toca la API (02-07), la autenticación por OIDC (03-02, 04-03) y el job seguridad (04-03). Lo nuevo son las cuatro cajas del centro y la forma del artefacto. Empecemos por ahí.
- El build de Vite: qué produce exactamente
$ npm run build --workspace apps/web
vite v5.4.2 building for production...
✓ 1.284 modules transformed.
dist/index.html 0.62 kB │ gzip: 0.38 kB
dist/assets/index-B7fK2p1x.css 41.20 kB │ gzip: 7.94 kB
dist/assets/index-Ca9mQ04d.js 188.53 kB │ gzip: 61.02 kB
dist/assets/agenda-Dk1x77Ze.js 94.11 kB │ gzip: 28.40 kB
✓ built in 6.42sTres observaciones que gobiernan todo lo demás. Primera: los nombres llevan un hash del contenido (index-Ca9mQ04d.js). Si el contenido cambia, cambia el nombre; si no cambia, el nombre es idéntico entre builds. Eso es lo que permitirá la estrategia de caché del apartado 6 y es, en el fondo, la misma idea del digest inmutable de la 02-06 aplicada a ficheros. Segunda: index.html no lleva hash —tiene que estar en una URL fija— y contiene las referencias a los ficheros que sí lo llevan. Es el único fichero mutable del conjunto, y por eso es el que no se cachea. Tercera: agenda-Dk1x77Ze.js está separado porque la vista de agenda se carga bajo demanda; el tamaño que importa no es el total sino lo que descarga el usuario en la primera visita.
El artefacto de este caso es, entonces, el contenido de dist/, y se sube a actions/upload-artifact para que los jobs siguientes lo consuman sin reconstruirlo. Igual que en la 02-06 con la imagen: se construye una vez.
construir:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/preparar-node # 1
- run: npm run build --workspace apps/web
- run: npx size-limit --json > size.json # 2
- uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }} # 3
path: apps/web/dist
retention-days: 7- La composite action de la 04-05 hace el
checkoutde Node,npm ciy la caché: no se reescribe aquí. - El presupuesto se calcula sobre el build recién hecho, no sobre una estimación. Lo vemos en el apartado siguiente.
- El nombre del artefacto incluye el SHA del commit, de modo que el job de despliegue descarga exactamente el build que pasó las pruebas y no uno reconstruido. Reconstruir para desplegar es el antipatrón que la 02-06 llamó "construir dos veces".
- Presupuesto de bundle como puerta de calidad
Un frontend se degrada de una forma muy concreta: nadie añade 400 kB de golpe, pero veinte PR que añaden 20 kB cada uno sí. En seis meses la primera carga pasa de 61 kB a 180 kB comprimidos y el equipo lo descubre por una queja, no por una medición. Un presupuesto de tamaño convierte esa degradación en un check rojo, que es exactamente lo que hicimos con la cobertura en la 02-04 y con la deuda nueva en el quality gate de la 02-05.
// apps/web/.size-limit.json
[
{
"name": "Carga inicial (JS)",
"path": ["dist/assets/index-*.js"],
"limit": "65 kB",
"gzip": true
},
{
"name": "Carga inicial (CSS)",
"path": ["dist/assets/index-*.css"],
"limit": "10 kB",
"gzip": true
},
{
"name": "Vista de agenda (diferida)",
"path": ["dist/assets/agenda-*.js"],
"limit": "30 kB",
"gzip": true
}
]Cuatro decisiones dentro de ese fichero. Se mide comprimido (gzip: true) porque es lo que viaja por la red; medir el fichero sin comprimir infla el número y desalinea la señal. Hay un presupuesto por grupo, no uno global: si solo hubiera un total, mover peso de la carga inicial a una vista diferida —que es una mejora real— no se distinguiría de no hacer nada. Los límites se fijan un poco por encima del valor actual, no en el valor exacto: un presupuesto que salta con cada cambio se desactiva en una semana. Y el límite es una decisión de producto, no técnica: 65 kB de JS inicial es la traducción de "la agenda tiene que abrirse en menos de dos segundos en el móvil de la recepcionista de una peluquería con 4G regular".
Cuando el PR se pasa del presupuesto, el check falla con un mensaje accionable:
Carga inicial (JS)
Size limit: 65 kB
Size: 71.4 kB with all dependencies, minified and gzipped
✗ Package size limit has exceeded by 6.4 kBDiego: "¿Y si necesito de verdad pasarme del límite?" Marta: "Entonces subes el límite en el mismo PR, con el número nuevo a la vista del revisor. Lo que no quiero es que se pase sin que nadie lo vea."
Eso es lo que convierte un presupuesto en algo sostenible: no es una prohibición, es una conversación forzada. La misma filosofía de la política de cuarentena de flaky de la 02-04.
- Configuración: build-time contra runtime
Aquí está el choque frontal con la 02-06. Vite sustituye las referencias a import.meta.env.VITE_* por su valor durante la compilación:
// apps/web/src/api/cliente.ts
export const BASE_API = import.meta.env.VITE_API_URL; // se sustituye al compilarSi VITE_API_URL vale https://api.staging.reservalia.app al construir, ese texto queda escrito dentro del JavaScript. El artefacto ya no es neutro: es el artefacto de staging. Y promocionar a producción el mismo build que se validó en staging —la regla que sostiene medio curso— pasa a ser imposible, porque apuntaría a la API equivocada. Hay dos salidas, y conviene ver las dos con sus costes:
| Build por entorno | Configuración en runtime (/config.json) |
|
|---|---|---|
| Cómo funciona | Se compila una vez por entorno con sus variables | Se compila una sola vez; al arrancar, la app hace fetch('/config.json') |
| Promoción por artefacto | ❌ No: lo probado en staging no es lo desplegado | ✅ Sí: el mismo dist/ va a los tres entornos |
| Tiempo de pipeline | 3 builds (o N con más entornos) | 1 build |
| Riesgo | Un fallo de build de prod que staging no vio | Una petición extra antes de renderizar |
| Cambiar la config | Requiere reconstruir y redesplegar | Editar un fichero y invalidar su ruta |
| Complejidad en el código | Ninguna | Hay que arrancar tras resolver la config |
| Cuándo elegirlo | Pocos entornos y config muy estable | Cuando quieres promoción real por artefacto |
Reservalia elige la segunda, coherente con todo lo anterior. La implementación mínima:
// se genera en el despliegue, no está en el repositorio
{
"apiUrl": "https://api.reservalia.app",
"entorno": "prod",
"sentryDsn": "https://[email protected]/2",
"version": "1.14.0"
}// apps/web/src/config.ts
export type Config = { apiUrl: string; entorno: string; sentryDsn: string; version: string };
export async function cargarConfig(): Promise<Config> {
const r = await fetch('/config.json', { cache: 'no-store' }); // 1
if (!r.ok) throw new Error('No se pudo cargar la configuración');
return r.json();
}// apps/web/src/main.tsx
const config = await cargarConfig(); // 2
inicializarSentry(config);
crearRaiz(document.getElementById('root')!).render(<App config={config} />);cache: 'no-store'es imprescindible: si el navegador cacheaconfig.json, un cambio de configuración no llega nunca. Es el mismo razonamiento que conindex.htmlen el apartado siguiente.- La aplicación arranca después de resolver la configuración. El coste es una petición en serie antes del primer render; se compensa con un
<link rel="preload" href="/config.json">en el HTML para que el navegador la pida en paralelo con el JS.
Y una regla que no se negocia: config.json solo contiene valores públicos. La sentryDsn de cliente lo es por diseño; una clave de servicio no. Si algo no puede aparecer en una captura de pantalla del inspector, no va aquí y probablemente tenga que resolverlo la API.
- Caché e invalidación: assets con hash e
index.html sin caché
index.html sin cachéLa 03-02 dejó el despliegue de la web resuelto a grandes rasgos: sincronizar dist/ con S3 e invalidar CloudFront. Lo que no vimos es que no todos los ficheros se cachean igual, y que hacerlo mal produce el bug más desconcertante del frontend: un usuario con el HTML nuevo pidiendo un JS que ya no existe, o al revés.
# 1 · assets con hash: caché eterna, sin invalidar nunca
aws s3 sync apps/web/dist s3://reservalia-web-prod --delete \
--exclude "index.html" --exclude "config.json" \
--cache-control "public, max-age=31536000, immutable"
# 2 · index.html y config.json: nunca en caché
aws s3 cp apps/web/dist/index.html s3://reservalia-web-prod/index.html \
--cache-control "no-cache, must-revalidate"
aws s3 cp config.prod.json s3://reservalia-web-prod/config.json \
--cache-control "no-cache, must-revalidate"
# 3 · invalidación mínima
aws cloudfront create-invalidation --distribution-id "$CF_DIST" \
--paths "/index.html" "/config.json"- Un año de caché e
immutablepara los assets con hash. Es seguro justamente porque el nombre depende del contenido: si el contenido cambia, la URL es otra y no hay nada que invalidar. El navegador ni siquiera pregunta si ha cambiado. index.htmlse revalida siempre, porque es el único fichero cuya URL es fija y cuyo contenido cambia. Es el punto de entrada que dice qué assets hay que cargar.- La invalidación es de dos rutas, no de
/*. Invalidar todo cuesta dinero por encima de la cuota mensual, tarda más y no aporta nada: los assets con hash no necesitan invalidarse jamás. Este cambio, por sí solo, bajó el tiempo de despliegue de la web de Reservalia de 4 minutos a 40 segundos.
El orden importa y es contraintuitivo: primero se suben los assets nuevos, y solo después el index.html que los referencia. Al revés habría una ventana en la que el HTML nuevo pide ficheros que aún no existen. Y fíjate en el --delete del primer comando: borra de S3 lo que ya no está en dist/, lo que rompería a los usuarios con la pestaña abierta desde ayer. Se resuelve en el apartado 9.
- Pruebas E2E con Playwright contra la previsualización
Los entornos de previsualización por PR aparecieron en la 02-07 como forma de que Marta viera los cambios sin arrancar nada. Ahora cobran su segundo uso: son la URL contra la que se ejecutan las pruebas de navegador. Cada PR publica en pr-482.preview.reservalia.app y los tres jobs siguientes atacan esa dirección.
// apps/web/e2e/reservar.spec.ts
import { test, expect } from '@playwright/test';
test('un cliente reserva un hueco libre de la agenda', async ({ page }) => {
await page.goto('/negocios/demo/agenda'); // 1
await expect(page.getByRole('heading', { name: 'Agenda' })).toBeVisible();
await page.getByRole('button', { name: '25 de octubre' }).click();
await page.getByRole('button', { name: '10:30' }).click(); // 2
await page.getByLabel('Nombre').fill('Cliente Ficticio 1');
await page.getByLabel('Teléfono').fill('+34 600 000 001');
await page.getByRole('button', { name: 'Confirmar reserva' }).click();
await expect(page.getByText('Reserva confirmada')).toBeVisible(); // 3
});- La ruta es relativa: la URL base la pone la configuración de Playwright desde una variable, así que la misma prueba corre contra la previsualización del PR, contra staging o contra localhost. El negocio
demoy su agenda vienen de los datos sintéticos de la 04-06, y por eso10:30es un hueco conocido y no una casualidad. - Selectores por rol y texto accesible, no por clase CSS ni por
data-testidcuando se puede evitar. Tiene dos ventajas: la prueba no se rompe al cambiar estilos —una de las grandes fuentes de flaky de la 02-04— y además falla si el botón deja de ser accesible, con lo que la prueba funcional protege también la semántica. - Las esperas son sobre el estado visible, nunca
waitForTimeout. Playwright reintenta elexpecthasta el tiempo límite; unsleepfijo es la receta de la prueba inestable.
Sobre cuántas de estas hay que tener, la pirámide de la 02-04 sigue mandando: entre seis y diez recorridos, los que dan dinero (reservar, cancelar, ver la agenda del día, cobrar). Cada E2E cuesta entre 20 y 60 segundos y es la capa más frágil; la tentación de escribir cuarenta se paga en tiempo de pipeline (04-04) y en falsos rojos.
- Regresión visual, accesibilidad y Lighthouse CI
Tres puertas más que solo existen en el navegador. La clave para que ninguna sea insufrible es cómo se configura el umbral, y ahí las tres siguen la misma idea del quality gate de "nuevo código limpio" de la 02-05.
Regresión visual. Se captura la pantalla de un componente o una vista y se compara con una imagen de referencia versionada:
await expect(page.getByTestId('tarjeta-cita')).toHaveScreenshot('tarjeta-cita.png', {
maxDiffPixelRatio: 0.01, // 1 · tolerancia
mask: [page.getByTestId('reloj')], // 2 · zonas dinámicas
});- Una tolerancia pequeña pero no nula: el antialiasing de fuentes varía entre ejecuciones y una comparación exacta produce rojos aleatorios.
- Se enmascara todo lo que cambia solo: relojes, avatares aleatorios, animaciones. Sin esto, la regresión visual es la nueva prueba flaky. Y una precaución operativa: las capturas se generan dentro del contenedor del runner, nunca en el portátil de Nuria, porque las fuentes del sistema difieren y todas las referencias saldrían mal.
Accesibilidad automatizada con axe. Se ejecuta sobre las vistas principales dentro de la misma sesión de Playwright:
const resultados = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) // 1
.analyze();
expect(resultados.violations).toEqual([]); // 2- Se acota a los criterios que el equipo se ha comprometido a cumplir. Activarlo todo de golpe sobre una aplicación existente da doscientas violaciones y el check se ignora al día siguiente.
- Cero violaciones en las vistas cubiertas es alcanzable si se adopta vista a vista. Y conviene decir lo que el pipeline no puede: axe detecta alrededor de un tercio de los problemas reales de accesibilidad —contraste, etiquetas, roles, orden de tabulación— y no sustituye una revisión manual con lector de pantalla. Es una red que atrapa regresiones, no un certificado.
Lighthouse CI como gate de rendimiento.
// apps/web/lighthouserc.json
{
"ci": {
"collect": { "url": ["https://pr-482.preview.reservalia.app/negocios/demo/agenda"],
"numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
}
}
}numberOfRuns: 3 es lo que hace usable esta puerta: una sola medición en un runner compartido tiene un ruido de ±10 puntos, y con la mediana de tres el ruido baja lo bastante para que un rojo signifique algo. Aun así, la regla de oro es no poner umbrales al borde del valor actual; si hoy sacas 0,88 de rendimiento, el umbral se pone en 0,85 y se sube cuando mejore. Un gate que parpadea se acaba desactivando, y entonces no protege nada.
- Despliegue atómico y rollback de frontend
El --delete del apartado 6 tiene una consecuencia desagradable. Un usuario cargó index.html hace veinte minutos; despliegas; su pestaña, al navegar a la agenda, pide agenda-Dk1x77Ze.js… que acaba de borrarse. Pantalla en blanco. La solución es desplegar por versiones y no sobrescribir:
VERSION="$GITHUB_SHA" # 1
aws s3 sync apps/web/dist "s3://reservalia-web-prod/v/$VERSION/" \
--cache-control "public, max-age=31536000, immutable" # sin --delete
aws s3 cp "s3://reservalia-web-prod/v/$VERSION/index.html" \
s3://reservalia-web-prod/index.html \
--cache-control "no-cache, must-revalidate" # 2
aws cloudfront create-invalidation --distribution-id "$CF_DIST" --paths "/index.html"- Cada build vive en su propia carpeta
v/<sha>/y nunca se borra en el despliegue. Los assets antiguos siguen disponibles para las pestañas viejas; una regla de ciclo de vida de S3 los elimina a los 30 días. - El despliegue consiste en copiar un único fichero: el
index.htmlde esa versión a la raíz. Esa copia es la operación atómica —o está el HTML viejo o el nuevo, nunca una mezcla— y es lo que hace el conjunto seguro.
De ahí sale el rollback más rápido del curso:
aws s3 cp "s3://reservalia-web-prod/v/$SHA_ANTERIOR/index.html" \
s3://reservalia-web-prod/index.html --cache-control "no-cache, must-revalidate"
aws cloudfront create-invalidation --distribution-id "$CF_DIST" --paths "/index.html"Segundos, no los cuatro minutos de rollback.yml (03-05), porque no hay que arrancar nada: los ficheros anteriores nunca se fueron. Con dos matices honestos. Primero, la invalidación de CloudFront tarda entre 30 y 60 segundos en propagarse a todos los puntos de presencia, así que "segundos" significa menos de un minuto, no instantáneo. Y segundo, si el frontend nuevo dependía de un cambio de API, revertir solo el frontend no basta: por eso la regla de compatibilidad hacia atrás de la 03-04 se aplica también aquí, y la API debe seguir sirviendo a la versión anterior de la web. Es, otra vez, expand and contract (04-06) aplicado a un contrato distinto.
Para el despliegue progresivo, el equivalente al canary de la 03-04 se hace con una función en el borde que decide qué index.html sirve según una cookie o un porcentaje de peticiones. Reservalia no lo ha necesitado: con feature flags (03-05) dentro del bundle único se cubre el 90 % de los casos y con mucha menos maquinaria.
- El otro caso web: renderizado en servidor
Si en lugar de una SPA con Vite la web fuera una aplicación Next.js renderizada en servidor, ¿qué cambia? Menos de lo que parece, y lo que cambia lo hemos visto ya:
| Aspecto | SPA estática (apps/web) |
SSR (Next.js) |
|---|---|---|
| Artefacto | Carpeta de ficheros | Imagen de contenedor, como la API |
| Despliegue | Copiar a S3 + invalidar | Rolling update en ECS (03-04) |
| Configuración | /config.json en runtime |
Variables de entorno al arrancar… para el servidor; las del cliente siguen incrustándose |
| Rollback | Repuntar index.html: segundos |
Digest anterior: 4 min (03-05) |
| Secretos | Ninguno posible | Sí en el servidor, nunca en lo que se envía al navegador |
| Escalado | CDN, no hay servidores | Tareas y autoescalado, con su coste |
| Puertas de calidad | Bundle, E2E, visual, axe, Lighthouse | Exactamente las mismas |
La lectura es la que ordena todo el módulo: al recuperar un proceso ejecutándose, recuperas los problemas del módulo 3 y sus soluciones ya escritas —artefacto por digest, health checks, rolling update, rollback en cuatro minutos— y a cambio pierdes el despliegue atómico de coste cero. Lo que no cambia es la mitad específicamente frontend: el presupuesto de bundle, las pruebas de navegador, la accesibilidad y Lighthouse siguen siendo idénticos, porque el navegador del usuario no sabe quién generó el HTML. Y aparece un matiz que atrapa a mucha gente: en Next.js sigue habiendo variables que se incrustan en el bundle del cliente (las NEXT_PUBLIC_*), así que el problema del apartado 5 no desaparece con SSR, solo se reduce a la parte que viaja al navegador.
- Ficha del caso
| Contexto | SPA React/Vite servida desde S3 + CloudFront; 340 negocios; usuarios en móviles con red variable |
| Qué sigue valiendo tal cual | CI (02), artefacto único y promoción (02-06), OIDC (03-02), seguridad (04-03), preparar-node (04-05) |
| Decisión 1 | Configuración en runtime vía /config.json, para conservar la promoción por artefacto |
| Decisión 2 | Caché eterna para assets con hash, no-cache para index.html; invalidación de 2 rutas |
| Decisión 3 | Despliegue por carpeta v/<sha>/ con copia atómica del HTML; retención 30 días |
| Decisión 4 | Cuatro puertas nuevas: presupuesto de bundle, E2E Playwright, regresión visual, axe + Lighthouse |
| Coste | +2 min 40 s de pipeline en PR que tocan apps/web; ~8 h de trabajo inicial de configuración |
| Efecto en DORA | Lead time igual; time to restore del frontend: de 4 min a <1 min; change failure rate del frontend −1,2 pp |
| Qué te llevas a cualquier proyecto | Lo que se mide no se degrada: presupuestos y umbrales convierten el deterioro lento en un check rojo |
Errores Comunes y Consejos
Error 1: reconstruir el bundle en el job de despliegue en lugar de descargar el artefacto que pasó las pruebas. Es "construir dos veces" (02-06) y basta con que cambie una dependencia transitoria para desplegar algo distinto de lo validado. Error 2: meter secretos en variables VITE_* pensando que el minificado los oculta; están en texto claro en el bundle público.
Error 3: invalidar /* en cada despliegue. Cuesta dinero, tarda minutos y es innecesario si los assets llevan hash. Error 4: cachear index.html, con lo que el usuario sigue cargando la versión anterior durante horas y el despliegue "no se nota". Error 5: usar --delete sin versionar carpetas, que rompe las pestañas abiertas al borrar los assets viejos.
Error 6: cuarenta pruebas E2E en lugar de ocho recorridos que dan dinero; el pipeline se dobla y los falsos rojos entrenan al equipo para ignorar el rojo. Error 7: umbrales de Lighthouse al borde del valor actual y con una sola ejecución, que producen un gate que parpadea. Error 8: activar todas las reglas de axe de golpe sobre una aplicación existente: doscientas violaciones equivalen a cero.
Consejo 1: sube el presupuesto de bundle en el mismo PR que lo necesita, nunca en uno aparte. Consejo 2: genera las capturas de referencia en el contenedor del runner, jamás en un portátil. Consejo 3: incluye la versión en config.json y muéstrala en el pie de la aplicación; te dirá en dos segundos qué build está viendo un usuario que reporta un fallo. Consejo 4: aplica la compatibilidad hacia atrás también a la API respecto del frontend antiguo, porque siempre habrá pestañas de ayer.
Ejercicios
Ejercicio 1
Nuria despliega la web a las 12:05. A las 12:07, tres negocios reportan pantalla en blanco al abrir la agenda; en la consola del navegador aparece Failed to load module script: agenda-Dk1x77Ze.js (404). Los usuarios que abren la web por primera vez no tienen ningún problema. Explica el mecanismo exacto del fallo, por qué solo afecta a algunos usuarios y qué dos cambios en el pipeline lo eliminan.
Ejercicio 2
El equipo quiere que un mismo build de apps/web se pruebe en staging y se promocione a producción sin reconstruir, pero la URL de la API es distinta en cada entorno y hoy se inyecta con VITE_API_URL. Diseña la solución completa: qué se cambia en el código, qué produce el pipeline, dónde vive la configuración de cada entorno y qué cabecera de caché lleva. Señala además dos riesgos nuevos que introduce tu diseño.
Ejercicio 3
Un PR sube el presupuesto de "Carga inicial (JS)" de 65 kB a 96 kB y en la descripción dice: "necesario para la nueva librería de gráficos". El check pasa en verde porque el propio PR modifica el límite. ¿Es esto un fallo del diseño del gate? Argumenta la respuesta y propón qué añadirías al pipeline y al proceso.
Soluciones
Solución 1. El mecanismo tiene tres tiempos. (1) Los usuarios afectados cargaron index.html antes del despliegue —su pestaña lleva abierta desde las 11:40—, así que el HTML que tienen en memoria referencia los assets de la versión anterior, entre ellos el chunk diferido agenda-Dk1x77Ze.js. (2) El despliegue ejecutó aws s3 sync --delete, que borra de S3 todo lo que no está en el dist/ nuevo; como el contenido de la vista de agenda cambió, su hash cambió y el fichero viejo desapareció. (3) A las 12:07 esos usuarios navegan por primera vez a la agenda, el navegador pide el chunk diferido —que no se descargó en la carga inicial, justo por ser diferido— y recibe un 404. El módulo no carga, React no puede renderizar la ruta y queda la pantalla en blanco. Solo les afecta a ellos porque quien entra después de las 12:05 recibe el index.html nuevo, que referencia los assets nuevos, que sí existen.
Fíjate en un detalle que explica el retraso de dos minutos: el problema no se manifiesta al desplegar sino al navegar, porque los chunks diferidos se piden bajo demanda. Con carga inicial monolítica el fallo habría sido inmediato o inexistente; con code splitting queda latente en cada pestaña abierta.
Los dos cambios. (a) Despliegue por carpetas versionadas: subir cada build a v/<sha>/ sin --delete, de modo que los assets antiguos permanezcan mientras haya pestañas que los pidan, con una regla de ciclo de vida de S3 que los borre a los 30 días. El despliegue pasa a ser la copia atómica del index.html de esa versión a la raíz. (b) Detección y recuperación en el cliente: capturar el error de carga dinámica de módulos y, ante él, recargar la página una vez —lo que traerá el index.html nuevo y sus assets—, con una marca en sessionStorage para no entrar en un bucle de recargas. La primera medida elimina la causa; la segunda cubre el caso residual del día 31 y el de un usuario con una pestaña de hace un mes. Como refuerzo, mostrar la version de config.json en el pie ayuda a diagnosticar en soporte: el usuario lee un número y ya sabes qué build tiene.
Solución 2. En el código: se elimina toda referencia a import.meta.env.VITE_API_URL y se sustituye por el config.ts del apartado 5, que hace fetch('/config.json', { cache: 'no-store' }) antes de montar la aplicación; main.tsx pasa a arrancar de forma asíncrona y la config se propaga por contexto de React en lugar de importarse como constante. Merece la pena añadir una comprobación de tipos en tiempo de ejecución sobre el JSON recibido: si falta apiUrl, es mejor un error explícito en pantalla que un undefined propagándose por todas las llamadas.
Qué produce el pipeline: un único artefacto web-dist-<sha> con dist/, construido sin ninguna variable de entorno de entorno. Ese artefacto es el que se despliega a staging, el que pasa las E2E y el que se promociona a producción sin reconstruir, exactamente como la imagen por digest de la 02-06.
Dónde vive la configuración: tres ficheros versionados en el repositorio —apps/web/config/staging.json y prod.json, más dev.json— que el job de despliegue copia a la raíz del bucket con el nombre config.json. Van en el repositorio porque solo contienen valores públicos y así quedan bajo revisión, con historial y con CODEOWNERS. Si algún valor no fuera público, no iría aquí: lo resolvería la API tras autenticar. Cabeceras: config.json con no-cache, must-revalidate y presente en la lista de invalidación junto a index.html; los assets con hash, con max-age=31536000, immutable.
Dos riesgos nuevos. (1) Una petición en el camino crítico: la aplicación no renderiza nada hasta que config.json responde, así que un fallo o una lentitud de esa petición es una pantalla en blanco. Se mitiga con <link rel="preload"> para pedirlo en paralelo, con un reintento y con un mensaje de error legible en lugar del vacío. (2) Desincronización entre el HTML y la config: como son dos ficheros con caché independiente, existe una ventana en la que un usuario tiene el bundle nuevo y la config vieja. Si un despliegue introduce una clave nueva y obligatoria, esa combinación falla. Se mitiga tratando la configuración como un contrato con compatibilidad hacia atrás —claves nuevas siempre opcionales con valor por defecto, y las viejas se retiran una versión después—, que es expand and contract (04-06) aplicado a un fichero JSON. Un tercer riesgo menor, pero real: como la config ya no está en el bundle, un despliegue que copie config.staging.json a producción no lo detecta ningún compilador; conviene un smoke test posterior al despliegue que pida /config.json y verifique que entorno vale lo que debe.
Solución 3. No es un fallo del diseño, es el diseño funcionando, pero está incompleto. El propósito del presupuesto nunca fue impedir que la aplicación crezca —una aplicación que gana funcionalidad crece— sino impedir que crezca sin que nadie lo decida. Al obligar a modificar el límite en el mismo PR, el aumento de 31 kB aparece en el diff, va a la revisión y queda en el historial de git con fecha, autor y motivo. Eso es exactamente lo que se buscaba: una conversación forzada en el momento en que se puede tener. Compáralo con la alternativa de no tener gate, en la que esos 31 kB entran sin que nadie los vea y aparecen seis meses después como "la aplicación va lenta", sin forma de saber qué PR los trajo.
Lo que falta es que la conversación tenga datos suficientes. Añadiría cuatro cosas. (1) Un comentario automático en el PR con la comparativa frente a main —métrica actual, límite anterior, límite propuesto, diferencia en kB y en tiempo estimado de descarga en 4G—, para que el revisor vea el impacto en segundos y no en kilobytes abstractos. (2) Una regla en CODEOWNERS (02-07) sobre .size-limit.json, de modo que cambiar un presupuesto requiera la aprobación de Marta y no solo la del compañero que revisa la funcionalidad. (3) Exigir en la plantilla de PR que un aumento de límite venga con alternativas descartadas: ¿se puede cargar la librería de gráficos de forma diferida, solo en la vista que la usa, y dejar la carga inicial intacta? En este caso concreto eso es casi seguro que sí, y convertiría un aumento de 31 kB en la carga inicial en un chunk diferido con su propio presupuesto. (4) Una revisión trimestral de los presupuestos junto a los datos reales de campo, porque la señal definitiva no es el número del CI sino el rendimiento percibido por la recepcionista con 4G. Con esas cuatro piezas, el gate deja de ser un semáforo que se puede pintar de verde y pasa a ser lo que debe: un mecanismo que hace visible una decisión y obliga a justificarla.
Conclusión
apps/web ha dejado de ser el acompañante de la API y ha mostrado que un frontend, aunque comparta pipeline, repositorio y equipo, tiene una física propia. Despliega ficheros y no procesos, lo que le da el rollback más barato del curso —repuntar index.html a una carpeta v/<sha>/ anterior, menos de un minuto— a cambio de exigir un despliegue diseñado para ser atómico y para no borrar lo que las pestañas abiertas siguen pidiendo. Su código se ejecuta en el navegador de otra persona, lo que prohíbe cualquier secreto y obliga a tratar a la versión anterior como un cliente que no puedes obligar a actualizarse. Y su configuración se incrusta al compilar, lo que chocaba de frente con la promoción por artefacto de la 02-06 hasta que la movimos a runtime con /config.json, aceptando a cambio una petición en el camino crítico y un contrato JSON que también necesita compatibilidad hacia atrás. Sobre esa base añadimos las cuatro puertas que solo tienen sentido en el navegador —presupuesto de bundle con size-limit, recorridos E2E con Playwright contra la previsualización del PR, regresión visual con tolerancia y máscaras, y axe más Lighthouse CI con umbrales realistas y tres ejecuciones—, y afinamos la caché de CloudFront hasta convertir una invalidación de cuatro minutos en una de dos rutas y cuarenta segundos. El caso SSR cerró el círculo mostrando que, en cuanto vuelve a haber un proceso ejecutándose, vuelven íntegros los mecanismos del módulo 3, mientras que la mitad específicamente frontend permanece idéntica.
El hilo que conecta con lo que viene es el de la fila más incómoda de la primera tabla: el frontend es un cliente antiguo que no puedes obligar a actualizarse, pero al menos basta con que el usuario recargue la página. En la siguiente lección, Caso de Estudio: Aplicación Móvil, esa misma propiedad se lleva al extremo. En Reservalia Pro —la app React Native con la que el profesional gestiona su agenda— el usuario decide cuándo actualiza y puede no hacerlo nunca, una tienda revisa cada versión durante horas o días antes de publicarla, y no existe el rollback de una versión ya distribuida. Todo lo que aquí resolvimos con una invalidación de CloudFront habrá que resolverlo con firmas de código, canales de distribución, despliegue escalonado por porcentaje y una disciplina de compatibilidad de API que es el equivalente móvil del expand and contract de la 04-06.
Curso de CI/CD: Integración y Despliegue Continuo
Módulo 1: Introducción a CI/CD
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
