Los entornos de vista previa dinámicos (también denominados entornos efímeros o Preview Environments) constituyen un paradigma de aislamiento de infraestructura donde se aprovisiona automáticamente una réplica completa e independiente de la aplicación para cada Pull Request abierto. Esto elimina el cuello de botella tradicional de los servidores de staging compartidos, en los cuales la validación simultánea de ramas en conflicto bloquea la cadencia de despliegues a producción.
La arquitectura del ciclo de vida de un entorno efímero se fundamenta en cinco pilares de ingeniería:
https://pr-412.preview.company.internal o subdominios dinámicos de plataforma (Vercel, Coolify, Ingress Controller de Kubernetes con Traefik). El tráfico se aísla rigurosamente mediante reglas de Ingress o virtual hosts.createDeployment y createDeploymentStatus, los despliegues se integran en la interfaz de usuario de GitHub: habilitan botones nativos como "View deployment", insignias de estado (pending, in_progress, success, failure, inactive) y un registro histórico inmutable en el timeline del PR.<!-- metadata:pr-preview-env-marker -->. El script consulta los comentarios existentes, identifica el mensaje previo mediante la firma y ejecuta una mutación HTTP PATCH sobre el identificador existente para actualizar la información in situ.pull_request: [closed] (que se dispara tanto tras un merge exitoso como ante el cierre manual de una rama descartada) debe garantizar la liberación incondicional de los recursos asociados: namespaces de Kubernetes, contenedores Docker, registros DNS temporales y ramas de bases de datos.La interacción entre webhooks de GitHub, ejecutores de CI, orquestadores de infraestructura y la interfaz de Pull Request conforma un ciclo cerrado:
[ Desarrollador: Push a la rama del PR ]
|
v
[ Evento GitHub: pull_request (opened / synchronize) ]
|
v
[ Workflow: preview-deploy.yml ]
|
+---> [ GitHub Deployments API: Estado = 'pending' ]
|
+---> [ Orquestador (K8s Namespace / Coolify / Vercel CLI) ]
| |
| v
| [ Compilación y Despliegue Next.js Standalone ]
| [ Provisión de Rama de BD Aislada (Copy-on-Write) ]
| [ Asignación de URL: pr-104.preview.domain.com ]
|
+---> [ Healthcheck: GET https://pr-104.../api/health -> 200 OK ]
|
+---> [ Generación de Código QR en Data URI PNG/SVG ]
|
+---> [ Octokit Bot: Búsqueda de <!-- pr-marker --> en comentarios ]
| |
| ├── ¿Existe? ---> [ PATCH /comments/:id (Actualización) ]
| └── ¿Nuevo? ---> [ POST /comments (Creación inicial) ]
|
v
[ GitHub Deployments API: Estado = 'success', URL del Entorno ]
======================= CICLO DE VIDA DE TEARDOWN =======================
[ Cierre del PR: pull_request (closed) ]
|
v
[ Workflow: preview-cleanup.yml ]
|
+---> [ Eliminación de K8s Namespace / Parada de Contenedores ]
|
+---> [ Eliminación de Rama de BD Neon / Supabase ]
|
+---> [ GitHub Deployments API: Estado = 'inactive' ]
|
v
[ Octokit Bot: Actualización de Comentario -> "⚪ Entorno Desmantelado" ]pr-${{ github.event.number }}). Un Ingress Controller con Traefik emite certificados TLS wildcard de Let's Encrypt de forma dinámica.Un sistema de entornos de vista previa resiliente se compone de dos flujos de trabajo coordinados: un pipeline de despliegue y un pipeline de desmantelamiento (teardown), gobernados por un script de TypeScript basado en Octokit.
El workflow de despliegue reacciona a tres eventos del ciclo de vida del PR: opened, synchronize (nuevo commit en la rama) y reopened.
name: Preview Deployment
on:
pull_request:
types:
- opened
- synchronize
- reopened
paths-ignore:
- '**/*.md'
- '.github/workflows/docs.yml'
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
deployments: writeEl parámetro cancel-in-progress: true es crítico: si un desarrollador envía varios commits seguidos, las compilaciones obsoletas se cancelan de inmediato, ahorrando minutos de ejecutor y cómputo en la nube.
Para generar códigos QR compactos e independientes de CDNs de terceros, se utiliza la librería qrcode, que genera un Data URI en formato data:image/png;base64,....
El script identifica comentarios previos mediante el marcador <!-- metadata:pr-preview-env-marker -->.
import { Octokit } from '@octokit/rest';
import QRCode from 'qrcode';
interface DeploymentNotificationParams {
githubToken: string;
owner: string;
repo: string;
prNumber: number;
previewUrl: string;
commitSha: string;
status: 'deploying' | 'ready' | 'failed' | 'destroyed';
buildDurationSec?: number;
}
const BOT_MARKER = '<!-- metadata:pr-preview-env-marker -->';
export async function upsertStickyPrComment(params: DeploymentNotificationParams): Promise<void> {
const { githubToken, owner, repo, prNumber, previewUrl, commitSha, status, buildDurationSec } = params;
const octokit = new Octokit({ auth: githubToken });
// 1. Generación de código QR en Data URI (PNG Base64)
const qrCodeDataUri = await QRCode.toDataURL(previewUrl, {
width: 160,
margin: 1,
color: { dark: '#000000', light: '#ffffff' }
});
// 2. Construcción del cuerpo del comentario según el estado
const shortSha = commitSha.substring(0, 7);
let statusBadge = '';
let contentBody = '';
if (status === 'ready') {
statusBadge = '🟢 **Entorno de Vista Previa Desplegado**';
contentBody = `
| Parámetro | Valor |
| :--- | :--- |
| **URL del Entorno** | [${previewUrl}](${previewUrl}) |
| **Commit** | \`${shortSha}\` |
| **Tiempo de Compilación** | \`${buildDurationSec ?? 0}s\` |
### Auditoría Móvil Directa (Código QR):
<img src="${qrCodeDataUri}" alt="Código QR de Vista Previa" width="160" height="160" />
> Este entorno efímero se destruirá automáticamente al cerrar este Pull Request.
`;
} else if (status === 'deploying') {
statusBadge = '🟡 **Desplegando Entorno de Vista Previa...**';
contentBody = `Aprovisionando infraestructura para el commit \`${shortSha}\`...`;
} else if (status === 'destroyed') {
statusBadge = '⚪ **Entorno de Vista Previa Desmantelado**';
contentBody = `Los recursos efímeros y el dominio asignados al commit \`${shortSha}\` han sido liberados.`;
} else {
statusBadge = '🔴 **Error en el Despliegue**';
contentBody = `No fue posible desplegar la vista previa para el commit \`${shortSha}\`. Revisa los logs de GitHub Actions.`;
}
const commentBody = `${BOT_MARKER}\n## ${statusBadge}\n\n${contentBody}`;
// 3. Búsqueda del comentario previo mediante el marcador oculto
const { data: comments } = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
});
const existingComment = comments.find((c) => c.body && c.body.includes(BOT_MARKER));
if (existingComment) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: commentBody,
});
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody,
});
}
}El flujo de limpieza opera de forma aislada y responde al evento pull_request: [closed]. Desmantela los recursos de cómputo, elimina las ramas de bases de datos y marca el despliegue en GitHub como inactive.
name: Preview Teardown
on:
pull_request:
types:
- closed
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
cleanup:
name: Deprovision Ephemeral Resources
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Eliminar Entorno en Kubernetes / Coolify
shell: bash
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
COOLIFY_API_KEY: ${{ secrets.COOLIFY_API_KEY }}
run: |
echo "Desmantelando recursos para PR #$PR_NUMBER..."
# Ejemplo de llamada a la API del orquestador
# curl -X DELETE "https://coolify.internal/api/v1/applications/pr-$PR_NUMBER" \
# -H "Authorization: Bearer $COOLIFY_API_KEY"
- name: Desactivar Deployment en GitHub
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-pr-${context.payload.pull_request.number}`
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
description: 'PR cerrado. Entorno efímero desmantelado.'
});
}
- name: Actualizar Comentario del Bot
uses: actions/github-script@v7
with:
script: |
const BOT_MARKER = '<!-- metadata:pr-preview-env-marker -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const targetComment = comments.find(c => c.body && c.body.includes(BOT_MARKER));
if (targetComment) {
const updated = `${BOT_MARKER}\n## ⚪ **Entorno de Vista Previa Desmantelado**\n\nLos recursos para el PR #${context.payload.pull_request.number} han sido eliminados correctamente.`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: targetComment.id,
body: updated
});
}GitHub Actions detecta pull_request: synchronize, registra un Deployment en estado 'pending' y publica el comentario inicial del bot.
Creación de un namespace en K8s o contenedor en Coolify; bifurcación de esquema de BD mediante API de Database Branching.
Compilación del contenedor Next.js Standalone y sondeo continuo a GET /api/health hasta recibir HTTP 200 con timeout de 60s.
El bot actualiza el Deployment a 'success', genera el código QR en Base64 y modifica el comentario persistente mediante PATCH.
Al fusionar o cerrar el PR se destruyen los contenedores, se elimina la base de datos efímera y se marca el Deployment como inactivo.
A continuación se muestra un workflow .github/workflows/pr-preview-full.yml que gestiona de manera unificada tanto la fase de despliegue como la de desmantelamiento con soporte nativo de Octokit.
name: PR Preview Lifecycle
on:
pull_request:
types:
- opened
- synchronize
- reopened
- closed
concurrency:
group: preview-env-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
# -------------------------------------------------------------
# Fase 1: Despliegue de la vista previa (al abrir o actualizar)
# -------------------------------------------------------------
deploy:
name: Deploy Preview Environment
if: github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout del Código
uses: actions/checkout@v4
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Instalar Dependencias del Bot
run: npm install @octokit/rest qrcode @types/qrcode
- name: Registrar Deployment en Estado 'pending'
id: create-deployment
uses: actions/github-script@v7
with:
script: |
const res = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.ref,
environment: `preview-pr-${context.payload.pull_request.number}`,
required_contexts: [],
auto_merge: false,
description: `Vista previa para PR #${context.payload.pull_request.number}`
});
core.setOutput('deployment_id', res.data.id);
- name: Ejecutar Despliegue y Healthcheck
id: deploy-step
shell: bash
run: |
START_TIME=$(date +%s)
PR_NUM=${{ github.event.pull_request.number }}
PREVIEW_HOST="https://pr-${PR_NUM}.preview.internal.dev"
echo "Compilando contenedor Next.js Standalone..."
# Emulación del despliegue en orquestador y propagación DNS
sleep 5
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "url=$PREVIEW_HOST" >> $GITHUB_OUTPUT
echo "duration=$DURATION" >> $GITHUB_OUTPUT
- name: Publicar Comentario Persistente con Código QR
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.deploy-step.outputs.url }}
BUILD_DURATION: ${{ steps.deploy-step.outputs.duration }}
DEPLOYMENT_ID: ${{ steps.create-deployment.outputs.deployment_id }}
with:
script: |
const QRCode = require('qrcode');
const previewUrl = process.env.PREVIEW_URL;
const duration = process.env.BUILD_DURATION;
const prNumber = context.payload.pull_request.number;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
const marker = '<!-- metadata:pr-preview-env-marker -->';
const qrDataUri = await QRCode.toDataURL(previewUrl, {
width: 140,
margin: 1
});
const body = `${marker}
## 🚀 Vista Previa Lista
| Parámetro | Valor |
| :--- | :--- |
| **URL** | [${previewUrl}](${previewUrl}) |
| **Commit** | \`${sha}\` |
| **Duración del Build** | \`${duration}s\` |
### Acceso Rápido desde Smartphone:
<img src="${qrDataUri}" width="140" height="140" alt="Código QR de Vista Previa" />
---
*Este entorno se desmantelará de forma automática al cerrar este PR.*`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}
// Actualización de estado del Deployment a success
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: process.env.DEPLOYMENT_ID,
state: 'success',
environment_url: previewUrl,
description: 'Vista previa desplegada y verificada con éxito'
});
# -------------------------------------------------------------
# Fase 2: Desmantelamiento del entorno (al cerrar el PR)
# -------------------------------------------------------------
teardown:
name: Teardown Preview Environment
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Liberar Recursos y Marcar Inactivo
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const marker = '<!-- metadata:pr-preview-env-marker -->';
console.log(`Liberando recursos efímeros del PR #${prNumber}`);
// 1. Marcar Deployments como inactivos
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-pr-${prNumber}`
});
for (const d of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: d.id,
state: 'inactive',
description: 'PR cerrado. Entorno efímero desmantelado.'
});
}
// 2. Actualizar comentario del bot
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: `${marker}\n## ⚪ **Entorno de Vista Previa Desmantelado**\n\nLos recursos para el PR #${prNumber} han sido eliminados correctamente.`
});
}Invocar octokit.rest.issues.createComment incondicionalmente ante cada commit sin buscar mensajes previos. En ramas activas con múltiples iteraciones, el Pull Request se inunda de mensajes idénticos, ocultando las discusiones técnicas del equipo de revisión.
// INCORRECTO: Crear siempre un nuevo comentario
await octokit.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: `Preview URL: ${url}`
});
// CORRECTO: Localizar por firma oculta y actualizar mediante PATCH
const marker = '<!-- pr-preview-sticky-bot -->';
const { data: comments } = await octokit.rest.issues.listComments({ owner, repo, issue_number: prNumber });
const target = comments.find(c => c.body?.includes(marker));
if (target) {
await octokit.rest.issues.updateComment({ owner, repo, comment_id: target.id, body: `${marker}\n${body}` });
} else {
await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: `${marker}\n${body}` });
}Configurar la limpieza únicamente en el evento push a la rama main. Si un desarrollador cierra un Pull Request sin fusionarlo, la infraestructura efímera permanece activa indefinidamente en la nube, consumiendo recursos y elevando los costes operativos.
# INCORRECTO: Limpieza condicionada únicamente al push en main
on:
push:
branches: [main]
# CORRECTO: Disparo explícito ante el evento pull_request closed
on:
pull_request:
types: [closed]Permitir el acceso a secretos corporativos en repositorios de código abierto usando pull_request_target y haciendo checkout del código del fork (ref: ${{ github.event.pull_request.head.sha }}). Esta vulnerabilidad crítica (PwnRequest) permite a un actor malicioso alterar los scripts de compilación para ejecutar código con privilegios y exfiltrar credenciales corporativas.
# INCORRECTO: Checkout de código no confiable dentro de un contexto privilegiado
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm run build # ¡CÓDIGO MALICIOSO CON ACCESO A SECRETOS!
# CORRECTO: Aislar compilaciones sin secretos o requerir aprobación manualConectar todos los despliegues de vista previa a una única base de datos de staging. Cuando un PR ejecuta una migración destructiva (como eliminar columnas o modificar tipos en Prisma), todos los demás entornos colapsan de inmediato con fallos en runtime. Se debe garantizar aislamiento por esquema o bifurcación de base de datos (Database Branching).
Establecer DNS comodín (*.preview.company.com) y configurar proxy inverso para enrutar dinámicamente según el número de PR.
Integrar APIs de ramificación de bases de datos (Neon, Supabase) o esquemas independientes en PostgreSQL para cada PR.
Construir el workflow que actualice el estado del Deployment en GitHub y valide el endpoint /api/health.
Programar script de TypeScript con firma oculta para comentarios persistentes y generación de QR en formato Base64.
Configurar el flujo ante pull_request: closed para garantizar la destrucción de contenedores y liberación de bases de datos.