Saltar a contenido

Pipeline de despliegue con GitHub Actions

Análisis, requerimientos y plan por fases para automatizar el despliegue del storefront: una imagen por entorno, construida en GitHub Actions, publicada en GHCR y desplegada por un runner self-hosted en el host. Reemplaza el procedimiento manual que hoy documenta el runbook —«No hay pipeline de deploy en este repositorio»—.

Alcance. Los hallazgos de la sección Diagnóstico están medidos contra develop y contra la imagen construida el 26/08/2026, no inferidos: cada fila dice dónde mirar. El plan por fases es una propuesta; las fases 2 y 3 dependen de definiciones del equipo que el repositorio no puede contestar y que están listadas al final.

Respuesta corta

Tres decisiones fijan la forma del pipeline. Se tomaron el 26/08/2026 y cada una cierra una bifurcación que cambiaba el diseño entero, no un detalle de implementación.

Pregunta Decisión Por qué
Modelo de imagen Una imagen por entorno Las NEXT_PUBLIC_* se inlinean durante next build. Una imagen de staging ya tiene el índice _dev de Algolia horneado en el bundle: promoverla a producción es imposible cambiándole el .env
Mecanismo de despliegue Runner self-hosted en el host El job corre dentro de la red de Compulandia y solo hace docker pull + compose up. No hay clave SSH del servidor guardada en GitHub
Alcance del artefacto Migrar a output: standalone Verificado en la fase 0: 1.28 GB → 264 MB, y REVALIDATE_SECRET deja de viajar dentro de la imagen
Disparadores Push a develop para staging, tag v* para producción Staging necesita feedback rápido; producción, un acto deliberado. Sin PRs en el flujo del equipo, el tag es el punto de control
Fuente de las variables GCP Secret Manager, un secret por entorno con el .env completo Un solo archivo para build y runtime, con historial de versiones legible. Los secrets de GitHub son write-only: no dejan ver el valor anterior

La imagen única para ambos entornos no es posible sin refactor

Es la petición natural —una imagen que se despliegue en producción o en staging— y choca con cómo funciona Next. El propio runbook lo advierte: las NEXT_PUBLIC_* se congelan en el build. Hacerla promovible exige sacar del bundle las ~6 variables que difieren entre entornos y servirlas en runtime. Queda registrado como evaluación de la fase 4, no como parte de este trabajo.

Diagnóstico

Estado del artefacto

Instantánea del 26/08/2026, antes de la fase 0

Las filas sobre peso, npm install, node:20 y el secreto en la imagen quedaron resueltas ese mismo día — ver la tabla R1 y el ADR-0005. Se dejan escritas porque son el motivo de las decisiones que siguen.

Hallazgo Evidencia Impacto en el pipeline
La imagen pesa 1.28 GB node_modules 820 MB + .next 428 MB Cada build empuja ~1 GB al registry
REVALIDATE_SECRET viajaba dentro de la imagen (resuelto en la fase 0) .next/cache/webpack/{server,client}-production/0.pack Cualquiera con permiso de pull lo extraía
Se instala con npm install, no npm ci Dockerfile, etapa builder Dos builds del mismo commit pueden diferir
La imagen se construye sobre node:20-alpine package.json declara engines: node >= 22 (ADR-0004, 26/08/2026) Se compila sobre una versión que el proyecto declara no soportada
No hay registry ni tags de imagen Runbook, Rollback Volver atrás exige recompilar: minutos
No hay endpoint de salud Solo existe src/app/api/credit-form El runner no puede afirmar que el deploy funcionó
El host de deploy no está registrado en el repo Runbook, Pendiente No hay a dónde apuntar el runner todavía

El secreto en el cache de webpack — resuelto en la fase 0

La etapa de runtime hacía COPY --from=builder /app/.next ./.next, que arrastraba también .next/cache. Ese cache contiene el snapshot de entorno del build, con el valor de REVALIDATE_SECRET en texto plano. Mientras la imagen no salía del host era un detalle; se volvía un problema real el día que se publicara en un registry. output: standalone lo resolvió de raíz: el cache no forma parte del output.

