Les quatre mètriques DORA són en verd i el mòdul anterior es va tancar dient que totes les decisions preses fins aquí van ser raonades però cap no és universal. Aquest mòdul les sotmet a contextos diferents, i comença pel més proper: una aplicació web de frontend. És el cas ideal per fer de pont perquè el protagonista fa cinc lliçons que és entre bastidors. apps/web ha aparegut al ci.yml de la 02-02, s'ha construït amb npm run build a la 02-03, s'ha pujat a S3 amb invalidació de CloudFront a la 03-02 i ha viatjat dins del matrix de la 04-04, però sempre com a acompanyant de l'API: mai no hem mirat què té de propi. I en té bastant. Un frontend no desplega un procés, desplega fitxers; la seva configuració s'incrusta en el moment de compilar i no pas quan arrenca —cosa que xoca de ple amb «construir una vegada, desplegar moltes vegades» de la 02-06—; el seu rendiment i la seva accessibilitat formen part del producte i es poden mesurar al pipeline; i el seu rollback funciona d'una manera que l'API no es pot permetre. En aquesta lliçó recorrem el seu pipeline d'extrem a extrem, hi afegim les quatre portes de qualitat que només tenen sentit al navegador, resolem el conflicte de la configuració, i acabem mirant l'altre cas web —una aplicació renderitzada al servidor— per veure què es recupera i què es perd.
Contingut
- Què té aquest context que l'API no tenia
- El pipeline d'
apps/webd'extrem a extrem - La construcció de Vite: què produeix exactament
- Pressupost de bundle com a porta de qualitat
- Configuració: build-time contra runtime
- Memòria cau i invalidació: assets amb hash i
index.htmlsense memòria cau - Proves E2E amb Playwright contra la previsualització
- Regressió visual, accessibilitat i Lighthouse CI
- Desplegament atòmic i rollback de frontend
- L'altre cas web: renderitzat al servidor
- Fitxa del cas
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Què té aquest context que l'API no tenia
Abans d'escriure una línia de YAML convé ser precís sobre les diferències, perquè d'elles surt tota la resta:
apps/api (el que ja coneixem) |
apps/web (aquest cas) |
|
|---|---|---|
| Què es desplega | Un procés en execució (tasca ECS) | Un conjunt de fitxers estàtics |
| On s'executa el codi | En un servidor que controlem | Al navegador de l'usuari, versió i xarxa desconegudes |
| Configuració | Variables d'entorn llegides en arrencar | Incrustada en compilar, llevat que es faci alguna cosa |
| Convivència de versions | Minuts, durant el rolling update | Hores o dies: pestanyes obertes amb la versió antiga |
| Rollback | Redesplegar el digest anterior: 4 min | Reapuntar a la carpeta anterior: segons |
| Secrets | N'hi ha, al gestor de secrets | No n'hi pot haver cap: tot és públic |
| Què mesura «que funciona» | Latència p95, taxa d'error 5xx | Això i a més mida, rendiment percebut, accessibilitat |
Dues files mereixen un comentari immediat. La dels secrets és absoluta: qualsevol cosa que entri al bundle és llegible per qui obri les eines del navegador, de manera que una clau d'API «només per al frontend» és una clau publicada. I la de convivència de versions és la que més sorprèn: quan rollback.yml torna l'API a un digest anterior, en quatre minuts no queda cap tasca antiga; però un usuari amb la pestanya oberta des d'ahir continua executant el JavaScript d'ahir contra la teva API d'avui. El frontend és un client antic que no pots obligar a actualitzar-se, i aquesta és exactament la idea que la lliçó següent portarà a l'extrem amb l'app mòbil.
- El pipeline d'
apps/web d'extrem a extrem
apps/web d'extrem a extremEl graf complet, amb les etapes noves marcades davant del que ja existia:
flowchart TD
PR["Pull request<br/>toca apps/web"] --> L["lint + tsc + unitaries<br/>02-04, 02-05"]
L --> B["build Vite<br/>+ pressupost de bundle"]
B --> P["Publicar previsualitzacio<br/>pr-482.preview.reservalia.app"]
P --> E2E["Playwright E2E"]
P --> VIS["Regressio visual"]
P --> A11Y["axe + Lighthouse CI"]
E2E --> G{"Porta de qualitat"}
VIS --> G
A11Y --> G
G -->|verd| M["Merge a main"]
M --> S["Deploy staging<br/>S3 + invalidacio"]
S --> PROD["Deploy prod<br/>per promocio de la mateixa build"]
El que ja coneixes continua valent tal qual i no ho repetim: els triggers i el runner (02-02), la instal·lació amb npm ci i memòria cau (02-03, 04-02), la composite action preparar-node (04-05), el paths i concurrency que eviten executar això quan el PR només toca l'API (02-07), l'autenticació per OIDC (03-02, 04-03) i el job seguretat (04-03). El que és nou són les quatre caixes del centre i la forma de l'artefacte. Comencem per aquí.
- La construcció de Vite: què produeix exactament
$ 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 observacions que governen tota la resta. Primera: els noms porten un hash del contingut (index-Ca9mQ04d.js). Si el contingut canvia, canvia el nom; si no canvia, el nom és idèntic entre construccions. Això és el que permetrà l'estratègia de memòria cau de l'apartat 6 i és, al capdavall, la mateixa idea del digest immutable de la 02-06 aplicada a fitxers. Segona: index.html no porta hash —ha de ser en una URL fixa— i conté les referències als fitxers que sí que en porten. És l'únic fitxer mutable del conjunt, i per això és el que no es desa a la memòria cau. Tercera: agenda-Dk1x77Ze.js està separat perquè la vista d'agenda es carrega sota demanda; la mida que importa no és el total sinó el que descarrega l'usuari a la primera visita.
L'artefacte d'aquest cas és, doncs, el contingut de dist/, i es puja a actions/upload-artifact perquè els jobs següents el consumeixin sense reconstruir-lo. Igual que a la 02-06 amb la imatge: es construeix una vegada.
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 fa el
checkoutde Node,npm cii la memòria cau: no es reescriu aquí. - El pressupost es calcula sobre la construcció acabada de fer, no sobre una estimació. Ho veiem a l'apartat següent.
- El nom de l'artefacte inclou el SHA del commit, de manera que el job de desplegament descarrega exactament la construcció que va passar les proves i no una de reconstruïda. Reconstruir per desplegar és l'antipatró que la 02-06 va anomenar «construir dues vegades».
- Pressupost de bundle com a porta de qualitat
Un frontend es degrada d'una manera molt concreta: ningú no hi afegeix 400 kB de cop, però vint PR que n'afegeixen 20 kB cadascun sí. En sis mesos la primera càrrega passa de 61 kB a 180 kB comprimits i l'equip ho descobreix per una queixa, no per un mesurament. Un pressupost de mida converteix aquesta degradació en un check vermell, que és exactament el que vam fer amb la cobertura a la 02-04 i amb el deute nou al quality gate de la 02-05.
// apps/web/.size-limit.json
[
{
"name": "Carrega inicial (JS)",
"path": ["dist/assets/index-*.js"],
"limit": "65 kB",
"gzip": true
},
{
"name": "Carrega inicial (CSS)",
"path": ["dist/assets/index-*.css"],
"limit": "10 kB",
"gzip": true
},
{
"name": "Vista d'agenda (diferida)",
"path": ["dist/assets/agenda-*.js"],
"limit": "30 kB",
"gzip": true
}
]Quatre decisions dins d'aquest fitxer. Es mesura comprimit (gzip: true) perquè és el que viatja per la xarxa; mesurar el fitxer sense comprimir infla el número i desalinea el senyal. Hi ha un pressupost per grup, no un de global: si només hi hagués un total, moure pes de la càrrega inicial a una vista diferida —que és una millora real— no es distingiria de no fer res. Els límits es fixen una mica per damunt del valor actual, no en el valor exacte: un pressupost que salta amb cada canvi es desactiva en una setmana. I el límit és una decisió de producte, no tècnica: 65 kB de JS inicial és la traducció de «l'agenda s'ha d'obrir en menys de dos segons al mòbil de la recepcionista d'una perruqueria amb 4G regular».
Quan el PR passa del pressupost, el check falla amb un missatge accionable:
Carrega 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: «I si de debò necessito passar-me del límit?» Marta: «Aleshores puges el límit al mateix PR, amb el número nou a la vista del revisor. El que no vull és que es passi sense que ningú ho vegi.»
Això és el que converteix un pressupost en una cosa sostenible: no és una prohibició, és una conversa forçada. La mateixa filosofia de la política de quarantena de proves flaky de la 02-04.
- Configuració: build-time contra runtime
Aquí hi ha el xoc frontal amb la 02-06. Vite substitueix les referències a import.meta.env.VITE_* pel seu valor durant la compilació:
// apps/web/src/api/client.ts
export const BASE_API = import.meta.env.VITE_API_URL; // se substitueix en compilarSi VITE_API_URL val https://api.staging.reservalia.app en construir, aquest text queda escrit dins del JavaScript. L'artefacte ja no és neutre: és l'artefacte de staging. I promocionar a producció la mateixa construcció que es va validar en staging —la regla que sosté mig curs— passa a ser impossible, perquè apuntaria a l'API equivocada. Hi ha dues sortides, i convé veure-les totes dues amb els seus costos:
| Construcció per entorn | Configuració en runtime (/config.json) |
|
|---|---|---|
| Com funciona | Es compila una vegada per entorn amb les seves variables | Es compila una sola vegada; en arrencar, l'app fa fetch('/config.json') |
| Promoció per artefacte | ❌ No: el que s'ha provat en staging no és el que es desplega | ✅ Sí: el mateix dist/ va als tres entorns |
| Temps de pipeline | 3 construccions (o N amb més entorns) | 1 construcció |
| Risc | Una fallada de construcció de prod que staging no va veure | Una petició extra abans de renderitzar |
| Canviar la config | Requereix reconstruir i redesplegar | Editar un fitxer i invalidar-ne la ruta |
| Complexitat al codi | Cap | Cal arrencar després de resoldre la config |
| Quan triar-ho | Pocs entorns i config molt estable | Quan vols promoció real per artefacte |
Reservalia tria la segona, coherent amb tot l'anterior. La implementació mínima:
// es genera al desplegament, no és al repositori
{
"apiUrl": "https://api.reservalia.app",
"entorn": "prod",
"sentryDsn": "https://[email protected]/2",
"version": "1.14.0"
}// apps/web/src/config.ts
export type Config = { apiUrl: string; entorn: string; sentryDsn: string; version: string };
export async function carregarConfig(): Promise<Config> {
const r = await fetch('/config.json', { cache: 'no-store' }); // 1
if (!r.ok) throw new Error('No s\'ha pogut carregar la configuració');
return r.json();
}// apps/web/src/main.tsx
const config = await carregarConfig(); // 2
inicialitzarSentry(config);
crearArrel(document.getElementById('root')!).render(<App config={config} />);cache: 'no-store'és imprescindible: si el navegador desaconfig.jsona la memòria cau, un canvi de configuració no arriba mai. És el mateix raonament que ambindex.htmla l'apartat següent.- L'aplicació arrenca després de resoldre la configuració. El cost és una petició en sèrie abans del primer render; es compensa amb un
<link rel="preload" href="/config.json">a l'HTML perquè el navegador la demani en paral·lel amb el JS.
I una regla que no es negocia: config.json només conté valors públics. La sentryDsn de client ho és per disseny; una clau de servei no. Si alguna cosa no pot aparèixer en una captura de pantalla de l'inspector, no va aquí i probablement l'hagi de resoldre l'API.
- Memòria cau i invalidació: assets amb hash i
index.html sense memòria cau
index.html sense memòria cauLa 03-02 va deixar el desplegament de la web resolt a grans trets: sincronitzar dist/ amb S3 i invalidar CloudFront. El que no vam veure és que no tots els fitxers es guarden igual a la memòria cau, i que fer-ho malament produeix el bug més desconcertant del frontend: un usuari amb l'HTML nou demanant un JS que ja no existeix, o a l'inrevés.
# 1 · assets amb hash: memoria cau eterna, sense invalidar mai
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 i config.json: mai a la memoria cau
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 · invalidacio minima
aws cloudfront create-invalidation --distribution-id "$CF_DIST" \
--paths "/index.html" "/config.json"- Un any de memòria cau i
immutableper als assets amb hash. És segur justament perquè el nom depèn del contingut: si el contingut canvia, la URL és una altra i no hi ha res a invalidar. El navegador ni tan sols pregunta si ha canviat. index.htmles revalida sempre, perquè és l'únic fitxer amb una URL fixa i un contingut que canvia. És el punt d'entrada que diu quins assets cal carregar.- La invalidació és de dues rutes, no de
/*. Invalidar-ho tot costa diners per damunt de la quota mensual, triga més i no aporta res: els assets amb hash no necessiten invalidar-se mai. Aquest canvi, tot sol, va baixar el temps de desplegament de la web de Reservalia de 4 minuts a 40 segons.
L'ordre importa i és contraintuïtiu: primer es pugen els assets nous, i només després l'index.html que els referencia. A l'inrevés hi hauria una finestra en què l'HTML nou demanaria fitxers que encara no existeixen. I fixa't en el --delete de la primera ordre: esborra de S3 el que ja no és a dist/, cosa que trencaria els usuaris amb la pestanya oberta des d'ahir. Es resol a l'apartat 9.
- Proves E2E amb Playwright contra la previsualització
Els entorns de previsualització per PR van aparèixer a la 02-07 com a manera que la Marta veiés els canvis sense arrencar res. Ara cobren el seu segon ús: són la URL contra la qual s'executen les proves de navegador. Cada PR publica a pr-482.preview.reservalia.app i els tres jobs següents ataquen aquesta adreça.
// apps/web/e2e/reservar.spec.ts
import { test, expect } from '@playwright/test';
test('un client reserva un forat lliure de l\'agenda', async ({ page }) => {
await page.goto('/negocis/demo/agenda'); // 1
await expect(page.getByRole('heading', { name: 'Agenda' })).toBeVisible();
await page.getByRole('button', { name: '25 d\'octubre' }).click();
await page.getByRole('button', { name: '10:30' }).click(); // 2
await page.getByLabel('Nom').fill('Client Fictici 1');
await page.getByLabel('Telèfon').fill('+34 600 000 001');
await page.getByRole('button', { name: 'Confirmar reserva' }).click();
await expect(page.getByText('Reserva confirmada')).toBeVisible(); // 3
});- La ruta és relativa: la URL base la posa la configuració de Playwright des d'una variable, així que la mateixa prova corre contra la previsualització del PR, contra staging o contra localhost. El negoci
demoi la seva agenda vénen de les dades sintètiques de la 04-06, i per això10:30és un forat conegut i no una casualitat. - Selectors per rol i text accessible, no per classe CSS ni per
data-testidquan es pot evitar. Té dos avantatges: la prova no es trenca en canviar estils —una de les grans fonts de proves flaky de la 02-04— i a més falla si el botó deixa de ser accessible, amb la qual cosa la prova funcional protegeix també la semàntica. - Les esperes són sobre l'estat visible, mai
waitForTimeout. Playwright reintenta l'expectfins al temps límit; unsleepfix és la recepta de la prova inestable.
Sobre quantes n'hi ha d'haver, la piràmide de la 02-04 continua manant: entre sis i deu recorreguts, els que donen diners (reservar, cancel·lar, veure l'agenda del dia, cobrar). Cada E2E costa entre 20 i 60 segons i és la capa més fràgil; la temptació d'escriure'n quaranta es paga en temps de pipeline (04-04) i en falsos vermells.
- Regressió visual, accessibilitat i Lighthouse CI
Tres portes més que només existeixen al navegador. La clau perquè cap no sigui insuportable és com es configura el llindar, i aquí les tres segueixen la mateixa idea del quality gate de «codi nou net» de la 02-05.
Regressió visual. Es captura la pantalla d'un component o una vista i es compara amb una imatge de referència versionada:
await expect(page.getByTestId('targeta-cita')).toHaveScreenshot('targeta-cita.png', {
maxDiffPixelRatio: 0.01, // 1 · tolerància
mask: [page.getByTestId('rellotge')], // 2 · zones dinàmiques
});- Una tolerància petita però no nul·la: l'antialiàsing de fonts varia entre execucions i una comparació exacta produeix vermells aleatoris.
- S'emmascara tot el que canvia sol: rellotges, avatars aleatoris, animacions. Sense això, la regressió visual és la nova prova flaky. I una precaució operativa: les captures es generen dins del contenidor del runner, mai al portàtil de la Nuria, perquè les fonts del sistema difereixen i totes les referències sortirien malament.
Accessibilitat automatitzada amb axe. S'executa sobre les vistes principals dins de la mateixa sessió de Playwright:
const resultats = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) // 1
.analyze();
expect(resultats.violations).toEqual([]); // 2- S'acota als criteris que l'equip s'ha compromès a complir. Activar-ho tot de cop sobre una aplicació existent dóna dues-centes violacions i el check s'ignora l'endemà.
- Zero violacions a les vistes cobertes és assolible si s'adopta vista a vista. I convé dir el que el pipeline no pot: axe detecta al voltant d'un terç dels problemes reals d'accessibilitat —contrast, etiquetes, rols, ordre de tabulació— i no substitueix una revisió manual amb lector de pantalla. És una xarxa que atrapa regressions, no un certificat.
Lighthouse CI com a gate de rendiment.
// apps/web/lighthouserc.json
{
"ci": {
"collect": { "url": ["https://pr-482.preview.reservalia.app/negocis/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 és el que fa usable aquesta porta: un sol mesurament en un runner compartit té un soroll de ±10 punts, i amb la mediana de tres el soroll baixa prou perquè un vermell signifiqui alguna cosa. Tot i així, la regla d'or és no posar llindars al caire del valor actual; si avui treus 0,88 de rendiment, el llindar es posa a 0,85 i es puja quan millori. Un gate que parpelleja s'acaba desactivant, i llavors no protegeix res.
- Desplegament atòmic i rollback de frontend
El --delete de l'apartat 6 té una conseqüència desagradable. Un usuari va carregar index.html fa vint minuts; desplegues; la seva pestanya, en navegar a l'agenda, demana agenda-Dk1x77Ze.js… que s'acaba d'esborrar. Pantalla en blanc. La solució és desplegar per versions i no sobreescriure:
VERSIO="$GITHUB_SHA" # 1
aws s3 sync apps/web/dist "s3://reservalia-web-prod/v/$VERSIO/" \
--cache-control "public, max-age=31536000, immutable" # sense --delete
aws s3 cp "s3://reservalia-web-prod/v/$VERSIO/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 construcció viu a la seva pròpia carpeta
v/<sha>/i mai no s'esborra en el desplegament. Els assets antics continuen disponibles per a les pestanyes velles; una regla de cicle de vida de S3 els elimina als 30 dies. - El desplegament consisteix a copiar un únic fitxer: l'
index.htmld'aquella versió a l'arrel. Aquesta còpia és l'operació atòmica —o hi ha l'HTML vell o el nou, mai una barreja— i és el que fa segur el conjunt.
D'aquí surt el rollback més ràpid del curs:
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"Segons, no els quatre minuts de rollback.yml (03-05), perquè no cal arrencar res: els fitxers anteriors mai no se'n van anar. Amb dos matisos honestos. Primer, la invalidació de CloudFront triga entre 30 i 60 segons a propagar-se a tots els punts de presència, així que «segons» vol dir menys d'un minut, no instantani. I segon, si el frontend nou depenia d'un canvi d'API, revertir només el frontend no n'hi ha prou: per això la regla de compatibilitat cap enrere de la 03-04 s'aplica també aquí, i l'API ha de continuar servint la versió anterior de la web. És, un cop més, expand and contract (04-06) aplicat a un contracte diferent.
Per al desplegament progressiu, l'equivalent al canari de la 03-04 es fa amb una funció a la vora que decideix quin index.html serveix segons una galeta o un percentatge de peticions. Reservalia no ho ha necessitat: amb feature flags (03-05) dins del bundle únic es cobreix el 90 % dels casos i amb molta menys maquinària.
- L'altre cas web: renderitzat al servidor
Si en lloc d'una SPA amb Vite la web fos una aplicació Next.js renderitzada al servidor, què canvia? Menys del que sembla, i el que canvia ja ho hem vist:
| Aspecte | SPA estàtica (apps/web) |
SSR (Next.js) |
|---|---|---|
| Artefacte | Carpeta de fitxers | Imatge de contenidor, com l'API |
| Desplegament | Copiar a S3 + invalidar | Rolling update a ECS (03-04) |
| Configuració | /config.json en runtime |
Variables d'entorn en arrencar… per al servidor; les del client continuen incrustant-se |
| Rollback | Reapuntar index.html: segons |
Digest anterior: 4 min (03-05) |
| Secrets | Cap de possible | Sí al servidor, mai en el que s'envia al navegador |
| Escalat | CDN, no hi ha servidors | Tasques i autoescalat, amb el seu cost |
| Portes de qualitat | Bundle, E2E, visual, axe, Lighthouse | Exactament les mateixes |
La lectura és la que ordena tot el mòdul: en recuperar un procés en execució, recuperes els problemes del mòdul 3 i les seves solucions ja escrites —artefacte per digest, health checks, rolling update, rollback en quatre minuts— i a canvi perds el desplegament atòmic de cost zero. El que no canvia és la meitat específicament frontend: el pressupost de bundle, les proves de navegador, l'accessibilitat i Lighthouse continuen sent idèntics, perquè el navegador de l'usuari no sap qui va generar l'HTML. I apareix un matís que atrapa molta gent: a Next.js continua havent-hi variables que s'incrusten al bundle del client (les NEXT_PUBLIC_*), de manera que el problema de l'apartat 5 no desapareix amb SSR, només es redueix a la part que viatja al navegador.
- Fitxa del cas
| Context | SPA React/Vite servida des de S3 + CloudFront; 340 negocis; usuaris en mòbils amb xarxa variable |
| Què continua valent tal qual | CI (02), artefacte únic i promoció (02-06), OIDC (03-02), seguretat (04-03), preparar-node (04-05) |
| Decisió 1 | Configuració en runtime via /config.json, per conservar la promoció per artefacte |
| Decisió 2 | Memòria cau eterna per a assets amb hash, no-cache per a index.html; invalidació de 2 rutes |
| Decisió 3 | Desplegament per carpeta v/<sha>/ amb còpia atòmica de l'HTML; retenció 30 dies |
| Decisió 4 | Quatre portes noves: pressupost de bundle, E2E Playwright, regressió visual, axe + Lighthouse |
| Cost | +2 min 40 s de pipeline en PR que toquen apps/web; ~8 h de feina inicial de configuració |
| Efecte 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è t'endús a qualsevol projecte | El que es mesura no es degrada: pressupostos i llindars converteixen el deteriorament lent en un check vermell |
Errors Comuns i Consells
Error 1: reconstruir el bundle al job de desplegament en lloc de descarregar l'artefacte que va passar les proves. És «construir dues vegades» (02-06) i n'hi ha prou que canviï una dependència transitòria per desplegar una cosa diferent de la validada. Error 2: posar secrets en variables VITE_* pensant que la minificació els amaga; són en text clar al bundle públic.
Error 3: invalidar /* a cada desplegament. Costa diners, triga minuts i és innecessari si els assets porten hash. Error 4: desar index.html a la memòria cau, amb la qual cosa l'usuari continua carregant la versió anterior durant hores i el desplegament «no es nota». Error 5: usar --delete sense versionar carpetes, que trenca les pestanyes obertes en esborrar els assets vells.
Error 6: quaranta proves E2E en lloc de vuit recorreguts que donen diners; el pipeline es dobla i els falsos vermells entrenen l'equip per ignorar el vermell. Error 7: llindars de Lighthouse al caire del valor actual i amb una sola execució, que produeixen un gate que parpelleja. Error 8: activar totes les regles d'axe de cop sobre una aplicació existent: dues-centes violacions equivalen a zero.
Consell 1: puja el pressupost de bundle al mateix PR que ho necessita, mai en un de separat. Consell 2: genera les captures de referència al contenidor del runner, mai en un portàtil. Consell 3: inclou la versió a config.json i mostra-la al peu de l'aplicació; et dirà en dos segons quina construcció està veient un usuari que reporta una fallada. Consell 4: aplica la compatibilitat cap enrere també a l'API respecte del frontend antic, perquè sempre hi haurà pestanyes d'ahir.
Exercicis
Exercici 1
La Nuria desplega la web a les 12:05. A les 12:07, tres negocis reporten pantalla en blanc en obrir l'agenda; a la consola del navegador apareix Failed to load module script: agenda-Dk1x77Ze.js (404). Els usuaris que obren la web per primera vegada no tenen cap problema. Explica el mecanisme exacte de la fallada, per què només afecta alguns usuaris i quins dos canvis al pipeline l'eliminen.
Exercici 2
L'equip vol que una mateixa construcció d'apps/web es provi en staging i es promocioni a producció sense reconstruir, però la URL de l'API és diferent a cada entorn i avui s'injecta amb VITE_API_URL. Dissenya la solució completa: què es canvia al codi, què produeix el pipeline, on viu la configuració de cada entorn i quina capçalera de memòria cau porta. Assenyala a més dos riscos nous que introdueix el teu disseny.
Exercici 3
Un PR puja el pressupost de «Càrrega inicial (JS)» de 65 kB a 96 kB i a la descripció diu: «necessari per a la nova llibreria de gràfics». El check passa en verd perquè el mateix PR modifica el límit. És això una fallada del disseny del gate? Argumenta la resposta i proposa què afegiries al pipeline i al procés.
Solucions
Solució 1. El mecanisme té tres temps. (1) Els usuaris afectats van carregar index.html abans del desplegament —la seva pestanya és oberta des de les 11:40—, de manera que l'HTML que tenen a memòria referencia els assets de la versió anterior, entre ells el chunk diferit agenda-Dk1x77Ze.js. (2) El desplegament va executar aws s3 sync --delete, que esborra de S3 tot el que no és al dist/ nou; com que el contingut de la vista d'agenda va canviar, el seu hash va canviar i el fitxer vell va desaparèixer. (3) A les 12:07 aquests usuaris naveguen per primera vegada a l'agenda, el navegador demana el chunk diferit —que no es va descarregar a la càrrega inicial, justament per ser diferit— i rep un 404. El mòdul no carrega, React no pot renderitzar la ruta i queda la pantalla en blanc. Només els afecta a ells perquè qui entra després de les 12:05 rep l'index.html nou, que referencia els assets nous, que sí que existeixen.
Fixa't en un detall que explica el retard de dos minuts: el problema no es manifesta en desplegar sinó en navegar, perquè els chunks diferits es demanen sota demanda. Amb càrrega inicial monolítica la fallada hauria estat immediata o inexistent; amb code splitting queda latent a cada pestanya oberta.
Els dos canvis. (a) Desplegament per carpetes versionades: pujar cada construcció a v/<sha>/ sense --delete, de manera que els assets antics romanguin mentre hi hagi pestanyes que els demanin, amb una regla de cicle de vida de S3 que els esborri als 30 dies. El desplegament passa a ser la còpia atòmica de l'index.html d'aquella versió a l'arrel. (b) Detecció i recuperació al client: capturar l'error de càrrega dinàmica de mòduls i, davant seu, recarregar la pàgina una vegada —cosa que portarà l'index.html nou i els seus assets—, amb una marca a sessionStorage per no entrar en un bucle de recàrregues. La primera mesura elimina la causa; la segona cobreix el cas residual del dia 31 i el d'un usuari amb una pestanya de fa un mes. Com a reforç, mostrar la version de config.json al peu ajuda a diagnosticar a suport: l'usuari llegeix un número i ja saps quina construcció té.
Solució 2. Al codi: s'elimina tota referència a import.meta.env.VITE_API_URL i se substitueix pel config.ts de l'apartat 5, que fa fetch('/config.json', { cache: 'no-store' }) abans de muntar l'aplicació; main.tsx passa a arrencar de manera asíncrona i la config es propaga per context de React en lloc d'importar-se com a constant. Val la pena afegir una comprovació de tipus en temps d'execució sobre el JSON rebut: si falta apiUrl, és millor un error explícit a pantalla que un undefined propagant-se per totes les crides.
Què produeix el pipeline: un únic artefacte web-dist-<sha> amb dist/, construït sense cap variable d'entorn d'entorn. Aquest artefacte és el que es desplega a staging, el que passa les E2E i el que es promociona a producció sense reconstruir, exactament com la imatge per digest de la 02-06.
On viu la configuració: tres fitxers versionats al repositori —apps/web/config/staging.json i prod.json, més dev.json— que el job de desplegament copia a l'arrel del bucket amb el nom config.json. Van al repositori perquè només contenen valors públics i així queden sota revisió, amb historial i amb CODEOWNERS. Si algun valor no fos públic, no aniria aquí: ho resoldria l'API després d'autenticar. Capçaleres: config.json amb no-cache, must-revalidate i present a la llista d'invalidació al costat d'index.html; els assets amb hash, amb max-age=31536000, immutable.
Dos riscos nous. (1) Una petició al camí crític: l'aplicació no renderitza res fins que config.json respon, així que una fallada o una lentitud d'aquesta petició és una pantalla en blanc. Es mitiga amb <link rel="preload"> per demanar-lo en paral·lel, amb un reintent i amb un missatge d'error llegible en lloc del buit. (2) Desincronització entre l'HTML i la config: com que són dos fitxers amb memòria cau independent, existeix una finestra en què un usuari té el bundle nou i la config vella. Si un desplegament introdueix una clau nova i obligatòria, aquesta combinació falla. Es mitiga tractant la configuració com un contracte amb compatibilitat cap enrere —claus noves sempre opcionals amb valor per defecte, i les velles es retiren una versió després—, que és expand and contract (04-06) aplicat a un fitxer JSON. Un tercer risc menor, però real: com que la config ja no és al bundle, un desplegament que copiï config.staging.json a producció no el detecta cap compilador; convé un smoke test posterior al desplegament que demani /config.json i verifiqui que entorn val el que ha de valer.
Solució 3. No és una fallada del disseny, és el disseny funcionant, però està incomplet. El propòsit del pressupost mai no va ser impedir que l'aplicació creixi —una aplicació que guanya funcionalitat creix— sinó impedir que creixi sense que ningú ho decideixi. En obligar a modificar el límit al mateix PR, l'augment de 31 kB apareix al diff, va a la revisió i queda a l'historial de git amb data, autor i motiu. Això és exactament el que es buscava: una conversa forçada en el moment en què es pot tenir. Compara-ho amb l'alternativa de no tenir gate, en què aquests 31 kB entren sense que ningú els vegi i apareixen sis mesos després com a «l'aplicació va lenta», sense manera de saber quin PR els va portar.
El que falta és que la conversa tingui prou dades. Hi afegiria quatre coses. (1) Un comentari automàtic al PR amb la comparativa davant de main —mètrica actual, límit anterior, límit proposat, diferència en kB i en temps estimat de descàrrega en 4G—, perquè el revisor vegi l'impacte en segons i no en kilobytes abstractes. (2) Una regla a CODEOWNERS (02-07) sobre .size-limit.json, de manera que canviar un pressupost requereixi l'aprovació de la Marta i no només la del company que revisa la funcionalitat. (3) Exigir a la plantilla de PR que un augment de límit vingui amb alternatives descartades: es pot carregar la llibreria de gràfics de manera diferida, només a la vista que la fa servir, i deixar la càrrega inicial intacta? En aquest cas concret això és gairebé segur que sí, i convertiria un augment de 31 kB a la càrrega inicial en un chunk diferit amb el seu propi pressupost. (4) Una revisió trimestral dels pressupostos al costat de les dades reals de camp, perquè el senyal definitiu no és el número del CI sinó el rendiment percebut per la recepcionista amb 4G. Amb aquestes quatre peces, el gate deixa de ser un semàfor que es pot pintar de verd i passa a ser el que ha de ser: un mecanisme que fa visible una decisió i obliga a justificar-la.
Conclusió
apps/web ha deixat de ser l'acompanyant de l'API i ha mostrat que un frontend, encara que comparteixi pipeline, repositori i equip, té una física pròpia. Desplega fitxers i no processos, cosa que li dóna el rollback més barat del curs —reapuntar index.html a una carpeta v/<sha>/ anterior, menys d'un minut— a canvi d'exigir un desplegament dissenyat per ser atòmic i per no esborrar el que les pestanyes obertes continuen demanant. El seu codi s'executa al navegador d'una altra persona, cosa que prohibeix qualsevol secret i obliga a tractar la versió anterior com un client que no pots obligar a actualitzar-se. I la seva configuració s'incrusta en compilar, cosa que xocava de ple amb la promoció per artefacte de la 02-06 fins que la vam moure a runtime amb /config.json, acceptant a canvi una petició al camí crític i un contracte JSON que també necessita compatibilitat cap enrere. Sobre aquesta base hi vam afegir les quatre portes que només tenen sentit al navegador —pressupost de bundle amb size-limit, recorreguts E2E amb Playwright contra la previsualització del PR, regressió visual amb tolerància i màscares, i axe més Lighthouse CI amb llindars realistes i tres execucions—, i vam afinar la memòria cau de CloudFront fins a convertir una invalidació de quatre minuts en una de dues rutes i quaranta segons. El cas SSR va tancar el cercle mostrant que, tan bon punt torna a haver-hi un procés en execució, tornen íntegres els mecanismes del mòdul 3, mentre que la meitat específicament frontend roman idèntica.
El fil que connecta amb el que ve és el de la fila més incòmoda de la primera taula: el frontend és un client antic que no pots obligar a actualitzar-se, però almenys n'hi ha prou que l'usuari recarregui la pàgina. A la lliçó següent, Cas d'Estudi: Aplicació Mòbil, aquesta mateixa propietat es porta a l'extrem. A Reservalia Pro —l'app React Native amb què el professional gestiona la seva agenda— l'usuari decideix quan actualitza i pot no fer-ho mai, una botiga revisa cada versió durant hores o dies abans de publicar-la, i no existeix el rollback d'una versió ja distribuïda. Tot el que aquí vam resoldre amb una invalidació de CloudFront caldrà resoldre-ho amb signatures de codi, canals de distribució, desplegament esglaonat per percentatge i una disciplina de compatibilitat d'API que és l'equivalent mòbil de l'expand and contract de la 04-06.
Curs de CI/CD: Integració i Desplegament Continu
Mòdul 1: Introducció al CI/CD
- Conceptes Bàsics de CI/CD
- Beneficis del CI/CD
- Eines Populars de CI/CD
- El Projecte del Curs: l'Aplicació que Automatitzarem
- Mètriques DORA: Com es Mesura el Lliurament de Programari
Mòdul 2: Integració Contínua (CI)
- Introducció a la Integració Contínua
- Configuració d'un Entorn de CI
- Automatització de la Construcció
- Proves Automatitzades
- Qualitat de Codi i Anàlisi Estàtica
- Artefactes, Versionat i Promoció
- Integració amb el Control de Versions
Mòdul 3: Desplegament Continu (CD)
- Introducció al Desplegament Continu
- Automatització del Desplegament
- Infraestructura com a Codi i Entorns Reproduïbles
- Estratègies de Desplegament
- Feature Flags, Rollback i Recuperació davant Errors
- Monitoratge i Retroalimentació
Mòdul 4: Pràctiques Avançades de CI/CD
- Pipelines de CI/CD
- Gestió de Dependències
- Seguretat en CI/CD
- Escalabilitat i Rendiment
- Pipeline as Code: Plantilles, Reutilització i Proves del Pipeline
- Bases de Dades al Pipeline: Migracions Segures
Mòdul 5: Implementació de CI/CD en Projectes Reals
- Cas d'Estudi: Projecte Web
- Cas d'Estudi: Aplicació Mòbil
- Cas d'Estudi: Microserveis
- Cas d'Estudi: Modernitzar un Projecte Legacy
Mòdul 6: Eines i Tecnologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker i Kubernetes
- GitHub Actions a Fons
- Comparativa i Criteris per Triar Eina
Mòdul 7: Exercicis Pràctics
- Exercici 1: Configuració d'un Pipeline Bàsic
- Exercici 2: Integració de Proves Automatitzades
- Exercici 3: Desplegament en un Entorn de Producció
- Exercici 4: Monitoratge i Retroalimentació
- Exercici 5: Enfortir el Pipeline amb Seguretat i Secrets
- Projecte Final: Pipeline Complet d'Extrem a Extrem
