Ir al contenido

El pipeline de despliegue

Al final de este módulo cada push a main despliega a desarrollo, sin ninguna clave de AWS guardada en GitHub, y con las cabeceras de caché correctas.

Trabajamos en el repositorio de la aplicación.

Paso 8.1 · Registrar las variables del entorno dev

Sección titulada «Paso 8.1 · Registrar las variables del entorno dev»

Objetivo: que el workflow sepa qué rol asumir y a dónde subir.

Junta los datos desde el repositorio de infraestructura:

Ventana de terminal
cd taskflow-p01-infra
make bootstrap-output # deploy_role_dev_arn
make output ENV=dev # bucket_name, distribution_id, site_url

Y regístralos en el repositorio de la aplicación:

Ventana de terminal
cd ../taskflow-p01-app
make shell
Ventana de terminal
gh variable set AWS_REGION --env dev --body "us-east-1"
gh variable set AWS_DEPLOY_ROLE --env dev --body "arn:aws:iam::123456789012:role/taskflow-p01-deploy-dev"
gh variable set AWS_S3_BUCKET --env dev --body "taskflow-p01-dev-123456789012"
gh variable set AWS_CLOUDFRONT_ID --env dev --body "E1234567890ABC"
gh variable set SITE_URL --env dev --body "https://d111111abcdef8.cloudfront.net"
gh variable list --env dev
exit

Qué deberías ver: cinco variables listadas.

Paso 8.2 · Tu primer despliegue automático

Sección titulada «Paso 8.2 · Tu primer despliegue automático»

Objetivo: ver el pipeline funcionar de punta a punta.

Cambia algo visible, por ejemplo el título del tablero:

src/views/tablero.ts
seccion.append(
h("h1", {}, "Tablero"),
resumen,
formulario(scope),
filtros(scope),
);
seccion.append(
h("h1", {}, "Mis tareas"),
resumen,
formulario(scope),
filtros(scope),
);

Y súbelo por un pull request:

Ventana de terminal
git switch -c cambio-titulo
git add -A
git commit -m "feat(tablero): cambiar el título a Mis tareas"
git push -u origin cambio-titulo
gh pr create --fill

Cuando pase la validación, fusiónalo y observa:

Ventana de terminal
gh run watch

Qué deberías ver: el workflow corre, se autentica contra AWS sin ninguna clave, sube los archivos, y termina. Abre el sitio de desarrollo: el título nuevo está ahí.