Lo que ya se corrigió

El build fallaba dentro de Docker por dos causas acumuladas, ambas arregladas y verificadas el 26/08/2026:

  1. No existía .dockerignore. COPY . . metía 745 MB de .next viejo y 821 MB de node_modules del host, pisando el npm install de la etapa builder y haciendo que webpack reutilizara chunks compilados de builds anteriores.
  2. generateStaticParams llamaba a Medusa sin protección. Cada caída del backend rompía el build entero, con el error saltando de categories a collections según qué chunk se reutilizaba.

El build ahora termina incluso con el backend devolviendo 502 —comprobado, porque el backend de dev se cayó dos veces durante este mismo análisis—.

Arquitectura del pipeline

flowchart LR
  subgraph sm["GCP Secret Manager"]
    direction TB
    ss["storefront-staging-env"]
    sp["storefront-prod-env"]
  end
  subgraph gh["GitHub Actions"]
    direction TB
    pd["push a develop"] --> bs["build<br/>ubuntu-latest"]
    bs -->|docker push| gs["GHCR<br/>:staging-SHA"]
    pm["tag v1.3.0<br/>guard: sobre main"] --> bp["build<br/>ubuntu-latest"]
    bp -->|docker push| gp["GHCR<br/>:v1.3.0 + :prod-SHA"]
  end
  subgraph infra["Infraestructura Compulandia"]
    direction TB
    rs["runner self-hosted<br/>VM red local"] --> hs["host staging"]
    rp["runner self-hosted<br/>GCP"] --> hp["host produccion"]
  end
  ss -. NEXT_PUBLIC_* .-> bs
  sp -. NEXT_PUBLIC_* .-> bp
  gs -->|docker pull| rs
  gp -->|docker pull| rp
  ss -. escribe .env .-> rs
  sp -. escribe .env .-> rp

Dos carriles independientes. Lo que los separa no es la configuración del contenedor sino el momento del build: los build-args entran ahí, no en el run, y por eso hacen falta dos imágenes.

El mismo secret alimenta los dos lados de cada carril —las NEXT_PUBLIC_* al build, el .env completo al host— y por eso hay una sola fuente de verdad y no dos lugares que mantener sincronizados. En la infraestructura de Compulandia nada se ejecuta en GitHub: el runner vive en el host, tira de la imagen y escribe el .env.

Consecuencia que hay que asumir

Hoy el build funciona porque .env.local entra al contexto por COPY . .. En Actions ese archivo no existe: está en .gitignore. El pipeline tiene que pasar las 11 NEXT_PUBLIC_* como ARG/ENV explícitos —tomadas del secret— y el .dockerignore pasa a excluir .env* para que un archivo suelto en una máquina no se cuele en un build. Es el mismo ARG que se quitó del Dockerfile el 26/08/2026, pero al revés: antes llegaba vacío y pisaba el valor bueno de .env.local; ahora llega siempre con valor y es la única fuente.

Modelo de disparadores

Dos modelos conviviendo, cada uno donde corresponde. Staging sin ceremonia, porque es donde hay que ver los cambios rápido; producción con un acto explícito y versionado.

Disparador Imagen Despliega Guard
push a staging staging-<sha> staging, automático —
tag v1.3.0 v1.3.0 y prod-<sha> producción el tag debe estar sobre staging o sobre main
workflow_dispatch — redespliega una versión ya construida —

branches y tags juntos se comportan como OR, no como AND

Un tag de git no pertenece a ninguna rama: apunta a un commit, que puede ser alcanzable desde varias ramas o desde ninguna. Por eso Actions no puede filtrar por rama en un evento de tag, y declarar branches y tags en el mismo push hace que el workflow corra cuando cualquiera de los dos filtros matchea:

on:
  push:
    branches: [main]   # NO restringe el tag
    tags: ['v*']       # corre con cualquier push a main O cualquier tag v*

