Динамічні попередні оточення (Ephemeral або Preview Environments) представляють собою парадигму ізоляції інфраструктури, за якої для кожного відкритого Pull Request автоматично розгортається незалежний, функціонально ідентичний продакшну стек застосунку. Це усуває традиційне «вузьке місце» спільного staging-сервера, де одночасне тестування конфліктуючих фічей блокує процес релізів.
Архітектура життєвого циклу ефемерного оточення спирається на такі інженерні концепції:
https://pr-412.preview.company.internal або детермінований preview-домен платформи (Vercel, Coolify, Traefik Ingress). Трафік ізолюється на рівні Ingress правил або віртуальних хостів.createDeployment і createDeploymentStatus переводить PR у стан інтеграції з GitHub UI: з'являються нативні кнопки «View deployment», індикатори станів (pending, in_progress, success, failure, inactive) та фіксація посилання в таймлайні PR.<!-- metadata:pr-preview-env-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: Оновлення коментаря -> "⚪ Оточення виведено з експлуатації" ]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} успішно видалені.`
});
}Створення нового коментаря через 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).
Налаштування 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.