post-deploy-smoke #1600
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
| name: post-deploy-smoke | |
| # Production smoke test: 104일간 보호 라우트가 404 였던 사건 재발 방지. | |
| # 모든 알림은 한글로 Discord 채널에 보고됩니다. | |
| on: | |
| push: | |
| branches: [main] | |
| schedule: | |
| - cron: "*/15 * * * *" | |
| workflow_dispatch: | |
| inputs: | |
| test_discord: | |
| description: "Send a test Discord notification (set true to verify webhook)" | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: read | |
| actions: read # 연속 실패 판정: 직전 run 결론 조회 (gh run list) | |
| jobs: | |
| smoke: | |
| name: Probe ccgather.com | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 14 # probe(10) + push 대기(3) + deadman 여유 — 전체 다운 시 deadman 가동 보장 | |
| env: | |
| BASE_URL: https://ccgather.com | |
| outputs: | |
| failures: ${{ steps.probe.outputs.failures }} | |
| failed_count: ${{ steps.probe.outputs.failed_count }} | |
| summary: ${{ steps.probe.outputs.summary }} | |
| steps: | |
| - name: Wait for Vercel deploy (push trigger only) | |
| if: github.event_name == 'push' | |
| run: | | |
| echo "180초 동안 Vercel 배포 완료 대기 중…" | |
| sleep 180 | |
| # 수동 테스트용 — webhook 연결 검증 | |
| - name: Send test Discord notification | |
| if: inputs.test_discord == true | |
| env: | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| if [ -z "$DISCORD_WEBHOOK_URL" ]; then | |
| echo "::error::DISCORD_WEBHOOK_URL secret 미설정" | |
| exit 1 | |
| fi | |
| KST=$(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M KST') | |
| payload=$(cat <<JSON | |
| { | |
| "username": "CCgather 감시봇", | |
| "content": "🧪 **테스트 알림** — Discord webhook 정상 작동 확인", | |
| "embeds": [{ | |
| "title": "Smoke 테스트 연동 검증 완료", | |
| "url": "${RUN_URL}", | |
| "color": 5763719, | |
| "fields": [ | |
| { "name": "상태", "value": "Webhook 살아있음. 실제 장애 발생 시 이 채널로 알림이 옵니다." }, | |
| { "name": "시각 (KST)", "value": "${KST}" } | |
| ] | |
| }] | |
| } | |
| JSON | |
| ) | |
| http=$(curl -sS -o /tmp/resp -w "%{http_code}" -X POST -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL") | |
| echo "HTTP $http" | |
| [ "$http" = "204" ] || [ "$http" = "200" ] || { echo "::error::Discord 가 거부함"; exit 1; } | |
| - name: Run all production probes | |
| id: probe | |
| timeout-minutes: 10 # 전체 다운 시 여기서 끊고 dead-man's switch 로 넘김 (job 14 보다 작게) | |
| run: | | |
| set +e | |
| failures=() | |
| # ─── 헬퍼: 일시적 글리치 (network timeout, 502/503/504) 1회 재시도 ─── | |
| # 의도: GitHub Actions ↔ Vercel 의 cold start / 일시 망 글리치로 인한 | |
| # false positive 차단. 진짜 빌드 fail (404/500) 은 즉시 결과 (재시도 X). | |
| # 최대 timeout 30s 로 콜드스타트 여유 확보 (15s→30s, off-peak 콜드스타트 대응) + hang 방지. | |
| fetch_code() { | |
| local url="$1" | |
| local code | |
| code=$(curl -sS -o /dev/null --max-time 30 -w "%{http_code}" "$url" 2>/dev/null) | |
| code="${code:-000}" | |
| # 네트워크 글리치/일시 서버 에러만 재시도 (총 2회 추가, 30s + 60s 백오프) | |
| local attempt=1 | |
| while [ "$code" = "000" ] || [ "$code" = "502" ] || [ "$code" = "503" ] || [ "$code" = "504" ]; do | |
| if [ $attempt -gt 2 ]; then break; fi | |
| local backoff=$((attempt * 30)) | |
| echo " [retry $attempt] $url 응답 $code → ${backoff}s 대기 후 재시도" >&2 | |
| sleep $backoff | |
| code=$(curl -sS -o /dev/null --max-time 30 -w "%{http_code}" "$url" 2>/dev/null) | |
| code="${code:-000}" | |
| attempt=$((attempt + 1)) | |
| done | |
| echo "$code" | |
| } | |
| fetch_matched() { | |
| local url="$1" | |
| curl -sS -D - -o /dev/null --max-time 30 "$url" 2>/dev/null | grep -i "^x-matched-path" | tr -d '\r' | awk '{print $2}' | |
| } | |
| fetch_html() { | |
| local url="$1" | |
| local html | |
| html=$(curl -sS --max-time 30 "$url" 2>/dev/null || echo "") | |
| if [ ${#html} -lt 1000 ]; then | |
| echo " [retry] $url HTML 작음 (${#html} bytes) → 30s 대기 후 재시도" >&2 | |
| sleep 30 | |
| html=$(curl -sS --max-time 30 "$url" 2>/dev/null || echo "") | |
| fi | |
| echo "$html" | |
| } | |
| # 콜드스타트 워밍(A): /sso-callback 은 저트래픽 새벽에 Vercel 함수가 콜드→첫 요청 30s | |
| # 타임아웃(000) 오탐(2026-07-05 03:24 KST 사례). strict 검사 전 넉넉히 1회 깨움. 결과 | |
| # 무시 — 진짜 다운은 아래 probe 가 잡음. 반복 오탐 라우트는 WARMUP 배열에 추가. | |
| WARMUP=("/sso-callback") | |
| for path in "${WARMUP[@]}"; do | |
| wcode=$(curl -sS -o /dev/null --max-time 45 -w "%{http_code}" "${BASE_URL}${path}" 2>/dev/null) | |
| echo " [warmup] ${path} → ${wcode:-000} (결과 무시, 함수 예열용)" | |
| done | |
| # ─── 보호 라우트: 비인증 시 redirect(307)/401 가 정상. 404/500 면 빌드 누락 ─── | |
| PROTECTED=( | |
| "/onboarding" | |
| "/settings" | |
| "/notifications" | |
| "/profile" | |
| "/api/me" | |
| "/api/feedback" | |
| "/api/submit" | |
| "/api/profile" | |
| "/api/notifications" | |
| "/api/badges" | |
| "/api/community" | |
| ) | |
| for path in "${PROTECTED[@]}"; do | |
| url="${BASE_URL}${path}" | |
| code=$(fetch_code "$url") | |
| matched="" | |
| if [ "$code" != "000" ]; then | |
| matched=$(fetch_matched "$url") | |
| fi | |
| echo " $code $path matched=$matched" | |
| if [ "$code" = "404" ] || [ "$code" = "500" ] || [ "$matched" = "/404" ]; then | |
| failures+=("🔒 **보호 라우트 빌드 누락** — \`${path}\` 가 HTTP ${code} 응답 (정상은 307 redirect). 가입 후 사용자가 404 페이지를 봅니다.") | |
| fi | |
| done | |
| # ─── 공개 페이지: 200 강제 ─── | |
| PUBLIC=("/" "/sign-up" "/sign-in" "/sso-callback" "/leaderboard" "/cli/auth" "/terms" "/privacy") | |
| for path in "${PUBLIC[@]}"; do | |
| url="${BASE_URL}${path}" | |
| code=$(fetch_code "$url") | |
| echo " $code $path" | |
| if [ "$code" != "200" ]; then | |
| failures+=("🌐 **공개 페이지 다운** — \`${path}\` 가 HTTP ${code} 응답 (정상은 200). 이 페이지로 진입한 사용자가 화면을 못 봅니다.") | |
| fi | |
| done | |
| # ─── API: 200/405 OK, 404/500 fail ─── | |
| APIS=("/api/leaderboard" "/api/cli/auth/device" "/api/webhooks/clerk" "/api/stats/global") | |
| for path in "${APIS[@]}"; do | |
| url="${BASE_URL}${path}" | |
| code=$(fetch_code "$url") | |
| echo " $code $path" | |
| if [ "$code" = "404" ] || [ "$code" = "500" ]; then | |
| ctx="" | |
| case "$path" in | |
| "/api/webhooks/clerk") ctx=" — Clerk 가 신규 가입자 정보를 전달 못 함" ;; | |
| "/api/leaderboard") ctx=" — 리더보드 자체가 빈 화면" ;; | |
| "/api/cli/auth/device") ctx=" — CLI 신규 인증 불가" ;; | |
| esac | |
| failures+=("🛠 **API 다운** — \`${path}\` 가 HTTP ${code}${ctx}.") | |
| fi | |
| done | |
| # ─── /sign-up HTML: Clerk hosted Portal fallback 차단 ─── | |
| html=$(fetch_html "${BASE_URL}/sign-up") | |
| size=${#html} | |
| echo "/sign-up HTML 크기: $size bytes" | |
| if [ "$size" -lt 5000 ]; then | |
| failures+=("📄 **/sign-up 비정상** — HTML 크기가 ${size} bytes 밖에 안 됨 (정상은 30KB+). 페이지 렌더 실패.") | |
| fi | |
| if echo "$html" | grep -qi "accounts.dev"; then | |
| failures+=("🔑 **Clerk Portal fallback 노출** — /sign-up 이 Clerk 의 외부 Portal 로 떨어짐. \`NEXT_PUBLIC_CLERK_SIGN_IN_URL\` env 또는 미들웨어 \`unauthenticatedUrl\` 설정 누락.") | |
| fi | |
| # 결과 집계 | |
| count=${#failures[@]} | |
| echo "failed_count=${count}" >> "$GITHUB_OUTPUT" | |
| if [ "$count" -eq 0 ]; then | |
| echo "✅ 모든 검증 통과" | |
| echo "summary=ok" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # multiline output 으로 실패 내용 전달 | |
| { | |
| echo "failures<<FAILEOF" | |
| printf '%s\n' "${failures[@]}" | |
| echo "FAILEOF" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "summary=${count}개 검증 실패" >> "$GITHUB_OUTPUT" | |
| echo "::error::production 스모크 ${count}개 실패" | |
| exit 1 | |
| # ─── 연속 실패 판정 (알림 과민 완화) ─── | |
| # schedule + 직전 완료 run 도 failure → 연속 → @here (loud=true). | |
| # schedule 1차 감지(직전 정상)는 일시 글리치(콜드스타트 등) 가능 → @here 보류 (loud=false). | |
| # push/수동은 즉시 @here. fail-safe: 직전이 'success' 로 확인될 때만 조용히, 불명이면 loud. | |
| - name: Determine alert severity | |
| id: severity | |
| if: failure() && steps.probe.outputs.failed_count != '' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} # 필수·삭제금지: checkout 없어 gh run list 가 레포를 못 찾음 → 직전 결론 '불명' → @here 오발 | |
| TRIGGER: ${{ github.event_name }} | |
| run: | | |
| set +e | |
| if [ "$TRIGGER" != "schedule" ]; then | |
| echo "loud=true" >> "$GITHUB_OUTPUT" | |
| echo "트리거=${TRIGGER} → 즉시 @here" | |
| exit 0 | |
| fi | |
| prev=$(gh run list --workflow=post-deploy-smoke.yml --status completed \ | |
| --limit 1 --json conclusion -q '.[0].conclusion' 2>/dev/null) | |
| echo "직전 완료 run 결론: ${prev:-불명}" | |
| if [ "$prev" = "success" ]; then | |
| echo "loud=false" >> "$GITHUB_OUTPUT" | |
| echo "schedule 1차 감지 (직전 정상) → @here 보류, 노랑 경고만" | |
| else | |
| echo "loud=true" >> "$GITHUB_OUTPUT" | |
| echo "schedule 연속 실패 또는 직전 불명 → @here (fail-safe)" | |
| fi | |
| exit 0 | |
| # ─── 한글 Discord 알림 — 어떤 라우트가 어떻게 깨졌는지 명시 ─── | |
| - name: Notify Discord on failure | |
| if: failure() && steps.probe.outputs.failed_count != '' | |
| env: | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| SHA_SHORT: ${{ github.sha }} | |
| TRIGGER: ${{ github.event_name }} | |
| FAIL_COUNT: ${{ steps.probe.outputs.failed_count }} | |
| FAILURES: ${{ steps.probe.outputs.failures }} | |
| COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} | |
| LOUD: ${{ steps.severity.outputs.loud }} | |
| run: | | |
| if [ -z "$DISCORD_WEBHOOK_URL" ]; then | |
| echo "::warning::DISCORD_WEBHOOK_URL secret 미설정 — 알림 스킵" | |
| exit 0 | |
| fi | |
| KST=$(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M KST') | |
| SHA7=${SHA_SHORT:0:7} | |
| # 트리거 한글 매핑 | |
| case "$TRIGGER" in | |
| push) trigger_kr="🚢 git push 직후 (방금 배포된 코드가 깨뜨림)" ;; | |
| schedule) trigger_kr="⏰ 정기 점검 (15분 주기 자동 감시)" ;; | |
| workflow_dispatch) trigger_kr="🖱 수동 실행" ;; | |
| *) trigger_kr="$TRIGGER" ;; | |
| esac | |
| # 실패 항목을 Discord 임베드 description 으로 | |
| # JSON 안전 escape (jq 사용) | |
| fail_json=$(printf '%s' "$FAILURES" | jq -Rs .) | |
| trigger_json=$(printf '%s' "$trigger_kr" | jq -Rs .) | |
| # 알림 강도 — 연속 실패 판정 결과 (loud=false 면 @here 없이 노랑 경고) | |
| if [ "$LOUD" = "false" ]; then | |
| content="⚠️ **production 1차 이상 감지** — 일시 글리치(콜드스타트 등) 가능성으로 멘션 보류. 다음 주기 재확인 중, 연속 실패 시 긴급 호출 발사." | |
| color=16776960 | |
| title="⚠️ ${FAIL_COUNT}개 검증 실패 (1차 감지 · 미확정)" | |
| else | |
| content="🚨 @here **production 장애 감지** — 신규 가입 흐름이 깨졌을 가능성. 즉시 확인 필요." | |
| color=15158332 | |
| title="❌ ${FAIL_COUNT}개 검증 실패 — 클릭해서 상세 로그" | |
| fi | |
| content_json=$(printf '%s' "$content" | jq -Rs .) | |
| title_json=$(printf '%s' "$title" | jq -Rs .) | |
| payload=$(cat <<JSON | |
| { | |
| "username": "CCgather 감시봇", | |
| "content": ${content_json}, | |
| "embeds": [{ | |
| "title": ${title_json}, | |
| "url": "${RUN_URL}", | |
| "color": ${color}, | |
| "description": ${fail_json}, | |
| "fields": [ | |
| { "name": "발생 시각", "value": "${KST}", "inline": true }, | |
| { "name": "트리거", "value": ${trigger_json}, "inline": true }, | |
| { "name": "마지막 커밋", "value": "[\`${SHA7}\`](${COMMIT_URL})", "inline": true }, | |
| { "name": "📋 권장 조치 순서", "value": "1️⃣ 위 제목 클릭 → Actions 로그에서 어떤 라우트인지 확인\\n2️⃣ Vercel Dashboard → Deployments → 최근 deploy build log 확인\\n3️⃣ 필요시 직전 정상 deploy 로 rollback (Vercel 의 'Promote to Production')\\n4️⃣ 원인 commit 찾아서 hotfix push" } | |
| ], | |
| "footer": { "text": "CCgather production · ${BASE_URL}" }, | |
| "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| }] | |
| } | |
| JSON | |
| ) | |
| http=$(curl -sS -o /tmp/resp -w "%{http_code}" -X POST -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL") | |
| echo "Discord 알림 응답 HTTP $http" | |
| cat /tmp/resp | |
| [ "$http" = "204" ] || [ "$http" = "200" ] || echo "::warning::Discord 알림 실패" | |
| # ─── dead-man's switch: probe 자체가 비정상 종료(타임아웃/크래시)면 그게 곧 비상 ─── | |
| # 정상 실패는 failed_count 가 채워져 위 Notify step 이 처리. 여기는 failed_count 가 | |
| # 비었을 때(= probe 가 timeout-minutes 초과 등으로 끝까지 못 감) = 전체 다운 의심 → 무조건 @here. | |
| # (probe 의 step-level timeout 이 job timeout 보다 작아 job 취소가 아닌 step 실패로 처리됨) | |
| - name: Alert on abnormal probe termination | |
| if: always() && steps.probe.outcome == 'failure' && steps.probe.outputs.failed_count == '' | |
| env: | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| if [ -z "$DISCORD_WEBHOOK_URL" ]; then | |
| echo "::warning::DISCORD_WEBHOOK_URL 미설정 — 알림 스킵" | |
| exit 0 | |
| fi | |
| KST=$(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M KST') | |
| payload=$(cat <<JSON | |
| { | |
| "username": "CCgather 감시봇", | |
| "content": "🚨 @here **스모크 비정상 종료** — probe 가 끝까지 실행 못 함(타임아웃/크래시). 사이트 전체 다운 의심, 즉시 확인.", | |
| "embeds": [{ | |
| "title": "❗ production 스모크 자체 실패 (결과 미산출)", | |
| "url": "${RUN_URL}", | |
| "color": 15158332, | |
| "fields": [ | |
| { "name": "발생 시각", "value": "${KST}", "inline": true }, | |
| { "name": "의미", "value": "probe step 이 timeout-minutes 초과 등으로 종료 → 다수 라우트 무응답 가능성. Vercel/플랫폼 상태부터 확인." } | |
| ], | |
| "footer": { "text": "CCgather production · ${BASE_URL}" }, | |
| "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| }] | |
| } | |
| JSON | |
| ) | |
| curl -sS -o /dev/null -w "deadman Discord HTTP %{http_code}\n" -X POST -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL" || true |