La restricción por rama se verifica dentro del job:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0            # sin historia completa no hay ancestría que comprobar
- name: El tag debe apuntar a un commit de staging o de main
  run: |
    git fetch --no-tags origin \
      +refs/heads/main:refs/remotes/origin/main \
      +refs/heads/staging:refs/remotes/origin/staging

    if   git merge-base --is-ancestor "$GITHUB_SHA" origin/main;    then rama=main
    elif git merge-base --is-ancestor "$GITHUB_SHA" origin/staging; then rama=staging
    else
      echo "::error::El tag $GITHUB_REF_NAME no está sobre staging ni sobre main"
      exit 1
    fi

Aceptar tags desde staging permite desplegar una versión ya validada sin esperar el merge a main. El costo: main deja de ser el registro de lo que corre en producción si el merge se demora. La disciplina que lo compensa es mergear staging a main después de cada despliegue.

Los dos guards cubren cosas distintas y se complementan: la deployment tag policy del Environment —que aplica GitHub— garantiza que solo refs llamados v* desplieguen a producción, pero filtra por nombre y no puede saber sobre qué rama está el tag. Eso lo comprueba el job.

Este modelo también mejora el rollback: el objetivo pasa a ser una versión legible («volvé a la v1.2.4») en lugar de un hash (prod-a1b2c3d).

Requerimientos

listo es trabajo ya hecho y verificado; bloqueado depende de una definición del equipo.

R1 · El artefacto

ID Requerimiento Por qué Estado
R1.1 Contexto de build aislado del host Evita que .next y node_modules locales entren a la imagen listo
R1.2 Base node:22-alpine El ADR-0004 fijó engines: node >= 22 y la imagen estaba en 20 listo
R1.3 npm ci en vez de npm install Build reproducible. Verificado: el lockfile está sincronizado listo
R1.4 output: standalone 264 MB en vez de 1.28 GB, sin .next/cache listo
R1.5 Ningún secreto server-side dentro de la imagen REVALIDATE_SECRET estaba en el cache de webpack listo
R1.6 El build no depende de que Medusa responda El backend cayó dos veces durante este análisis listo

R2 · El pipeline

ID Requerimiento Por qué Estado
R2.1 Workflow que construye y publica en GHCR Es el artefacto versionado que hoy no existe listo
R2.2 Tag por commit: staging-<sha> / prod-<sha> Habilita el rollback sin recompilar que el runbook pide listo
R2.3 Push a staging → staging; tag v* → producción Staging necesita feedback rápido; producción, un acto deliberado y versionado listo
R2.7 El tag v* debe apuntar a un commit de staging o de main Un tag no pertenece a ninguna rama: hay que verificarlo en el job listo
R2.8 Crear la rama staging y documentarla en AGENTS.md El modelo del equipo es develop para features, staging para pruebas y aceptación listo
R2.4 Cache de capas entre builds Sin cache, cada build reinstala todo listo
R2.5 concurrency por entorno Dos pushes seguidos no pueden pisarse el deploy listo
R2.6 workflow_dispatch con tag a desplegar Rollback y redeploy sin tocar el host listo

R3 · Entorno y secretos

ID Requerimiento Por qué Estado
R3.1 Un secret por entorno en GCP Secret Manager, con el .env completo como payload Una sola fuente para el build y para el runtime listo
R3.2 Workload Identity Federation entre GitHub y GCP Evita guardar una clave de servicio en GitHub o en los hosts listo
R3.3 El job de build extrae las NEXT_PUBLIC_* del secret y las pasa como build-args En CI no existe .env.local: está en .gitignore listo
R3.4 El job de deploy escribe el .env en el host antes de levantar Elimina la deriva entre lo configurado y lo que corre listo
R3.5 Environments staging y production en GitHub Habilitan la deployment tag policy y el registro de despliegues listo
R3.6 .dockerignore excluye .env* Un archivo suelto en una máquina no puede colarse a un build listo
R3.7 Definir las variables que el runbook documenta y no existen PAYLOAD_USER_EMAIL, PAYLOAD_USER_PASSWORD, NEXT_PUBLIC_RECAPTCHA_SITE_KEY pendiente — la home funciona sin ellas
R3.8 Un solo nombre por rol para la URL del backend Eran tres; NEXT_PUBLIC_MEDUSA_BACKEND_URL se eliminó el 27/08/2026 listo

