Динамические предварительные окружения (Ephemeral или Preview Environments) представляют собой парадигму изоляции инфраструктуры, при которой для каждого открытого Pull Request автоматически развертывается независимый, функционально идентичный продакшну стек приложения. Это устраняет традиционное «бутылочное горлышко» разделяемого staging-сервера, где одновременное тестирование конфликтующих фичей блокирует процесс релизов.
Архитектура жизненного цикла эфемерного окружения опирается на следующие инженерные концепции:
https://pr-412.preview.company.internal или детерминированный preview-домен платформы (Vercel, Coolify, Kubernetes Ingress Controller). Трафик изолируется на уровне Ingress/Traefik правил или виртуальных хостов Nginx.createDeployment и createDeploymentStatus переводит PR в состояние интеграции с GitHub UI: появляются нативные кнопки «View deployment», индикаторы состояний (pending, in_progress, success, failure, inactive) и фиксация ссылки в таймлайне PR.<!-- pr-preview-deployment-marker -->. Скрипт запрашивает список комментариев в PR, находит существующий комментарий по сигнатуре и выполняет мутацию через HTTP метод PATCH, сохраняя историю в едином блоке.pull_request: [closed] (срабатывающий как при успешном мердже, так и при ручном закрытии без слияния) обязан гарантированно удалить ассоциированные ресурсы: Kubernetes namespace, Docker-контейнеры, временные записи DNS и снимки баз данных.Взаимодействие GitHub Webhook, раннера, оркестратора деплоя и интерфейса Pull Request формирует замкнутый цикл:
[ Разработчик: Push в PR ]
|
v
[ GitHub Event: pull_request (opened / synchronize) ]
|
v
[ Workflow: preview-deploy.yml ]
|
+---> [ GitHub Deployments API: Status = 'pending' ]
|
+---> [ Оркестратор (K8s Namespace / Coolify / Vercel CLI) ]
| |
| v
| [ Сборка и деплой Next.js Standalone ]
| [ Подключение изолированной БД (Branching) ]
| [ Назначение URL: pr-104.preview.domain.com ]
|
+---> [ Healthcheck: GET https://pr-104.../api/health -> 200 OK ]
|
+---> [ Генерация QR-кода в SVG/Data URI ]
|
+---> [ Octokit Bot: Поиск <!-- pr-marker --> в комментариях ]
| |
| ├── Найдено? ---> [ PATCH /comments/:id (Обновление) ]
| └── Нет? ---> [ POST /comments (Создание) ]
|
v
[ GitHub Deployments API: Status = 'success', Environment URL ]
======================= ЖИЗНЕННЫЙ ЦИКЛ TEARDOWN =======================
[ Закрытие PR: pull_request (closed) ]
|
v
[ Workflow: preview-cleanup.yml ]
|
+---> [ Удаление k8s namespace / Остановка контейнеров ]
|
+---> [ Удаление ветки БД Neon / Supabase ]
|
+---> [ GitHub Deployments API: Status = 'inactive' ]
|
v
[ Octokit Bot: Обновление комментария -> "🔴 Окружение уничтожено" ]Настройка Wildcard DNS (*.preview.company.com) и конфигурация Ingress/SSL для динамической маршрутизации запросов по номеру PR.
Интеграция Database Branching API (Neon, Supabase или изолированные схемы PostgreSQL) для создания изолированной БД под каждый PR.
Написание workflow развертывания с генерацией Deployment State (pending/success) и обязательным опросом healthcheck-эндпоинта.
Разработка скрипта на TypeScript с поиском скрытого маркера, генерацией Base64 QR-кода и публикацией отчета в таймлайн PR.
Конфигурация workflow на событие pull_request: closed для гарантированного удаления контейнеров, базы данных и перевода статуса в inactive.
В чем заключается главное архитектурное преимущество использования GitHub Deployments API и идемпотентного Sticky Comment с HTML-маркером по сравнению с обычной публикацией комментариев ботом?
pr-${{ github.event.number }}. Ingress Controller на базе Traefik выдает динамические Let's Encrypt TLS-сертификаты по wildcard-домену.Реализация высоконадежных Preview Environments состоит из двух изолированных пайплайнов: конвейера развертывания и конвейера уничтожения (Teardown), управляемых скриптом автоматизации на TypeScript.
Workflow деплоя должен прослушивать три типа событий жизненного цикла PR: opened, synchronize (новый push в существующую ветку) и 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: writeПараметр cancel-in-progress: true критичен: если разработчик делает несколько коммитов подряд, устаревшие билды отменяются, экономя инфраструктурные ресурсы.
Для формирования компактного, не зависящего от внешних CDN QR-кода используется библиотека qrcode, генерирующая Data URI строку формата data:image/png;base64,... или векторный SVG.
Внутри скрипта используется маркер <!-- 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. Генерация QR-кода в формате Data URI (PNG Base64)
const qrCodeDataUri = await QRCode.toDataURL(previewUrl, {
width: 160,
margin: 1,
color: { dark: '#000000', light: '#ffffff' }
});
// 2. Формирование тела сообщения в зависимости от статуса
const shortSha = commitSha.substring(0, 7);
let statusBadge = '';
let contentBody = '';
if (status === 'ready') {
statusBadge = '🟢 **Превью успешно развернуто**';
contentBody = `
| Параметр | Значение |
| :--- | :--- |
| **URL окружения** | [${previewUrl}](${previewUrl}) |
| **Коммит** | \`${shortSha}\` |
| **Время сборки** | \`${buildDurationSec ?? 0} сек\` |
### Мобильный аудит (QR-код):
<img src="${qrCodeDataUri}" alt="Preview QR Code" width="160" height="160" />
> Окружение будет автоматически уничтожено при закрытии этого Pull Request.
`;
} else if (status === 'deploying') {
statusBadge = '🟡 **Развертывание окружения...**';
contentBody = `Идет сборка и подготовка инфраструктуры для коммита \`${shortSha}\`...`;
} else if (status === 'destroyed') {
statusBadge = '⚪ **Окружение выведено из эксплуатации**';
contentBody = `Ресурсы и домен для коммита \`${shortSha}\` успешно освобождены.`;
} else {
statusBadge = '🔴 **Сбой развертывания**';
contentBody = `Не удалось развернуть превью для коммита \`${shortSha}\`. Проверьте логи GitHub Actions.`;
}
const commentBody = `${BOT_MARKER}\n## ${statusBadge}\n\n${contentBody}`;
// 3. Поиск существующего комментария бота по маркеру
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,
});
}
}Пайплайн очистки изолирован от основного пайплайна и прослушивает событие pull_request: closed. Он депровижионит ресурсы и переводит GitHub Deployment в статус 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: Удаление окружения в Kubernetes / Coolify
shell: bash
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
COOLIFY_API_KEY: ${{ secrets.COOLIFY_API_KEY }}
run: |
echo "Уничтожение ресурсов для PR #$PR_NUMBER..."
# Пример вызова API оркестратора
# curl -X DELETE "https://coolify.internal/api/v1/applications/pr-$PR_NUMBER" \
# -H "Authorization: Bearer $COOLIFY_API_KEY"
- name: Деактивация GitHub Deployment
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 был закрыт, окружение уничтожено'
});
}
- name: Обновление статуса в бот-комментарии
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## ⚪ **Превью окружение уничтожено**\n\nРесурсы PR #${context.payload.pull_request.number} были депровижионированы.`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: targetComment.id,
body: updated
});
}GitHub Actions реагирует на pull_request: synchronize, регистрирует Deployment в состоянии pending и отправляет первичный комментарий бота.
Создание изолированного Kubernetes Namespace или инстанса контейнера в Coolify. Выделение изолированной ветки БД через DB Branching API.
Сборка Next.js Standalone контейнера, запуск и циклическая проверка HTTP 200 на эндпоинте /api/health с тайм-аутом 60 секунд.
Бот переводит Deployment в success, генерирует QR-код и мутирует существующий Sticky Comment в PR.
При мердже или закрытии PR срабатывает очищающий workflow: удаление контейнеров, схемы БД и перевод Deployment в статус inactive.
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:
# -------------------------------------------------------------
# Фаза 1: Развертывание превью (при открытии или обновлении PR)
# -------------------------------------------------------------
deploy:
name: Deploy Preview Environment
if: github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout кода
uses: actions/checkout@v4
- name: Установка Node.js среды
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Установка зависимостей скрипта бота
run: npm install @octokit/rest qrcode @types/qrcode
- name: Регистрация Deployment в статусе '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: `Превью для PR #${context.payload.pull_request.number}`
});
core.setOutput('deployment_id', res.data.id);
- name: Имитация деплоя на хостинг и 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 "Сборка контейнера Next.js..."
# Эмуляция шагов сборки и регистрации домена
sleep 5
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "url=$PREVIEW_HOST" >> $GITHUB_OUTPUT
echo "duration=$DURATION" >> $GITHUB_OUTPUT
- name: Публикация Sticky Comment с 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}
## 🚀 Превью-окружение готово
| Параметр | Значение |
| :--- | :--- |
| **URL** | [${previewUrl}](${previewUrl}) |
| **Коммит** | \`${sha}\` |
| **Сборка заняла** | \`${duration} сек\` |
### Быстрый доступ со смартфона:
<img src="${qrDataUri}" width="140" height="140" alt="Preview QR" />
---
*Окружение будет автоматически депровижионировано при закрытии 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
});
}
// Перевод статуса Deployment в 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: 'Превью успешно развернуто и прошло healthcheck'
});
# -------------------------------------------------------------
# Фаза 2: Уничтожение превью (при закрытии PR)
# -------------------------------------------------------------
teardown:
name: Teardown Preview Environment
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Очистка инфраструктуры и обновление комментариев
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const marker = '<!-- metadata:pr-preview-env-marker -->';
console.log(`Освобождение ресурсов для PR #${prNumber}`);
// 1. Помечаем Deployments как inactive
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 закрыт. Окружение депровижионировано.'
});
}
// 2. Обновляем бот-комментарий
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## ⚪ Превью-окружение деактивировано\n\nРесурсы PR #${prNumber} успешно удалены.`
});
}Какая критическая уязвимость безопасности возникает при использовании триггера pull_request_target с автоматическим чекаутом кода ветки из внешнего форка?
Создание нового комментария через octokit.rest.issues.createComment на каждый push без поиска предыдущего сообщения бота. В активных PR с 20 коммитами обсуждение превращается в длинную ленту одинаковых ссылок. Разработчики перестают замечать рецензии ревьюеров.
// НЕПРАВИЛЬНО: Безусловное создание нового комментария
await octokit.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: `Preview URL: ${url}`
});
// ПРАВИЛЬНО: Поиск по скрытому маркеру и мутация существующего комментария
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}` });
}Многие пайплайны настраивают уничтожение окружения только при событии push в ветку main. Если разработчик закрывает PR без мерджа (unmerged / abandoned PR), окружение продолжает работать в облаке неделями, потребляя память кластера и раздувая расходы компании на инфраструктуру.
# НЕПРАВИЛЬНО: Очистка только при пуше в main
on:
push:
branches: [main]
# ПРАВИЛЬНО: Явный триггер на закрытие PR независимо от статуса слияния
on:
pull_request:
types: [closed]Попытка получить доступ к секретам деплоя (например, токенам облачного провайдера) при обработке PR от внешних участников через триггер pull_request_target с автоматическим чекаутом ветки PR (ref: ${{ github.event.pull_request.head.sha }}). Это классическая критическая уязвимость (PwnRequest): злоумышленник может модифицировать package.json или npm-скрипты в форке и выполнить произвольный код на раннере, похитив облачные секреты компании.
# НЕПРАВИЛЬНО: Чекаут кода форка внутри привилегированного контекста
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm run build # ВЫПОЛНЕНИЕ ВРЕДОНОСНОГО КОДА С ДОСТУПОМ К СЕКРЕТАМ!
# ПРАВИЛЬНО: Изоляция сборки артефактов без секретов или ручной аппрув для форковПодключение всех PR к одной общей staging-базе данных. Если в одном PR запускается деструктивная миграция (удаление колонки в Prisma) или удаляются тестовые пользователи, все остальные превью-окружения немедленно падают с ошибками рантайма. Необходимо использовать изолированные схемы баз данных (Schema per PR) или технологии ветвления БД (Database Branching).