Pipeline de despliegue con GitHub Actions¶
Análisis, decisiones y plan por fases para automatizar el despliegue del CMS: imagen construida en CI, publicada en GHCR y desplegada por un runner self-hosted, reemplazando el procedimiento manual del runbook —«no hay pipeline de build ni de deploy»—. Es el documento hermano del análisis equivalente del storefront y reutiliza su arquitectura donde aplica; aquí se documenta lo que es distinto en el CMS.
Alcance. Los hallazgos del Diagnóstico están medidos contra
dependencias-npmel 27/08/2026 (Payload 3.88.0, Next 15.4.11, ADR-0004), no inferidos. La fase 0 se ejecutó ese mismo día y su resultado quedó en el ADR-0005.
Respuesta corta¶
Las decisiones que fijan la forma del pipeline están tomadas. Donde este documento no dice lo
contrario, aplica el estándar CI/CD de Compulandia (Guía de configuración CI/CD con runners
self-hosted, versión 26/08/2026): secretos en GCP Secret Manager, staging automático por push a la rama
staging (el Environment staging solo acepta esa rama), producción por tag v*, GHCR autenticado con el token del job.
| Pregunta | Decisión | Por qué |
|---|---|---|
| ¿Cómo llegan los cambios de esquema a producción? | Solo payload migrate con migraciones commiteadas; el modo push jamás sale de dev |
El push de drizzle pide confirmación interactiva ante warnings (pushDevSchema): sin TTY se cuelga — verificado el 26/08/2026 con el test de integración. Además puede destruir datos sin dejar registro |
| ¿El deploy incluye migraciones? | Sí, aplicadas por el propio arranque del contenedor (ADR-0006) | La imagen standalone no incluye el CLI payload migrate. prodMigrations (con la compuerta PAYLOAD_MIGRATE_ON_INIT=true, que solo definen los servidores) las aplica al inicializar, antes de servir tráfico de Payload |
| ¿Dónde vive la base de datos? | Fuera del compose, en una VM dedicada de PostgreSQL | El pipeline nunca gestiona la base, solo se conecta. El control de acceso por red de esa VM (solo los hosts del CMS) reemplaza al usuario sin DDL que el ADR-0006 vuelve inviable |
| ¿Imagen única o por entorno? | Una imagen por entorno | NEXT_PUBLIC_SERVER_URL se inlinea en el build, y las páginas SSG del frontend heredado se prerenderizan con el contenido de la base usada al construir |
| ¿Dónde se construye la imagen? | En CI (ubuntu-latest) + GHCR |
El build ya no necesita la base — resuelto en la fase 0, ADR-0005 |
| ¿Cómo se publica producción? | Por tag v* sobre main, con doble guard |
Estándar CI/CD: el tag es el acto deliberado y versionado — reemplaza la aprobación manual por revisores. El rollback se lee «volvé a la v1.2.4», no un hash |
| ¿Dónde viven variables y secretos? | GCP Secret Manager: un secret por entorno con el .env completo |
Estándar CI/CD: versionado, valores anteriores legibles, y una sola fuente para build y runtime. El .env del host pasa a ser generado por el deploy |
Resuelto: el build ya no necesita PostgreSQL
Medido el 27/08/2026: next build fallaba con la base inalcanzable porque las páginas del
frontend heredado consultan contenido al compilar. Se protegieron los tres
generateStaticParams (devuelven lista vacía sin base; las páginas se generan bajo demanda)
y la portada y el listado de posts pasaron a renderizado bajo demanda. Con eso el build pasa
completo sin base, y DATABASE_URI y PAYLOAD_SECRET dejaron de ser argumentos de build.
Alternativas consideradas y descartadas: en el ADR-0005.
Diagnóstico¶
| Hallazgo | Evidencia | Impacto en el pipeline |
|---|---|---|
| ~~El build requiere la base alcanzable~~ | Resuelto 27/08/2026 (ADR-0005) | El job de build puede correr en ubuntu-latest |
| ~~Los build-args llevan secretos~~ | Resuelto 27/08/2026: DATABASE_URI y PAYLOAD_SECRET ya no son ARG; solo runtime |
docker history queda limpio |
| Las migraciones no se aplican solas | CMD = node server.js; runbook, Deploy |
El job de deploy necesita el paso migrate explícito, y ya hay una pendiente (payload_kv) |
| No hay endpoint de salud propio | Solo existen las rutas del template; /admin inicializa Payload → depende de la base |
El runner no puede distinguir «CMS caído» de «base caída» |
| No hay registry ni tags | El host construye con docker compose build |
Rollback = recompilar (y re-migrar): minutos, no segundos |
| Sin healthcheck en compose | docker-compose.yml, servicio payload |
up -d reporta éxito aunque el proceso muera al arrancar |
| El host de deploy no está en el repo | Runbook, Deploy | No hay dónde instalar el runner todavía |
| CORS del storefront hardcodeado | Array cors en src/payload.config.ts |
Un dominio nuevo de storefront exige build nuevo, no un cambio de config |
Lo que ya está resuelto (y en el storefront costó una fase entera): la imagen usa
node:22-alpine, output: standalone y usuario no-root; existe .dockerignore y excluye
.env*; el artefacto corre Payload 3.88 + Next 15.4.11 sin vulnerabilidades críticas
(ADR-0004); el test de integración está en
verde tras mapear el valor huérfano custom (commit 2a9e455).
Requerimientos¶
R1 · El artefacto¶
| ID | Requerimiento | Estado |
|---|---|---|
| R1.1 | Base node:22-alpine, standalone, non-root, .dockerignore |
listo |
| R1.2 | Ningún secreto como ARG: BuildKit secrets o build sin base |
listo |
| R1.3 | El build no depende de la base (ADR-0005) | listo |
| R1.4 | Endpoint /api/health sin tocar la base, con SHA del commit |
listo |
| R1.5 | healthcheck en docker-compose.yml |
listo |
R2 · El pipeline¶
| ID | Requerimiento | Estado |
|---|---|---|
| R2.1 | Workflow build + push a GHCR: tag staging-<sha> para staging, v<semver> para producción; login con el GITHUB_TOKEN del job (nunca PAT) |
verificado 27/08/2026: imágenes staging-* publicadas en GHCR |
| R2.2 | rama staging → staging automático (develop es integración, no despliega); producción por tag v* |
verificado en ambos carriles (staging 27/08, producción v1.0.0 28/08) |
| R2.3 | Job de deploy: pull → up -d → smoke en dos pasos: /api/health (la app vive) y /admin (fuerza el init de Payload → aplica y verifica migraciones, ADR-0006) |
verificado en ambos entornos |
| R2.4 | Doble guard de producción: deployment tag policy v* en el Environment + git merge-base --is-ancestor contra main en el job (los tags no pertenecen a ramas: hacen falta los dos) |
verificado — la regla rechazó un deploy desde develop el 27/08 y aceptó v1.0.0 |
| R2.5 | workflow_dispatch con versión para rollback/redeploy |
pendiente |
| R2.6 | concurrency por entorno, sin cancelar el deploy en curso |
listo |
R3 · Entorno y secretos¶
| ID | Requerimiento | Estado |
|---|---|---|
| R3.1 | Secrets cms-staging-env / cms-prod-env en GCP Secret Manager, cada uno con el .env completo (DATABASE_URI, PAYLOAD_SECRET, CRON_SECRET, PREVIEW_SECRET, credenciales Cloudflare, NEXT_PUBLIC_SERVER_URL) |
listo (staging v3 con secretos reales; prod operativo) |
| R3.2 | Workload Identity Federation configurado (pool, provider con attribute-condition por organización, cuenta de servicio atada al repo) y variables GCP_* en el repositorio |
listo — a nivel de organización |
| R3.3 | El job de deploy escribe el .env del host desde el secret: editarlo por SSH deja de tener efecto |
verificado en ambos hosts (/opt/payload-cms) |
| R3.4 | El build extrae del secret solo las variables públicas (NEXT_PUBLIC_* — aquí una sola: NEXT_PUBLIC_SERVER_URL) como build-args |
verificado |
| R3.5 | Acceso a la base restringido por red (única barrera restante: con el ADR-0006 la credencial de la app tiene DDL). Producción (GCP): ya configurado. Staging (VM local, alcanzable desde toda la red): decidir si se restringe en la VM o se acepta el riesgo — el accidente npm run dev apuntado a esa base es el mismo que corrompió la base de dev |
staging: a decidir |
| R3.6 | Environments staging/production en GitHub; production con la tag policy v* (disponible en plan Team para repos privados) |
listo — y staging con regla de rama |
La protección contra un next dev apuntado a otra base es la red
El modo push se activa por cómo corre Payload (dev), no contra qué base: un next dev
con la URI equivocada en el .env intenta alterar el esquema de esa base. Como el
ADR-0006 exige que la credencial de la app tenga
DDL (quien migra es la app), la salvaguarda no puede ser el usuario de base — es la red.
En producción (GCP) ya está restringida. En staging (VM local alcanzable desde toda la red)
es una decisión abierta (R3.5); el accidente ahí es real: es el mismo que corrompió la base
de dev y rompió el test de integración.
R4 · Rollback¶
| ID | Requerimiento | Estado |
|---|---|---|
| R4.1 | Rollback por tag: elegir tag anterior → pull → up -d |
las imágenes por versión ya existen en GHCR; falta el botón (workflow_dispatch, fase 4) |
| R4.2 | Política de reversión de migraciones documentada por release | pendiente |
El rollback del CMS tiene un paso más que el del storefront
Volver el código atrás no revierte el esquema. La regla del runbook se mantiene: si la
versión que se baja aplicó una migración, decidir entre down o esquema adelantado
compatible. El pipeline lo hace visible: el job de deploy registra qué migraciones aplicó.
Plan de implementación¶
Fase 0 · Responder si el build puede funcionar sin base — hecha (27/08/2026)¶
El build pasa sin base: guardas en los 3 generateStaticParams, portada y listado de posts en
renderizado bajo demanda, y DATABASE_URI/PAYLOAD_SECRET fuera de los argumentos de build.
Decisión y consecuencias en el ADR-0005. El carril de
build queda: ubuntu-latest + GHCR.
Fase 1 · Señal de salud — hecha (27/08/2026)¶
/api/health responde 200 sin importar nada de Payload, devolviendo el SHA del commit (build-arg
GIT_SHA, lo pasa el workflow) y la versión; healthcheck agregado al servicio en compose.
Criterio verificado: con Postgres caído a propósito, /api/health respondió 200 y /admin 500 —
dos señales distintas. Con la base real, la API REST de Payload convive con la ruta nueva.
Fase 2 · Construir y publicar — hecha y verificada (27/08/2026)¶
Workflow imagen.yml
según el estándar: push a la rama staging → :staging-<sha>, tag v* → :v<semver>; OIDC hacia GCP,
extrae solo las NEXT_PUBLIC_* del secret (cms-staging-env / cms-prod-env) como build-args
(más GIT_SHA para /api/health), GHCR con el GITHUB_TOKEN, cache de capas. Con las
variables GCP_* cargadas a nivel de organización, el carril quedó operativo desde el primer
push (imágenes staging-* en GHCR, build en ~3m30). El disparador inicial fue develop;
el 27/08/2026 se corrigió a la rama staging, que es la que el Environment acepta.
workflow_dispatch para redeploy/rollback se suma en la fase 4.
Fase 3a · Deploy a staging — hecha y verificada (27/08/2026)¶
Job deploy-staging según la guía: escribe el .env del host desde el secret, compose pull +
up -d con el tag del build, y smoke en dos pasos — /api/health verifica que la app vive y
que responde el SHA desplegado, /admin fuerza la inicialización de Payload que aplica las
migraciones. Corre en el runner de la organización con label staging (VM de staging de esta
red) y se activa con la variable RUNNER_STAGING del repo.
Primer despliegue automático: 27/08/2026. La base mi-cms-payload-staging, creada vacía esa
mañana, terminó con las 77 tablas y las 3 migraciones registradas — aplicadas por el arranque
del contenedor, sin intervención humana. https://stg-cms.compulandia.com.py/api/health
responde el SHA exacto del commit desplegado.
Fase 3b · Deploy a producción — hecha y verificada (28/08/2026)¶
v1.0.0 desplegado por tag: la regla del Environment y el guard de main funcionaron (rechazaron
un intento desde develop), el runner de la VM de e-commerce (label ecommerce-srv-production)
operó desde /opt/payload-cms y el arranque migró la base de producción. Los tres incidentes
del camino (permiso del secret, permisos de /opt, red hacia la base) fueron diagnosticados por
el propio smoke sin afectar lo que corría.
Plan original de la fase (referencia)¶
- Instalar el runner del host de staging (label
staging, servicio systemd, solo tráfico saliente hacia GitHub, GHCR y*.googleapis.com— pasos en la guía de la wiki) y cargar la variableRUNNER_STAGING. - Job
deploy-prod: igual al de staging, más el doble guard (tag policyv*en el Environment ymerge-base --is-ancestorcontramain). Necesita: secretcms-prod-env, host con runnerproduction, URI de la base de producción en GCP. - Reescribir la sección Deploy del runbook cuando el primer despliegue automático funcione.
Fase 4 · Endurecer¶
concurrency, retención de imágenes en GHCR,workflow_dispatchde rollback.- Evaluar sacar el array
corsa configuración de runtime.
Definiciones pendientes¶
- ¿Hosts de staging y producción del CMS? ¿Los mismos del storefront? Sin esto no hay dónde instalar runners.
- ¿URI de la base de producción (GCP)? La restricción de acceso por red ya está configurada allí. Staging usa su propia VM local de PostgreSQL; queda a decisión del equipo si se restringe por red (R3.5).
- ¿URL pública del CMS en producción? Es
NEXT_PUBLIC_SERVER_URL(se hornea en el build) y condiciona el arraycorsdel storefront. - ¿Proyecto de GCP para Secret Manager? Cuál se usa, y quién crea el Workload Identity Pool y la cuenta de servicio (R3.1–R3.2). Es un setup único por organización que comparten todos los repos.
- ¿El sitio Next del frontend heredado se mantiene o se apaga? Si nadie lo consume, el sitemap/SEO local dejan de importar.
Ya no es una definición pendiente quién aprueba los deploys: con el estándar por tag, el tag es la aprobación. Los revisores requeridos del Environment quedan como opción para cuando haya separación real de roles.