Ver el workflow completo y cómo funciona la autenticación
.github/workflows/deploy-dev.yml
name: Desplegar a dev
# Cada push a main va a desarrollo. Producción es otra historia: solo se llega
# por tag, y eso vive en release.yml.
on:
push:
branches: [main]
workflow_dispatch:
# Sin esto no hay OIDC. `id-token: write` es lo que permite al job pedirle a
# GitHub un token firmado para presentárselo a AWS.
permissions:
id-token: write
contents: read
# Si llegan dos pushes seguidos, cancela el despliegue anterior: no tiene
# sentido publicar una versión que ya quedó obsoleta.
concurrency:
group: deploy-dev
cancel-in-progress: true
jobs:
desplegar:
runs-on: ubuntu-latest
# El nombre del entorno es parte del contrato de seguridad: determina el
# claim `sub` del token OIDC, y el rol de AWS solo confía en este valor.
environment:
name: dev
url: ${{ vars.SITE_URL }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Instalar dependencias
run: npm ci
- name: Verificar tipos
run: npm run typecheck
- name: Compilar
env:
APP_VERSION: dev-${{ github.run_number }}
APP_COMMIT: ${{ github.sha }}
run: npm run build
- name: Autenticar contra AWS
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
# Nombre visible en CloudTrail: agradecerás poder rastrear quién
# desplegó qué cuando algo salga mal.
role-session-name: gha-deploy-dev-${{ github.run_id }}
# ═══════════════════════════════════════════════════════════════════
# PASO 8.5 — Las cabeceras de caché
#
# El sync de abajo sube los archivos pero no dice nada sobre caché, así
# que CloudFront aplica su TTL por defecto de un día. Ese es el síntoma
# que vas a diagnosticar en el módulo 8.
#
# Ojo con --delete, que aquí NO se usa a propósito. Borrar los assets
# viejos rompería a quien tenga la página abierta con el index.html
# anterior, y además impediría revertir un release sin recompilar.
# ═══════════════════════════════════════════════════════════════════
- name: Subir assets
env:
BUCKET: ${{ vars.AWS_S3_BUCKET }}
run: |
aws s3 sync dist/ "s3://${BUCKET}/" \
--exclude "index.html" \
--only-show-errors
# PASO 8.5: agregar
# --cache-control "public,max-age=31536000,immutable"
- name: Subir index.html
env:
BUCKET: ${{ vars.AWS_S3_BUCKET }}
run: |
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--content-type "text/html; charset=utf-8" \
--only-show-errors
# PASO 8.5: agregar
# --cache-control "public,max-age=0,must-revalidate"
# ═══════════════════════════════════════════════════════════════════
# PASO 8.4 — Invalidar la caché de CloudFront
#
# Falta a propósito. Primero vas a desplegar un cambio y comprobar que NO
# lo ves en el sitio. Ahí entiendes para qué sirve invalidar.
#
# Descomenta cuando la guía te lo indique.
# ═══════════════════════════════════════════════════════════════════
# - name: Invalidar la caché de CloudFront
# env:
# DISTRIBUTION: ${{ vars.AWS_CLOUDFRONT_ID }}
# run: |
# # Solo hace falta invalidar el index.html y la raíz. Los assets son
# # inmutables y llevan hash: invalidar /* costaría dinero sin motivo.
# aws cloudfront create-invalidation \
# --distribution-id "${DISTRIBUTION}" \
# --paths "/" "/index.html" \
# --query 'Invalidation.Id' \
# --output text
- name: Resumen
env:
SITE_URL: ${{ vars.SITE_URL }}
run: |
{
echo "### Desplegado a dev"
echo ""
echo "- Versión: \`dev-${{ github.run_number }}\`"
echo "- Commit: \`${GITHUB_SHA:0:7}\`"
echo "- Sitio: ${SITE_URL}"
} >> "$GITHUB_STEP_SUMMARY"

El permiso id-token: write es lo que permite al job pedirle a GitHub un token OIDC. Sin esa línea la autenticación falla, y es el error número uno al montar esto por primera vez porque el mensaje no lo sugiere. Fíjate también en que contents es read: declarar los permisos al mínimo es lo correcto.

El nombre del entorno es parte de la seguridad. GitHub construye el claim sub del token a partir del repositorio y del entorno: repo:TU/taskflow-p01-app:environment:dev. Esa cadena exacta es la que el rol de AWS exige. Cambia el nombre del entorno y el despliegue deja de funcionar.

El intercambio, en cuatro pasos: el job pide a GitHub un token firmado que dice qué repositorio, qué entorno y qué referencia de git; configure-aws-credentials lo presenta a STS con AssumeRoleWithWebIdentity; STS lo verifica contra el proveedor OIDC y comprueba las condiciones del rol; si todo coincide, devuelve credenciales temporales de una hora.

No hay ninguna clave de larga duración. No hay nada que rotar.

role-session-name no es cosmético. Ese nombre aparece en CloudTrail. Cuando dentro de tres meses alguien pregunte quién sobrescribió el sitio el martes a las once, la respuesta está ahí con el número de ejecución exacto.

Objetivo: provocar el problema de caché para entender qué lo causa.

Haz otro cambio visible y despliégalo igual que antes:

src/views/tablero.ts
seccion.append(
h("h1", {}, "Mis tareas"),
resumen,
formulario(scope),
filtros(scope),
);
seccion.append(
h("h1", {}, "Tablero de tareas"),
resumen,
formulario(scope),
filtros(scope),
);

Espera a que el workflow termine y recarga el sitio.

Qué deberías ver: el cambio no aparece. Y no es un error del pipeline: el workflow terminó bien y el archivo está en S3.

Ventana de terminal
curl -sI https://d111111abcdef8.cloudfront.net/ | grep -iE 'cache-control|age|x-cache'

Qué deberías ver: algo como x-cache: Hit from cloudfront y un age alto, sin ninguna cabecera cache-control útil.

Ahí está la causa. El sync sube los archivos sin decir nada sobre caché, así que S3 no envía Cache-Control. Y cuando el origen no dice nada, la política Managed-CachingOptimized de CloudFront aplica su TTL por defecto, que es un día.

Tu index.html está cacheado veinticuatro horas en cada punto de presencia.

Objetivo: forzar a CloudFront a olvidar lo que tiene guardado.

Abre el workflow y busca el marcador PASO 8.4:

.github/workflows/deploy-dev.yml
# - name: Invalidar la caché de CloudFront
# env:
# DISTRIBUTION: ${{ vars.AWS_CLOUDFRONT_ID }}
# run: |
# aws cloudfront create-invalidation \
# --distribution-id "${DISTRIBUTION}" \
# --paths "/" "/index.html" \
# --query 'Invalidation.Id' \
# --output text
- name: Invalidar la caché de CloudFront
env:
DISTRIBUTION: ${{ vars.AWS_CLOUDFRONT_ID }}
run: |
aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION}" \
--paths "/" "/index.html" \
--query 'Invalidation.Id' \
--output text