De dónde salen las variables al compilar

En Actions no hay ningún archivo de entorno: .env.local está en .gitignore y nunca llega al runner. Las variables se dividen en dos grupos que viajan por caminos distintos, desde el mismo secret:

Grupo Cuándo se necesita Cómo llega ¿Queda en la imagen?
Las 11 NEXT_PUBLIC_* Build — Next las inlinea en el bundle build-args que el job extrae del secret Sí, horneadas
Server-side (ALGOLIA_ADMIN_API_KEY, RECAPTCHA_SECRET_KEY, REVALIDATE_SECRET, PAYLOAD_USER_*) Runtime El job de deploy escribe el .env en el host No

Los server-side no tienen nada que hacer en el build, y mantenerlos fuera de la imagen es justamente lo que aseguró la fase 0.

El .env del host pasa a ser generado

A partir de la fase 3, editar el .env por SSH deja de tener efecto: el próximo despliegue lo reescribe desde el secret. Es deliberado —así desaparece la deriva entre lo que alguien cree que está configurado y lo que realmente corre— pero cambia el hábito: para cambiar un valor se edita el secret y se redespliega.

Los runners self-hosted también reciben el token OIDC de GitHub

Es lo que hace viable este modelo con un host fuera de GCP. Ni la VM de la red local ni la de GCP necesitan una clave de servicio: la credencial es el token del job, que vive minutos. La VM local solo necesita salida HTTPS hacia las APIs de GCP.

R4 · Despliegue y verificación

ID Requerimiento Por qué Estado
R4.1 Runner self-hosted por host, con labels El job de deploy tiene que correr dentro de la red bloqueado
R4.2 Endpoint /api/health que no llame a Medusa /py da 500 con el backend caído: no sirve como señal listo
R4.3 Smoke test después del up -d Un deploy que no se verifica no es un deploy listo
R4.4 El tag es el gate, reforzado con deployment tag policy v* Crear el tag es el acto explícito; la policy impide que otro ref despliegue a producción listo
R4.5 Rollback documentado por tag Reemplaza el «volver al commit y recompilar» del runbook listo
R4.6 El compose despliega desde una imagen del registry Sin image:, docker compose pull no tiene de dónde bajar listo

Lo que cambia en el rollback

Es el beneficio más concreto, y el que el propio runbook lista como mejora pendiente: «taguear imágenes por commit para poder volver sin recompilar».

Hoy · en el host, sobre el código Con registry · tag por commit
Pasos git checkout <sha> → npm install → next build → compose up -d elegir tag anterior → docker pull → compose up -d
Orden de magnitud minutos segundos
Por qué Instalar y compilar ocurren en el momento del rollback Ya ocurrieron una vez, cuando se construyó esa imagen

Plan de implementación

Las fases son una secuencia real: cada una deja algo verificable y la siguiente se apoya en eso. Las fases 0 y 1 no tocan GitHub Actions — arreglan el artefacto antes de automatizar su publicación, que es el orden correcto: automatizar la publicación de una imagen que filtra un secreto solo hace que el problema se replique más rápido.

Fase 0 · Higiene del artefacto — completada el 2026-08-26

Requerimientos R1.2 – R1.5. Registrada en el ADR-0005.

  • Subir la base a node:22-alpine en ambas etapas, alineando con el engines del ADR-0004.
  • Cambiar npm install por npm ci.
  • Agregar output: "standalone" a next.config.js.
  • Reescribir la etapa de runtime: copiar .next/standalone, .next/static y public/; arrancar con node server.js.
  • Confirmar si check-env-variables.js sigue corriendo al arrancar en modo standalone. Si no, mover el chequeo a un entrypoint — no perder la validación.
  • Registrar la decisión en un ADR nuevo. Las reglas del índice de ADRs prohíben editar un ADR para cambiar la decisión: se escribe uno que lo reemplaza y el viejo pasa a reemplazado por ADR-NNNN.

Verificado el 2026-08-26

