name: TaxBaik CI/CD on: workflow_dispatch: push: branches: - master jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0' - name: Restore dependencies run: dotnet restore src/TaxBaik.sln - name: Build solution run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - name: Test solution run: dotnet test src/TaxBaik.sln -c Release --no-build - name: Publish Web (auto-includes WASM from referenced TaxBaik.Web.Client) run: | set -e mkdir -p ./publish-logs web_log="./publish-logs/publish-web.log" start=$(date +%s) # Web.Client needs a Release static-web-assets manifest for Web publish. # Build it explicitly so publish can reuse the prepared outputs. dotnet build src/TaxBaik.Web.Client/TaxBaik.Web.Client.csproj -c Release --no-restore -p:ContinuousIntegrationBuild=true # Build the Web host in Release as well so publish has the same inputs # the server uses in production. dotnet build src/TaxBaik.Web/TaxBaik.Web.csproj -c Release --no-restore -p:ContinuousIntegrationBuild=true echo "--- Web.Client Release artifacts ---" ls -la src/TaxBaik.Web.Client/bin/Release/net10.0 || true ls -la src/TaxBaik.Web.Client/obj/Release/net10.0 || true if ! dotnet publish src/TaxBaik.Web/ \ -c Release \ -o ./publish \ --no-restore \ -p:SelfContained=false \ -p:PublishReadyToRun=false \ -p:PerformanceSummary=true \ -clp:Summary \ -bl:"./publish-logs/publish-web.binlog" >"$web_log" 2>&1; then echo "=== Publish Web failed; tailing log ===" tail -n 120 "$web_log" || true exit 1 fi end=$(date +%s) echo "✓ Publish Web elapsed: $((end - start))s" ls -lh ./publish-logs/publish-web.binlog - name: Publish Proxy run: | set -e mkdir -p ./publish-logs # Proxy is not part of the solution restore graph, so restore it once # here before publishing to avoid NETSDK1004 in CI. dotnet restore src/TaxBaik.Proxy/ dotnet build src/TaxBaik.Proxy/TaxBaik.Proxy.csproj -c Release --no-restore start=$(date +%s) dotnet publish src/TaxBaik.Proxy/ \ -c Release \ -o ./publish/proxy \ --no-restore \ --no-build \ -p:PublishReadyToRun=false \ -p:PerformanceSummary=true \ -clp:Summary \ -bl:./publish-logs/publish-proxy.binlog end=$(date +%s) echo "✓ Publish Proxy elapsed: $((end - start))s" ls -lh ./publish-logs/publish-proxy.binlog - name: Write production secrets run: | set -e JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}" TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}" TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}" TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}" TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}" [ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; } [ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; } [ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; } [ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID" [ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480" JWT_SECRET_KEY="$JWT_SECRET_KEY" \ TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \ TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \ TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \ TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \ python3 -c ' import json, os, pathlib pathlib.Path("./publish/appsettings.Production.json").write_text( json.dumps({ "Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]}, "Telegram": { "BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"], "InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"], "SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"] } }, ensure_ascii=False, indent=2), encoding="utf-8" )' test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; } - name: Verify proxy artifact run: | test -s ./publish/proxy/TaxBaik.Proxy.dll || { echo "TaxBaik.Proxy.dll missing" >&2; exit 1; } test -s ./publish/proxy/TaxBaik.Proxy.runtimeconfig.json || { echo "TaxBaik.Proxy.runtimeconfig.json missing" >&2; exit 1; } - name: Copy migrations run: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true - name: Validate migration version uniqueness run: bash scripts/validate_migrations.sh db/migrations - name: Validate KST timestamps run: bash scripts/validate_kst_timestamps.sh - name: Generate build info run: | COMMIT_HASH=$(git rev-parse --short HEAD) BUILD_TIME=$(TZ=Asia/Seoul date +'%Y-%m-%d %H:%M:%S KST') mkdir -p ./publish/wwwroot printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME" - name: Setup SSH run: | mkdir -p ~/.ssh SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}" SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}" if [ -n "$SSH_KEY_B64" ]; then printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519 elif [ -n "$SSH_KEY_RAW" ]; then if printf '%s' "$SSH_KEY_RAW" | grep -q 'BEGIN .*PRIVATE KEY'; then printf '%b\n' "$SSH_KEY_RAW" > ~/.ssh/id_ed25519 else printf '%s' "$SSH_KEY_RAW" | base64 -d > ~/.ssh/id_ed25519 fi else echo "Missing DEPLOY_SSH_KEY_B64 or DEPLOY_SSH_KEY" >&2; exit 1 fi sed -i 's/\r$//' ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true - name: Package artifact run: | cp deploy_gb.sh ./publish/deploy_gb.sh mkdir -p ./publish/scripts cp scripts/validate_migrations.sh ./publish/scripts/validate_migrations.sh chmod +x ./publish/scripts/validate_migrations.sh tar -czf taxbaik_deploy.tgz -C ./publish . echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)" - name: Deploy & verify on server run: | set -e export TAXBAIK_DEPLOY_FROM_CI=1 TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S) COMMIT=$(git rev-parse --short HEAD) DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" DEPLOY_USER="${{ secrets.DEPLOY_USER }}" TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}" TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}" TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}" send_telegram() { local text="$1" if [ -z "$TELEGRAM_BOT_TOKEN" ]; then echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2 return 0 fi curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ --data-urlencode "text=${text}" \ -d "parse_mode=HTML" >/dev/null || true } notify_failure() { local exit_code=$? send_telegram "❌ TaxBaik 배포 실패 커밋: ${COMMIT} 시간: ${TIMESTAMP} 단계: CI/CD deploy" exit "$exit_code" } trap notify_failure ERR echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ===" # 1. 아티팩트 업로드 scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \ taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz" # 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원) ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \ -o ServerAliveInterval=10 \ "$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE set -e DEPLOY_HOME="/home/kjh2064" DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}" TIMESTAMP="${TIMESTAMP}" COMMIT="${COMMIT}" echo "--- [1/5] 압축 해제 ---" mkdir -p "\$DEPLOY_DIR" tar -xzf "/tmp/taxbaik_\${TIMESTAMP}.tgz" -C "\$DEPLOY_DIR" rm -f "/tmp/taxbaik_\${TIMESTAMP}.tgz" echo "--- [2/5] 운영 설정 검증 ---" test -s "\$DEPLOY_DIR/appsettings.Production.json" \ || { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; } test -s "\$DEPLOY_DIR/proxy/TaxBaik.Proxy.dll" \ || { echo "FATAL: TaxBaik.Proxy.dll 없음" >&2; exit 1; } echo "--- [3/5] 마이그레이션 사전 검증 ---" test -x "\$DEPLOY_DIR/scripts/validate_migrations.sh" \ || { echo "FATAL: validate_migrations.sh 없음" >&2; exit 1; } "\$DEPLOY_DIR/scripts/validate_migrations.sh" "\$DEPLOY_DIR/db/migrations" "postgresql://taxbaik:taxbaik123@localhost:5432/taxbaikdb" echo "--- [4/5] Green-Blue 배포 실행 ---" chmod +x "\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR" echo "--- [4.5/5] Nginx 설정 검증 ---" # 실제 로드되는 파일은 sites-enabled/의 심볼릭 링크 대상만이다. # sites-available/에 다른 파일(예: default)이 있어도 sites-enabled에 # 링크되어 있지 않으면 nginx는 그 내용을 절대 읽지 않는다. NGINX_CONF="" for f in /etc/nginx/sites-enabled/*; do if [ -e "\$f" ] && grep -q "location /taxbaik" "\$f" 2>/dev/null; then NGINX_CONF=\$(readlink -f "\$f") break fi done if [ -z "\$NGINX_CONF" ]; then echo "❌ FATAL: sites-enabled/ 안에서 'location /taxbaik'를 정의한 파일을 찾을 수 없음" >&2 echo " sites-available/에 파일을 수정해도 sites-enabled에 심볼릭 링크되어 있지 않으면 반영되지 않는다." >&2 exit 1 fi echo "실제 로드되는 설정 파일: \$NGINX_CONF" # 불변식: '/'와 '/taxbaik' location 모두 반드시 127.0.0.1:5001 (TaxBaik.Proxy)을 # 가리켜야 한다. 5003/5004를 직접 하드코딩하면 Green-Blue 포트 전환 시 # 죽은 포트를 가리키게 되어 502/404가 발생한다 (실제 발생했던 장애). if grep -E "proxy_pass\s+http://127\.0\.0\.1:500[34]" "\$NGINX_CONF" > /dev/null 2>&1; then echo "❌ FATAL: \$NGINX_CONF 가 포트 5003/5004를 직접 참조함 (Green-Blue 전환 시 502 발생)" >&2 echo " 수정: sudo sed -i 's|127.0.0.1:500[34]|127.0.0.1:5001|g' \$NGINX_CONF && sudo nginx -t && sudo systemctl reload nginx" >&2 exit 1 fi # proxy_pass에 URI(끝 슬래시)가 있으면 nginx가 요청 경로를 재작성하며, # location 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(//)가 # 전달되어 404가 발생한다 (실제 발생했던 장애). 접두사 location에서는 # proxy_pass에 URI를 붙이지 않는다. if grep -E "location\s+/taxbaik\s*\{" -A 1 "\$NGINX_CONF" | grep -qE "proxy_pass\s+http://127\.0\.0\.1:5001/;"; then echo "❌ FATAL: location /taxbaik 의 proxy_pass 에 불필요한 trailing slash가 있음 (이중 슬래시로 인한 404 위험)" >&2 exit 1 fi echo "✓ Nginx 설정 검증 통과 (실제 로드 파일 확인 + 포트 5001 고정 + trailing slash 없음)" echo "--- [5/5] 헬스 체크 (최대 60초) ---" ATTEMPTS=20 for i in \$(seq 1 \$ATTEMPTS); do STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/healthz 2>/dev/null || echo "000") if [ "\$STATUS" = "200" ]; then echo "✓ [1/6] 헬스 체크 완료" # 검증 1: 메인 페이지 로드. curl -L + -w 는 리다이렉트 체인의 상태코드를 # 이어붙이므로, 첫 응답 코드만 받아 200/3xx를 허용한다. MAIN_STATUS=\$(curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000") if ! printf '%s' "\$MAIN_STATUS" | grep -Eq '^(200|301|302|307|308)$'; then echo "❌ 메인 페이지 로드 실패 (상태: \$MAIN_STATUS)" >&2 exit 1 fi echo "✓ [2/6] 메인 페이지 로드 완료" # 검증 2: CSS 파일 로드 CSS_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/css/admin.css 2>/dev/null || echo "000") if [ "\$CSS_STATUS" != "200" ]; then echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2 exit 1 fi echo "✓ [3/6] CSS 파일 로드 완료" # 검증 3: 버전 정보. 파일 존재만 보면 5001이 잘못된 구 프로세스를 # 가리키는 장애를 놓치므로, HTTP 응답이 이번 커밋인지 확인한다. if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then echo "❌ version.json 누락" >&2 exit 1 fi VERSION_JSON=\$(curl -fsS http://127.0.0.1:5001/taxbaik/version.json 2>/dev/null || true) if ! printf '%s' "\$VERSION_JSON" | grep -q "\"version\": \"\$COMMIT\""; then echo "❌ 5001 프록시가 이번 배포 버전을 제공하지 않음" >&2 echo " expected: \$COMMIT" >&2 echo " actual: \$VERSION_JSON" >&2 echo " 확인: 5001 포트가 TaxBaik.Proxy.dll인지, /home/kjh2064/taxbaik_port가 새 포트인지 점검" >&2 exit 1 fi echo "✓ [4/6] 버전 정보 확인 완료" # 검증 4: 5001 프록시 확인 if ! ss -tlnp | grep -q ':5001 '; then echo "❌ 5001 프록시가 실행 중이 아님" >&2 exit 1 fi echo "✓ [5/6] 5001 프록시 확인 완료" # 검증 5: 관리자 로그인 페이지 LOGIN_STATUS=\$(curl -fsSL -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000") if [ "\$LOGIN_STATUS" != "200" ]; then echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2 exit 1 fi echo "✓ [6/6] 관리자 페이지 로드 완료" echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)" # 구 배포 디렉토리 정리 (최근 5개 보존) ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \ | tail -n +6 | xargs rm -rf 2>/dev/null || true exit 0 fi if [ "\$i" -eq "\$ATTEMPTS" ]; then echo "=== FATAL: 서비스가 \$ATTEMPTS회 시도 후에도 응답하지 않음 ===" >&2 echo "--- 5001 listener ---" >&2 ss -tlnp 2>/dev/null | grep ':5001 ' >&2 || true echo "--- active port file ---" >&2 cat "\$DEPLOY_HOME/taxbaik_port" >&2 || true echo "--- 신규 앱 로그 ---" >&2 ACTIVE_PORT=\$(cat "\$DEPLOY_HOME/taxbaik_port" 2>/dev/null | tr -d '[:space:]' || true) if [ -n "\$ACTIVE_PORT" ] && [ -s "\$DEPLOY_DIR/web_\${ACTIVE_PORT}.log" ]; then tail -n 80 "\$DEPLOY_DIR/web_\${ACTIVE_PORT}.log" >&2 else ls -la "\$DEPLOY_DIR" >&2 || true fi echo "--- proxy 로그 ---" >&2 tail -n 80 "\$DEPLOY_HOME/taxbaik_proxy.log" >&2 || true exit 1 fi echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)" sleep 3 done REMOTE echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST" echo "--- 실제 공개 도메인 종단 간 검증 (Nginx/Cloudflare 경유, 최대 3회 재시도) ---" ROOT_URL="https://www.taxbaik.com/" \ ADMIN_URL="https://www.taxbaik.com/taxbaik/admin/login" \ PUBLIC_MARKER="백원숙 세무회계" \ PUBLIC_FORBIDDEN="관리자" \ ADMIN_MARKER="관리자 로그인" \ MAX_RETRIES=3 \ RETRY_SLEEP_SECONDS=5 \ bash ./scripts/taxbaik-smoke.sh echo "✓ 실제 공개 도메인 전체 정상" send_telegram "✅ TaxBaik 배포 완료 커밋: ${COMMIT} 시간: ${TIMESTAMP} 대상: ${DEPLOY_HOST} 채널: ${TELEGRAM_CHAT_ID}"