Descoméntalo, súbelo por pull request, fusiona, y espera al despliegue.

Qué deberías ver: ahora sí, el cambio aparece.

Paso 8.5 · Arreglar la causa, no el síntoma

Sección titulada «Paso 8.5 · Arreglar la causa, no el síntoma»

Objetivo: que la caché funcione a tu favor en lugar de tener que combatirla.

La invalidación resolvió el síntoma, pero seguimos con un problema: los assets tampoco se están cacheando bien. Con el TTL por defecto de un día, cada usuario vuelve a descargar el JavaScript entero cada mañana, aunque no haya cambiado nada.

Busca el marcador PASO 8.5 y agrega las dos cabeceras:

.github/workflows/deploy-dev.yml
- name: Subir assets
run: |
aws s3 sync dist/ "s3://${BUCKET}/" \
--exclude "index.html" \
--cache-control "public,max-age=31536000,immutable" \
--only-show-errors
- name: Subir index.html
run: |
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--content-type "text/html; charset=utf-8" \
--cache-control "public,max-age=0,must-revalidate" \
--only-show-errors

Despliega y vuelve a mirar las cabeceras:

Ventana de terminal
curl -sI https://d111111abcdef8.cloudfront.net/ | grep -i cache-control
curl -sI https://d111111abcdef8.cloudfront.net/assets/index.eXtURQbc.js | grep -i cache-control

Qué deberías ver: el index.html con max-age=0, must-revalidate, y el asset con max-age=31536000, immutable.

Un año para los assets. Llevan un hash del contenido en el nombre, así que su contenido nunca cambia. immutable le dice al navegador que no se moleste ni en preguntar.

Cero para el HTML. Es el único archivo sin hash, y el que apunta a la versión vigente de los assets. Si lo cachearas, desplegarías y no verías nada, que es exactamente lo que te pasó en el paso 8.3.

El orden importa. Los assets primero, el index.html al final. Si se subiera antes, durante unos segundos el HTML referenciaría archivos que todavía no existen, y quien cargara la página en ese momento vería una pantalla en blanco. Subirlo al final lo convierte en el interruptor que activa la nueva versión.

Objetivo: verificar que este rol no puede hacer daño más allá de dev.

Ventana de terminal
make shell
Ventana de terminal
aws iam get-role-policy \
--role-name taskflow-p01-deploy-dev \
--policy-name taskflow-p01-dev-deploy

Qué deberías ver: los ARN del bucket y la distribución de desarrollo, únicamente. Sin comodines.

Ese rol no puede crear infraestructura, no puede leer otros buckets, y no puede tocar producción. Si el token de un workflow se filtrara, el daño posible queda acotado a sobrescribir el sitio de desarrollo.

Ventana de terminal
# variables del entorno dev
make shell
gh variable set AWS_REGION --env dev --body "us-east-1"
# ... las otras cuatro
gh variable list --env dev
# ciclo de trabajo
git switch -c mi-cambio
git add -A && git commit -m "feat: ..."
git push -u origin mi-cambio
gh pr create --fill
# fusionar, y luego
gh run watch
# diagnosticar caché
curl -sI <site_url>/ | grep -iE 'cache-control|age|x-cache'
# comprobar los permisos del rol
aws iam get-role-policy \
--role-name taskflow-p01-deploy-dev \
--policy-name taskflow-p01-dev-deploy

Ahora el release a producción por tag.