Skip to content

⚙️ [기능추가][BE-Callback] 백엔드의 /api/ai/callback 연동 API 개발 필요 #97

⚙️ [기능추가][BE-Callback] 백엔드의 /api/ai/callback 연동 API 개발 필요

⚙️ [기능추가][BE-Callback] 백엔드의 /api/ai/callback 연동 API 개발 필요 #97

# ===================================================================
# Python/FastAPI + Synology PR/Issue Preview 시스템
# ===================================================================
#
# PR 또는 Issue 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다.
# Traefik 리버스 프록시를 통해 동적 라우팅됩니다.
#
# 📌 Issue에서 빌드 시:
# - Issue Helper 댓글에서 브랜치명을 자동으로 추출합니다.
# - ISSUE_HELPER_MARKER 환경변수로 마커를 설정할 수 있습니다.
#
# 🚀 사용법:
# @suh-lab server build - PR/Issue 빌드 및 배포 (기존 컨테이너 자동 교체)
# @suh-lab server destroy - Preview 환경 삭제
# @suh-lab server status - 현재 상태 확인
#
# ⚙️ 프로젝트별 필수 수정 사항:
# 아래 env 섹션의 값들을 프로젝트에 맞게 수정하세요.
#
# 🔑 GitHub Secrets:
#
# [필수] 모든 프로젝트 공통:
# - ENV_FILE: .env 파일 전체 내용 (환경변수)
# - DOCKERHUB_USERNAME: Docker Hub 사용자명
# - DOCKERHUB_TOKEN: Docker Hub 액세스 토큰
# - SERVER_HOST: 시놀로지 서버 호스트 (예: suh-project.synology.me)
# - SERVER_USER: SSH 사용자명
# - SERVER_PASSWORD: SSH 비밀번호
#
# 📋 사전 요구사항:
# - Traefik 컨테이너 실행 중 (traefik-network)
# - 와일드카드 DNS 설정: *.pr.suhsaechan.kr → 서버
# - 서버에 프로젝트 디렉토리 존재
#
# ※ Health Check 방식:
# - HEALTH_CHECK_PATH로 HTTP 엔드포인트 확인 (기본값: /docs)
# - 폴백: HEALTH_CHECK_LOG_PATTERN으로 로그 패턴 매칭
#
# 🌐 Traefik 대시보드:
# https://traefik.suhsaechan.kr/dashboard/#/
#
# 대시보드에서 확인 가능한 정보:
# - Entrypoints: 트래픽 진입점 (web:80, traefik:8080)
# - HTTP Routers: PR Preview 라우팅 규칙 목록
# - HTTP Services: 연결된 컨테이너 서비스 목록
# - Middlewares: 적용된 미들웨어 (인증, 리다이렉트 등)
# - Providers: Docker 프로바이더 상태
#
# 배포 후 대시보드에서 새로운 Router/Service가 추가되었는지 확인할 수 있습니다.
# 예: Router "mapsy-pr-123" → Service "mapsy-pr-123"
#
# 📊 리소스 네이밍 규칙:
# - 컨테이너: {PROJECT_NAME}-pr-{PR번호}
# - 이미지: {DOCKERHUB_USERNAME}/{PROJECT_NAME}:pr-{PR번호}
# - 도메인: {PROJECT_NAME}-pr-{PR번호}.pr.suhsaechan.kr
# - Preview URL: http://{도메인}:8079
#
# ===================================================================
name: PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW
# ===================================================================
# ⚠️ [영역 1] 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요
# ===================================================================
env:
# 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용)
PROJECT_NAME: mapsy
# Docker 설정
DOCKERFILE_PATH: './Dockerfile'
INTERNAL_PORT: '8000'
# Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지)
TRAEFIK_NETWORK: traefik-network
PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr
PREVIEW_PORT: '8079'
# SSH 포트 (시놀로지 포트 환경 구축 후 수정 금지)
SSH_PORT: '2022'
# Issue Helper 댓글 마커 (프로젝트별 수정 필요)
# Issue 댓글에서 브랜치명을 추출할 때 사용
ISSUE_HELPER_MARKER: 'Guide by SUH-LAB'
# Health Check 설정 (프로젝트별 수정 필요)
# - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀)
# - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백)
# - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시)
#
# 프레임워크별 기본값 예시:
# FastAPI: '/docs', 'Uvicorn running on|Application startup complete'
# Spring Boot: '/actuator/health', 'Started.*Application|Tomcat started on port'
# Express: '/health', 'Server listening on port'
HEALTH_CHECK_PATH: '/docs'
HEALTH_CHECK_LOG_PATTERN: 'Uvicorn running on|Application startup complete'
API_DOCS_PATH: '/docs'
# ===================================================================
# 트리거 설정
# ===================================================================
on:
issue_comment:
types: [created] # PR 댓글 + Issue 댓글
issues:
types: [closed] # Issue 닫힘 시 destroy
pull_request:
types: [closed] # PR 닫힘 시 destroy
permissions:
contents: read
pull-requests: write
issues: write
# ===================================================================
# Jobs
# ===================================================================
jobs:
# -----------------------------------------------------------------
# Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원)
# -----------------------------------------------------------------
check-command:
name: 명령어 확인
if: github.event_name == 'issue_comment'
runs-on: ubuntu-latest
outputs:
command: ${{ steps.parse.outputs.command }}
is_valid: ${{ steps.parse.outputs.is_valid }}
is_pr: ${{ steps.parse.outputs.is_pr }}
steps:
- name: 댓글에 👀 리액션 추가
if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server')
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
- name: 커맨드 파싱
id: parse
env:
COMMENT: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
run: |
# PR인지 Issue인지 확인
if [[ "$IS_PR" == "true" ]]; then
echo "is_pr=true" >> $GITHUB_OUTPUT
echo "ℹ️ PR 댓글에서 명령어 감지"
else
echo "is_pr=false" >> $GITHUB_OUTPUT
echo "ℹ️ Issue 댓글에서 명령어 감지"
fi
if [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+build ]]; then
echo "command=build" >> $GITHUB_OUTPUT
echo "is_valid=true" >> $GITHUB_OUTPUT
echo "✅ 명령어 감지: build"
elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+destroy ]]; then
echo "command=destroy" >> $GITHUB_OUTPUT
echo "is_valid=true" >> $GITHUB_OUTPUT
echo "✅ 명령어 감지: destroy"
elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+status ]]; then
echo "command=status" >> $GITHUB_OUTPUT
echo "is_valid=true" >> $GITHUB_OUTPUT
echo "✅ 명령어 감지: status"
else
echo "is_valid=false" >> $GITHUB_OUTPUT
echo "ℹ️ @suh-lab server 명령어가 아님"
fi
# -----------------------------------------------------------------
# Job 2-1: 빌드 & 배포 (PR 댓글에서 실행)
# -----------------------------------------------------------------
build-preview-pr:
name: Preview 빌드 & 배포 (PR)
needs: check-command
if: |
needs.check-command.outputs.is_valid == 'true' &&
needs.check-command.outputs.command == 'build' &&
needs.check-command.outputs.is_pr == 'true'
runs-on: ubuntu-latest
steps:
- name: PR 정보 가져오기
id: pr
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('ref', pr.data.head.ref);
core.setOutput('sha', pr.data.head.sha.substring(0, 7));
return pr.data.number;
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 진행 상황 댓글 시스템
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- name: 진행 상황 댓글 생성
id: progress
uses: actions/github-script@v7
with:
script: |
const prNumber = context.issue.number;
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const startTime = Date.now();
const body = [
'## 🚀 PR Preview 빌드 중...',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
'| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
'| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
core.setOutput('comment_id', comment.id);
core.setOutput('start_time', startTime);
core.setOutput('docker_start', startTime);
- name: 코드 체크아웃
uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.ref }}
# =================================================================
# ⚠️ [영역 2] 환경변수 파일 생성
# =================================================================
- name: "[필수] .env 파일 생성"
run: |
cat << 'EOF' > .env
${{ secrets.ENV_FILE }}
EOF
# =================================================================
# ⚠️ [영역 2 끝] 환경변수 파일 생성 끝
# =================================================================
- name: Docker 로그인
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: Docker 이미지 빌드 & Push
uses: docker/build-push-action@v5
with:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ github.event.issue.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: 진행 상황 - Docker 완료
id: docker_progress
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id }};
const dockerStart = ${{ steps.progress.outputs.docker_start }};
const now = Date.now();
const dockerElapsed = now - dockerStart;
const minutes = Math.floor(dockerElapsed / 60000);
const seconds = Math.floor((dockerElapsed % 60000) / 1000);
const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
'## 🚀 PR Preview 빌드 중...',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
'| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
core.setOutput('docker_duration', dockerDuration);
core.setOutput('deploy_start', now);
- name: 서버에 배포
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ env.SSH_PORT }}
script: |
set -e
# 환경 변수 설정 (시놀로지 NAS용)
export PATH=$PATH:/usr/local/bin
export PW="${{ secrets.SERVER_PASSWORD }}"
# 변수 설정
PR_NUMBER=${{ github.event.issue.number }}
PROJECT_NAME="${{ env.PROJECT_NAME }}"
CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}"
IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}"
DOMAIN="${PROJECT_NAME}-pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}"
INTERNAL_PORT="${{ env.INTERNAL_PORT }}"
TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 PR Preview 배포 시작"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 프로젝트: ${PROJECT_NAME}"
echo "🔢 PR 번호: #${PR_NUMBER}"
echo "📛 컨테이너: ${CONTAINER_NAME}"
echo "🌐 도메인: ${DOMAIN}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 이미지 Pull
echo "📥 Docker 이미지 Pull 중..."
echo $PW | sudo -S docker pull "${IMAGE}"
# 기존 컨테이너 삭제
echo "🗑️ 기존 컨테이너 정리 중..."
echo $PW | sudo -S docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
# 새 컨테이너 실행
echo "🐳 새 컨테이너 실행 중..."
echo $PW | sudo -S docker run -d \
--name "${CONTAINER_NAME}" \
--network "${TRAEFIK_NETWORK}" \
--label "traefik.enable=true" \
--label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \
--label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
--label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \
-e TZ=Asia/Seoul \
-e ENVIRONMENT=prod \
-v /etc/localtime:/etc/localtime:ro \
"${IMAGE}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Health Check (최대 120초 대기) - 하이브리드 방식
# 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
# 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
echo ""
echo "⏳ Health Check 시작 (최대 120초 대기)..."
HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
MAX_RETRIES=24
RETRY_COUNT=0
HEALTH_CHECK_PASSED=false
HEALTH_CHECK_METHOD=""
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
sleep 5
RETRY_COUNT=$((RETRY_COUNT + 1))
# 1. 컨테이너 상태 확인
STATUS=$(echo $PW | sudo -S docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
if [ "$STATUS" = "exited" ]; then
echo "❌ 컨테이너 비정상 종료!"
echo ""
echo "📋 컨테이너 로그 (최근 100줄):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
if [ "$STATUS" = "running" ]; then
# 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
if [ -n "$HEALTH_PATH" ]; then
HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
if [ -n "$HEALTH" ]; then
echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
HEALTH_CHECK_PASSED=true
HEALTH_CHECK_METHOD="HTTP"
break
fi
fi
# 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
if [ -n "$LOG_PATTERN" ]; then
STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
if [ -n "$STARTED" ]; then
echo "✅ 정상 기동 확인! (로그 패턴)"
echo " $STARTED"
HEALTH_CHECK_PASSED=true
HEALTH_CHECK_METHOD="Log"
break
fi
fi
fi
echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
done
if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
echo ""
echo "❌ Health Check 타임아웃 (120초)"
echo ""
echo "📋 컨테이너 로그 (최근 100줄):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- name: 배포 완료 코멘트
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id }};
const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
const now = Date.now();
const deployElapsed = now - deployStart;
const minutes = Math.floor(deployElapsed / 60000);
const seconds = Math.floor((deployElapsed % 60000) / 1000);
const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
const projectName = '${{ env.PROJECT_NAME }}';
const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
const previewPort = '${{ env.PREVIEW_PORT }}';
const apiDocsPath = '${{ env.API_DOCS_PATH }}';
const prNumber = context.issue.number;
const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`;
const previewUrl = `http://${domain}:${previewPort}`;
const sha = '${{ steps.pr.outputs.sha }}';
// Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
const envRows = [
`| **Preview URL** | ${previewUrl} |`,
];
if (apiDocsPath) {
envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
}
envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`);
envRows.push(`| **커밋** | \`${sha}\` |`);
const body = [
'## ✅ PR Preview 배포 완료!',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
`| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
'',
'### 🌐 Preview 환경',
'| 항목 | 값 |',
'|------|-----|',
...envRows,
'',
'### 📋 명령어',
'| 명령어 | 설명 |',
'|--------|------|',
'| `@suh-lab server build` | 최신 커밋으로 재배포 |',
'| `@suh-lab server destroy` | Preview 환경 삭제 |',
'| `@suh-lab server status` | 현재 상태 확인 |',
'',
'---',
'*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
- name: 빌드/배포 실패 시 에러 코멘트
if: failure()
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
const projectName = '${{ env.PROJECT_NAME }}';
const prNumber = context.issue.number;
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
// 진행 상황에 따라 어디서 실패했는지 표시
const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
// 실패 지점 판단 - 초기 단계 실패도 구분
let dockerStatus, deployStatus;
let dockerTime = '-';
if (!commentId) {
// 진행 상황 댓글 생성 전 실패 (초기화 단계)
dockerStatus = '⚠️ 초기화 실패';
deployStatus = '-';
} else if (!dockerDuration || dockerDuration === '') {
// Docker 빌드 단계에서 실패
dockerStatus = '❌ 실패';
deployStatus = '⏸️ 대기';
} else {
// 배포 단계에서 실패
dockerStatus = '✅ 완료';
deployStatus = '❌ 실패';
dockerTime = dockerDuration;
}
const body = [
'## ❌ PR Preview 배포 실패!',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
`| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
'',
`**[📋 빌드/배포 로그 확인](${runUrl})**`,
'',
'### 🔍 가능한 원인',
'- Docker 이미지 빌드 실패 (Python 의존성 문제)',
'- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
'- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
'- 환경변수 누락 (.env 파일 설정 확인)',
'',
'### 💡 다음 단계',
'1. 위 링크에서 빌드/배포 로그를 확인하세요',
'2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`',
'',
'---',
'*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
// 진행 상황 댓글이 있으면 업데이트, 없으면 새로 생성
if (commentId) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
}
# -----------------------------------------------------------------
# Job 2-2: Issue에서 브랜치명 추출
# -----------------------------------------------------------------
get-branch-from-issue:
name: Issue에서 브랜치 추출
needs: check-command
if: |
needs.check-command.outputs.is_valid == 'true' &&
needs.check-command.outputs.is_pr == 'false'
runs-on: ubuntu-latest
outputs:
branch_name: ${{ steps.extract.outputs.branch }}
found: ${{ steps.extract.outputs.found }}
issue_number: ${{ steps.extract.outputs.issue_number }}
steps:
- name: Issue 댓글에서 브랜치명 추출
id: extract
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.issue.number;
const marker = '${{ env.ISSUE_HELPER_MARKER }}';
console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`);
console.log(`📝 마커: ${marker}`);
// Issue의 모든 댓글 조회
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
// 브랜치 추출 패턴 (마커와 무관하게 동일한 형식)
// "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭
const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/;
for (const comment of comments.data) {
// 마커가 포함된 댓글 찾기
if (comment.body.includes(marker)) {
const match = comment.body.match(branchRegex);
if (match) {
const branchName = match[1].trim();
core.setOutput('branch', branchName);
core.setOutput('found', 'true');
core.setOutput('issue_number', issueNumber);
console.log(`✅ 브랜치 발견: ${branchName}`);
return;
}
}
}
// 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리
core.setOutput('found', 'false');
core.setOutput('issue_number', issueNumber);
console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.');
- name: 브랜치 없음 알림
if: steps.extract.outputs.found == 'false'
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.issue.number;
const marker = '${{ env.ISSUE_HELPER_MARKER }}';
const body = [
'## ⚠️ 브랜치를 찾을 수 없습니다',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **Issue** | #${issueNumber} |`,
`| **마커** | \`${marker}\` |`,
'',
'### 💡 확인 사항',
'1. Issue Helper 댓글이 존재하는지 확인하세요',
'2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요',
'3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요',
'',
'---',
'*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
});
# -----------------------------------------------------------------
# Job 2-3: 빌드 & 배포 (Issue 댓글에서 실행)
# -----------------------------------------------------------------
build-preview-issue:
name: Preview 빌드 & 배포 (Issue)
needs: [check-command, get-branch-from-issue]
if: |
needs.check-command.outputs.is_valid == 'true' &&
needs.check-command.outputs.command == 'build' &&
needs.check-command.outputs.is_pr == 'false' &&
needs.get-branch-from-issue.outputs.found == 'true'
runs-on: ubuntu-latest
steps:
- name: 브랜치 존재 확인
id: check_branch
uses: actions/github-script@v7
with:
script: |
const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
console.log(`🔍 브랜치 존재 확인: ${branchName}`);
try {
await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: branchName
});
core.setOutput('exists', 'true');
console.log(`✅ 브랜치 존재: ${branchName}`);
} catch (error) {
if (error.status === 404) {
core.setOutput('exists', 'false');
console.log(`❌ 브랜치 없음: ${branchName}`);
// 브랜치 없음 알림 댓글
const body = [
'## ❌ 브랜치를 찾을 수 없습니다',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **Issue** | #${issueNumber} |`,
`| **브랜치** | \`${branchName}\` |`,
'',
'### 💡 확인 사항',
'1. 브랜치가 push되었는지 확인하세요',
'2. 브랜치명이 정확한지 확인하세요',
'3. 브랜치가 삭제되지 않았는지 확인하세요',
'',
'---',
'*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
});
} else {
throw error;
}
}
- name: 브랜치 없으면 중단
if: steps.check_branch.outputs.exists == 'false'
run: |
echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다."
exit 0
- name: Issue 정보 설정
if: steps.check_branch.outputs.exists == 'true'
id: issue
run: |
echo "ref=${{ needs.get-branch-from-issue.outputs.branch_name }}" >> $GITHUB_OUTPUT
echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT
# 브랜치의 최신 커밋 SHA 가져오기
echo "sha=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.get-branch-from-issue.outputs.branch_name }} | cut -c1-7)" >> $GITHUB_OUTPUT
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 진행 상황 댓글 시스템
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- name: 진행 상황 댓글 생성
if: steps.check_branch.outputs.exists == 'true'
id: progress
uses: actions/github-script@v7
with:
script: |
const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const startTime = Date.now();
const body = [
'## 🚀 Issue Preview 빌드 중...',
'',
`**브랜치**: \`${branchName}\``,
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
'| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
'| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
});
core.setOutput('comment_id', comment.id);
core.setOutput('start_time', startTime);
core.setOutput('docker_start', startTime);
- name: 코드 체크아웃
if: steps.check_branch.outputs.exists == 'true'
uses: actions/checkout@v4
with:
ref: ${{ needs.get-branch-from-issue.outputs.branch_name }}
# =================================================================
# ⚠️ [영역 2] 환경변수 파일 생성
# =================================================================
- name: "[필수] .env 파일 생성"
if: steps.check_branch.outputs.exists == 'true'
run: |
cat << 'EOF' > .env
${{ secrets.ENV_FILE }}
EOF
# =================================================================
# ⚠️ [영역 2 끝] 환경변수 파일 생성 끝
# =================================================================
- name: Docker 로그인
if: steps.check_branch.outputs.exists == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Buildx 설정
if: steps.check_branch.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: Docker 이미지 빌드 & Push
if: steps.check_branch.outputs.exists == 'true'
uses: docker/build-push-action@v5
with:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ needs.get-branch-from-issue.outputs.issue_number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: 진행 상황 - Docker 완료
if: steps.check_branch.outputs.exists == 'true'
id: docker_progress
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id }};
const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
const dockerStart = ${{ steps.progress.outputs.docker_start }};
const now = Date.now();
const dockerElapsed = now - dockerStart;
const minutes = Math.floor(dockerElapsed / 60000);
const seconds = Math.floor((dockerElapsed % 60000) / 1000);
const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
'## 🚀 Issue Preview 빌드 중...',
'',
`**브랜치**: \`${branchName}\``,
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
'| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
core.setOutput('docker_duration', dockerDuration);
core.setOutput('deploy_start', now);
- name: 서버에 배포
if: steps.check_branch.outputs.exists == 'true'
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ env.SSH_PORT }}
script: |
set -e
# 환경 변수 설정 (시놀로지 NAS용)
export PATH=$PATH:/usr/local/bin
export PW="${{ secrets.SERVER_PASSWORD }}"
# 변수 설정
ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }}
PROJECT_NAME="${{ env.PROJECT_NAME }}"
CONTAINER_NAME="${PROJECT_NAME}-pr-${ISSUE_NUMBER}"
IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${ISSUE_NUMBER}"
DOMAIN="${PROJECT_NAME}-pr-${ISSUE_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}"
INTERNAL_PORT="${{ env.INTERNAL_PORT }}"
TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Issue Preview 배포 시작"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 프로젝트: ${PROJECT_NAME}"
echo "🔢 Issue 번호: #${ISSUE_NUMBER}"
echo "📛 컨테이너: ${CONTAINER_NAME}"
echo "🌐 도메인: ${DOMAIN}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 이미지 Pull
echo "📥 Docker 이미지 Pull 중..."
echo $PW | sudo -S docker pull "${IMAGE}"
# 기존 컨테이너 삭제
echo "🗑️ 기존 컨테이너 정리 중..."
echo $PW | sudo -S docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
# 새 컨테이너 실행
echo "🐳 새 컨테이너 실행 중..."
echo $PW | sudo -S docker run -d \
--name "${CONTAINER_NAME}" \
--network "${TRAEFIK_NETWORK}" \
--label "traefik.enable=true" \
--label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \
--label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
--label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \
-e TZ=Asia/Seoul \
-e ENVIRONMENT=prod \
-v /etc/localtime:/etc/localtime:ro \
"${IMAGE}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Health Check (최대 120초 대기) - 하이브리드 방식
# 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
# 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
echo ""
echo "⏳ Health Check 시작 (최대 120초 대기)..."
HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
MAX_RETRIES=24
RETRY_COUNT=0
HEALTH_CHECK_PASSED=false
HEALTH_CHECK_METHOD=""
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
sleep 5
RETRY_COUNT=$((RETRY_COUNT + 1))
# 1. 컨테이너 상태 확인
STATUS=$(echo $PW | sudo -S docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
if [ "$STATUS" = "exited" ]; then
echo "❌ 컨테이너 비정상 종료!"
echo ""
echo "📋 컨테이너 로그 (최근 100줄):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
if [ "$STATUS" = "running" ]; then
# 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
if [ -n "$HEALTH_PATH" ]; then
HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
if [ -n "$HEALTH" ]; then
echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
HEALTH_CHECK_PASSED=true
HEALTH_CHECK_METHOD="HTTP"
break
fi
fi
# 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
if [ -n "$LOG_PATTERN" ]; then
STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
if [ -n "$STARTED" ]; then
echo "✅ 정상 기동 확인! (로그 패턴)"
echo " $STARTED"
HEALTH_CHECK_PASSED=true
HEALTH_CHECK_METHOD="Log"
break
fi
fi
fi
echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
done
if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
echo ""
echo "❌ Health Check 타임아웃 (120초)"
echo ""
echo "📋 컨테이너 로그 (최근 100줄):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- name: 배포 완료 코멘트
if: steps.check_branch.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id }};
const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
const now = Date.now();
const deployElapsed = now - deployStart;
const minutes = Math.floor(deployElapsed / 60000);
const seconds = Math.floor((deployElapsed % 60000) / 1000);
const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
const projectName = '${{ env.PROJECT_NAME }}';
const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
const previewPort = '${{ env.PREVIEW_PORT }}';
const apiDocsPath = '${{ env.API_DOCS_PATH }}';
const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`;
const previewUrl = `http://${domain}:${previewPort}`;
// Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
const envRows = [
`| **Preview URL** | ${previewUrl} |`,
];
if (apiDocsPath) {
envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
}
envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`);
envRows.push(`| **브랜치** | \`${branchName}\` |`);
const body = [
'## ✅ Issue Preview 배포 완료!',
'',
`**브랜치**: \`${branchName}\``,
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
`| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
'',
'### 🌐 Preview 환경',
'| 항목 | 값 |',
'|------|-----|',
...envRows,
'',
'### 📋 명령어',
'| 명령어 | 설명 |',
'|--------|------|',
'| `@suh-lab server build` | 최신 커밋으로 재배포 |',
'| `@suh-lab server destroy` | Preview 환경 삭제 |',
'| `@suh-lab server status` | 현재 상태 확인 |',
'',
'---',
'*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
- name: 빌드/배포 실패 시 에러 코멘트
if: failure() && steps.check_branch.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
const projectName = '${{ env.PROJECT_NAME }}';
const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
let dockerStatus, deployStatus;
let dockerTime = '-';
if (!commentId) {
dockerStatus = '⚠️ 초기화 실패';
deployStatus = '-';
} else if (!dockerDuration || dockerDuration === '') {
dockerStatus = '❌ 실패';
deployStatus = '⏸️ 대기';
} else {
dockerStatus = '✅ 완료';
deployStatus = '❌ 실패';
dockerTime = dockerDuration;
}
const body = [
'## ❌ Issue Preview 배포 실패!',
'',
`**브랜치**: \`${branchName}\``,
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
`| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
'',
`**[📋 빌드/배포 로그 확인](${runUrl})**`,
'',
'### 🔍 가능한 원인',
'- Docker 이미지 빌드 실패 (Python 의존성 문제)',
'- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
'- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
'- 환경변수 누락 (.env 파일 설정 확인)',
'',
'### 💡 다음 단계',
'1. 위 링크에서 빌드/배포 로그를 확인하세요',
'2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`',
'',
'---',
'*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
if (commentId) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
});
}
# -----------------------------------------------------------------
# Job 3: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령)
# -----------------------------------------------------------------
destroy-preview:
name: Preview 삭제
# needs 의존성 제거: PR/Issue 닫힘 이벤트에서도 독립적으로 실행되도록 함
if: |
(github.event_name == 'pull_request' && github.event.action == 'closed') ||
(github.event_name == 'issues' && github.event.action == 'closed') ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server destroy'))
runs-on: ubuntu-latest
steps:
- name: PR/Issue 번호 가져오기
id: pr_number
run: |
if [[ "${{ github.event_name }}" == "issue_comment" ]]; then
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
echo "type=comment" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
echo "type=pr_closed" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
echo "type=issue_closed" >> $GITHUB_OUTPUT
fi
- name: 컨테이너 & 이미지 삭제
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ env.SSH_PORT }}
script: |
# 환경 변수 설정 (시놀로지 NAS용)
export PATH=$PATH:/usr/local/bin
export PW="${{ secrets.SERVER_PASSWORD }}"
PR_NUMBER=${{ steps.pr_number.outputs.number }}
PROJECT_NAME="${{ env.PROJECT_NAME }}"
CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}"
IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🗑️ PR Preview 삭제"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 프로젝트: ${PROJECT_NAME}"
echo "🔢 PR 번호: #${PR_NUMBER}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 컨테이너 삭제
echo "🐳 컨테이너 삭제 중..."
echo $PW | sudo -S docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
# 이미지 삭제
echo "🖼️ 이미지 삭제 중..."
echo $PW | sudo -S docker rmi "${IMAGE}" 2>/dev/null || true
echo "✅ 삭제 완료!"
- name: 삭제 완료 코멘트
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
const number = ${{ steps.pr_number.outputs.number }};
const projectName = '${{ env.PROJECT_NAME }}';
const body = [
'## 🗑️ Preview 환경 삭제 완료!',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **컨테이너** | \`${projectName}-pr-${number}\` |`,
'| **상태** | 삭제됨 |',
'',
'다시 배포하려면: `@suh-lab server build`',
'',
'---',
'*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
body: body
});
# -----------------------------------------------------------------
# Job 4: 상태 확인 (PR/Issue 모두 지원)
# -----------------------------------------------------------------
check-status:
name: Preview 상태 확인
needs: check-command
if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'status'
runs-on: ubuntu-latest
steps:
- name: 컨테이너 상태 확인
id: status
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ env.SSH_PORT }}
script: |
# 환경 변수 설정 (시놀로지 NAS용)
export PATH=$PATH:/usr/local/bin
NUMBER=${{ github.event.issue.number }}
PROJECT_NAME="${{ env.PROJECT_NAME }}"
CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🔍 Preview 상태 확인"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then
echo "STATUS=running"
echo "✅ 컨테이너 실행 중"
docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}"
else
echo "STATUS=not_found"
echo "❌ 컨테이너 없음"
fi
- name: 상태 코멘트 (실행 중)
uses: appleboy/ssh-action@v1.0.3
id: check_running
continue-on-error: true
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ env.SSH_PORT }}
script: |
# 환경 변수 설정 (시놀로지 NAS용)
export PATH=$PATH:/usr/local/bin
NUMBER=${{ github.event.issue.number }}
PROJECT_NAME="${{ env.PROJECT_NAME }}"
CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"
- name: 상태 코멘트 작성
uses: actions/github-script@v7
with:
script: |
const number = context.issue.number;
const projectName = '${{ env.PROJECT_NAME }}';
const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
const previewPort = '${{ env.PREVIEW_PORT }}';
const domain = `${projectName}-pr-${number}.${domainSuffix}`;
const previewUrl = `http://${domain}:${previewPort}`;
const isRunning = '${{ steps.check_running.outcome }}' === 'success';
const isPr = '${{ needs.check-command.outputs.is_pr }}' === 'true';
const contextType = isPr ? 'PR' : 'Issue';
let body;
if (isRunning) {
body = [
'## ✅ Preview 환경 실행 중',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **Preview URL** | ${previewUrl} |`,
`| **컨테이너** | \`${projectName}-pr-${number}\` |`,
'| **상태** | 🟢 Running |',
'',
'### 📋 명령어',
'| 명령어 | 설명 |',
'|--------|------|',
'| `@suh-lab server build` | 최신 커밋으로 재배포 |',
'| `@suh-lab server destroy` | Preview 환경 삭제 |',
'',
'---',
`*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
].join('\n');
} else {
body = [
'## ❌ Preview 환경 없음',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **컨테이너** | \`${projectName}-pr-${number}\` |`,
'| **상태** | 🔴 Not Found |',
'',
'배포하려면: `@suh-lab server build`',
'',
'---',
`*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
].join('\n');
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
body: body
});