Imagen de 264 MB (desde 1.28 GB); grep de los tres secretos server-side devuelve 0 archivos y .next/cache ya no existe en la imagen; el contenedor arranca en el puerto 8000, sirve .next/static y public/, aborta con exit 1 si falta la publishable key y responde a SIGTERM en 284 ms. Con el backend disponible y un build limpio: 687 páginas prerenderizadas, todas las rutas probadas en 200, contenido del CMS en la home y el optimizador de imágenes funcionando. Detalle en el ADR-0005.

Fase 1 · Señal de salud propia — completada el 2026-08-27

Requerimiento R4.2.

  • Crear /api/health que responda 200 sin llamar a Medusa ni a Payload, devolviendo el SHA del commit y la versión.
  • Inyectar el SHA como ARG en el build para que la respuesta diga qué imagen está corriendo.
  • Agregar healthcheck al servicio en docker-compose.yml.

Verificado el 2026-08-27

Con MEDUSA_BACKEND_URL apuntando a un puerto muerto, /api/health responde 200 y /py responde 500. La ruta no hace ningún fetch saliente (verificado con logging.fetches.fullUrl), devuelve Cache-Control: no-store y el healthcheck del compose queda en healthy.

Usar 127.0.0.1 en el healthcheck, nunca localhost

Dentro del contenedor localhost resuelve a ::1 (IPv6) y el servidor escucha en 0.0.0.0 (IPv4). busybox wget no reintenta con la otra familia, así que con localhost el healthcheck falla siempre, con la aplicación funcionando perfecto — y en el pipeline eso se ve como un deploy que falla sin causa aparente.

Fase 2 · Construir y publicar — completada el 2026-08-27

Requerimientos R2.1 – R2.4 y R3.1 – R3.4.

  • Crear los Environments staging y production con sus variables y secretos.
  • Declarar las NEXT_PUBLIC_* como ARG/ENV en el Dockerfile y pasarlas desde el Environment.
  • Excluir .env* del .dockerignore, ahora que las variables llegan por build-args.
  • Workflow imagen.yml: dispara en push a develop y main, login a GHCR con el GITHUB_TOKEN, build y push con tag por SHA y tag móvil por entorno.
  • Cache de capas entre corridas para que el build no reinstale de cero.

Criterio de verificación

Un push a develop deja la imagen en GHCR; un docker pull desde el host la baja y levanta el sitio con los valores de staging correctos — concretamente, con el índice de Algolia de staging, no el de producción.

Fase 3 · Desplegar desde el runner — completada el 2026-08-28

Requerimientos R4.1 y R4.3 – R4.6.

  • Instalar el runner self-hosted en cada host con labels (storefront + staging / production).
  • Job deploy que depende del build, corre sobre esos labels y hace compose pull + up -d con el tag por SHA.
  • Configurar el environment: production con deployment tag policy v*, para que ningún otro ref pueda desplegar ahí. Revisores requeridos quedan sin activar: con un equipo chico, quien taguea y quien aprueba son la misma persona.
  • Smoke test contra /api/health, y fallar el job si no responde.
  • Reescribir la sección Deploy del runbook, que hoy dice que no hay pipeline, y completar su sección Pendiente con los hosts reales.

Criterio de verificación

Un merge a main queda esperando aprobación; al aprobarlo, el sitio de producción sirve el commit nuevo y /api/health devuelve ese SHA. Un workflow_dispatch con el tag anterior revierte en segundos.

Fase 4 · Endurecer

Requerimiento R2.5. Después de la primera semana en uso.

  • concurrency por entorno para que dos deploys no se pisen.
  • Política de retención de imágenes en GHCR: el registry crece con cada commit.
  • ADR del pipeline, dentro del mismo PR que lo implementa, como pide AGENTS.md.
  • Evaluar si conviene mover a imagen única promovible. La decisión se retoma con datos de uso real, no antes.

Trampas encontradas al implementarlo

Seis cosas que costaron tiempo y que no se deducen leyendo documentación. Cinco de las seis no fallan de forma obvia: producen un éxito aparente, un log confuso o una espera indefinida.

