⚙️ [기능추가][BE-Callback] 백엔드의 /api/ai/callback 연동 API 개발 필요 #97
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # =================================================================== | |
| # 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 | |
| }); |