Skip to content

post-deploy-smoke #1600

post-deploy-smoke

post-deploy-smoke #1600

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