Trampa Qué se ve Qué pasa en realidad
build-args parsea una clave por línea El build falla diciendo que falta la publishable key, aunque el secret la trae Un valor multilínea se parte: llega solo la primera variable y el resto se lee como build-args que el Dockerfile no declara, y se descartan en silencio
github.repository conserva las mayúsculas repository name must be lowercase al pushear GHCR exige minúsculas; hay que normalizar con tr
Dentro del contenedor, localhost resuelve a ::1 El healthcheck falla siempre, con la aplicación sana El servidor escucha en 0.0.0.0 (IPv4) y busybox wget no reintenta con la otra familia. Usar 127.0.0.1
Label de runner equivocada El job queda en Queued, sin error No falla: espera indefinidamente un runner que no existe
Las variables server-side no se pueden validar en el build Contenedor healthy, todas las páginas en 500, despliegue reportado como exitoso No son build-args, así que exigirlas rompería la compilación. Se validan al arrancar, con --runtime
El WAF desafía al middleware Unexpected token '<' … is not valid JSON El fetch server-side sale con User-Agent Next.js Middleware desde una IP de datacenter; Cloudflare responde un managed challenge en HTML. Ver la falla 5 del runbook

La lección transversal: un pipeline no falla donde uno mira. La mitad de estos casos terminaban en un despliegue "exitoso" con el sitio roto, y por eso las dos validaciones que se agregaron —la de runtime y la de render— valen más que cualquier otra parte del trabajo.

Deuda que el pipeline hereda

Ninguna bloquea el trabajo, pero todas se vuelven más caras una vez que el deploy es automático: un valor mal puesto deja de ser el error de una persona en un host y pasa a replicarse solo.

Deuda Qué pasa en el pipeline Severidad
~~Tres nombres para la URL del backend~~ Resuelto el 27/08/2026 Quedan dos, uno por rol: MEDUSA_BACKEND_URL (server, runtime) y NEXT_PUBLIC_MEDUSA_BASE_URL (navegador, build) —
Variables que el runbook documenta y no existen en .env.local El primer deploy levanta con la home vacía si Payload autentica alta
NEXT_PUBLIC_ALGOLIA_API_KEY, que el código lee y nadie define La búsqueda queda rota en ambos entornos por igual media
El backend de dev responde 502 de forma intermitente El build ya no falla, pero un smoke test contra /py sí lo haría — de ahí /api/health media
handleGoogleCallback importada y nunca exportada Warning en build, error en runtime al usar login con Google media
El build ignora errores de ESLint y TypeScript (ignoreDuringBuilds, ignoreBuildErrors) El pipeline publica igual con código que no compila en estricto baja

Definiciones resueltas

Respondidas por el equipo entre el 26 y el 27/08/2026:

Pregunta Respuesta
¿Host de staging separado? Sí. Staging es una VM en la red local de desarrollo; producción está en GCP. Dos runners self-hosted con labels distintos
¿Qué rama dispara staging? staging, que hay que crear. El modelo del equipo es develop para empujar y mergear features, staging para pruebas y aceptación, main por defecto y poco usada
¿Desde qué rama se puede taguear para producción? Desde staging o desde main. Es la consecuencia de que main se use poco: exigir solo main bloquearía el despliegue de versiones ya aceptadas
¿Quién puede crear tags v*? Cualquier dev con acceso al repo. No hay restricción de personas: el control es la deployment tag policy más la verificación de rama
¿Qué valores difieren entre entornos? Potencialmente todos los del .env. Por eso hay un secret completo por entorno y no una lista de excepciones
¿Dónde viven las variables? GCP Secret Manager, con el .env completo como payload
¿Existe el proyecto de GCP? Sí, con Secret Manager ya en uso. Falta habilitar la lectura desde GitHub (Workload Identity Federation)
Nombres de los secrets storefront-staging-env y storefront-prod-env

Definiciones pendientes

Ninguna. Las que quedaban se resolvieron entre el 27 y el 28/08/2026: la URL del backend de producción entró con el secret storefront-prod-env, y la lectura desde GitHub quedó habilitada con Workload Identity Federation.