3c3f2d56c8
Root cause confirmed by direct test: curl --connect-timeout 5 http://178.104.200.7:5000/Account/Login -> 000 quantengine.service sets ASPNETCORE_URLS=http://127.0.0.1:5000 (loopback only, by design -- Nginx is the only public entry point, proxying quant.taxbaik.com to it). The Gitea Actions runner is not the production host, so its direct curl to $DEPLOY_HOST:5000 was always going to hit a closed port. Run #2005 is direct proof: "Deploy to Production" succeeded, the site was reachable over HTTPS the whole time, and journalctl was clean -- yet "Health Check & Verification" burned through all 20 retries (60s) because it was polling the wrong address entirely. This check has likely never once passed on this service's actual network layout. Fix: wrap the HTTP-200 / login-content / CSS retry loop in a single SSH session that runs curl against 127.0.0.1:5000 on the production server itself -- consistent with how the service-status and DB-error checks already correctly run remotely. Removed the redundant per-attempt SSH round trips for service status (now a plain local command inside the same remote script) and dropped the separate "Setup SSH (for service check)" step's curl usage entirely.
378 lines
15 KiB
YAML
378 lines
15 KiB
YAML
name: Deploy to Production
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
release:
|
|
description: 'Release version to deploy (e.g., v0.1.20260711, or leave empty for latest)'
|
|
required: false
|
|
type: string
|
|
|
|
concurrency:
|
|
group: deploy-prod-main
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
DEPLOY_HOST: 178.104.200.7
|
|
DEPLOY_USER: kjh2064
|
|
DEPLOY_PORT: 22
|
|
SERVICE_NAME: quantengine
|
|
REPO: kjh2064/QuantEngineByItz
|
|
|
|
jobs:
|
|
deploy:
|
|
name: Deploy to Production
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
outputs:
|
|
release-tag: ${{ steps.fetch.outputs.tag }}
|
|
artifact-name: ${{ steps.fetch.outputs.artifact }}
|
|
commit-hash: ${{ steps.fetch.outputs.commit }}
|
|
|
|
steps:
|
|
- name: Verify SSH Key and Secrets
|
|
run: |
|
|
# SSH_PRIVATE_KEY is the actual secret name registered in this repo
|
|
# (verified via GET /repos/{r}/actions/secrets -- DEPLOY_SSH_KEY_B64 /
|
|
# DEPLOY_SSH_KEY were never actually created despite CLAUDE.md
|
|
# claiming so; kept as fallback names in case they're added later).
|
|
SSH_KEY="${{ secrets.SSH_PRIVATE_KEY }}"
|
|
SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}"
|
|
SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}"
|
|
if [ -z "$SSH_KEY" ] && [ -z "$SSH_KEY_B64" ] && [ -z "$SSH_KEY_RAW" ]; then
|
|
echo "ERROR: No SSH key secret configured (checked SSH_PRIVATE_KEY, DEPLOY_SSH_KEY_B64, DEPLOY_SSH_KEY)"
|
|
exit 1
|
|
fi
|
|
[ -z "${{ secrets.GITEA_TOKEN }}" ] && { echo "ERROR: GITEA_TOKEN not configured"; exit 1; }
|
|
echo "✓ SSH key and GITEA_TOKEN configured"
|
|
|
|
- name: Fetch Release Info
|
|
id: fetch
|
|
run: |
|
|
RELEASE_INPUT="${{ github.event.inputs.release }}"
|
|
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
|
REPO="${{ env.REPO }}"
|
|
|
|
if [ -z "$RELEASE_INPUT" ]; then
|
|
RELEASE_URL="https://gitea.taxbaik.com/api/v1/repos/$REPO/releases/latest"
|
|
else
|
|
RELEASE_URL="https://gitea.taxbaik.com/api/v1/repos/$REPO/releases/tags/$RELEASE_INPUT"
|
|
fi
|
|
|
|
RELEASE=$(curl -sf -H "Authorization: token $TOKEN" "$RELEASE_URL")
|
|
TAG=$(echo "$RELEASE" | jq -r '.tag_name')
|
|
# NOTE: '.target_commitish' is the branch name the tag was cut from
|
|
# (e.g. "main"), NOT a commit SHA -- do not use it as a commit hash.
|
|
# Our tags are always "quant_YYYYMMDD.count.hash" (see
|
|
# prepare-release.yml), so pull the hash back out of the tag name.
|
|
COMMIT="${TAG##*.}"
|
|
ARTIFACT=$(echo "$RELEASE" | jq -r '.assets[0].name')
|
|
DOWNLOAD_URL=$(echo "$RELEASE" | jq -r '.assets[0].browser_download_url')
|
|
|
|
if [ "$TAG" = "null" ] || [ -z "$TAG" ]; then
|
|
echo "ERROR: Release not found"; exit 1
|
|
fi
|
|
if [ "$ARTIFACT" = "null" ] || [ -z "$ARTIFACT" ]; then
|
|
echo "ERROR: No artifacts found in release $TAG"; exit 1
|
|
fi
|
|
if [ "$DOWNLOAD_URL" = "null" ] || [ -z "$DOWNLOAD_URL" ]; then
|
|
echo "ERROR: No browser_download_url found for asset"; exit 1
|
|
fi
|
|
|
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
echo "download_url=${DOWNLOAD_URL}" >> $GITHUB_OUTPUT
|
|
echo "commit=${COMMIT}" >> $GITHUB_OUTPUT
|
|
|
|
echo "✓ Release: $TAG"
|
|
echo "✓ Artifact: $ARTIFACT"
|
|
echo "✓ Download URL: $DOWNLOAD_URL"
|
|
|
|
- name: Download Release Artifact
|
|
run: |
|
|
ARTIFACT="${{ steps.fetch.outputs.artifact }}"
|
|
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
|
DOWNLOAD_URL="${{ steps.fetch.outputs.download_url }}"
|
|
|
|
echo "Downloading: $DOWNLOAD_URL"
|
|
curl -sfL -H "Authorization: token $TOKEN" -o "$ARTIFACT" "$DOWNLOAD_URL"
|
|
|
|
# A 404/error page would still create a small file -- verify it's a
|
|
# real gzip archive, not an HTML/JSON error body (this is exactly
|
|
# how the old /releases/download/{tag}/{file} guessed URL failed
|
|
# silently: curl exited 0 but wrote a 19-byte "404 page not found").
|
|
file "$ARTIFACT" | grep -q "gzip compressed" || {
|
|
echo "ERROR: Downloaded file is not a valid gzip archive:"
|
|
file "$ARTIFACT"
|
|
cat "$ARTIFACT"
|
|
exit 1
|
|
}
|
|
|
|
echo "✓ Downloaded: $(du -sh $ARTIFACT)"
|
|
|
|
- name: Setup SSH
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
SSH_KEY="${{ secrets.SSH_PRIVATE_KEY }}"
|
|
SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}"
|
|
SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}"
|
|
|
|
write_key() {
|
|
# $1 = raw secret value; auto-detects PEM vs base64
|
|
if printf '%s' "$1" | grep -q 'BEGIN.*PRIVATE KEY'; then
|
|
printf '%b\n' "$1" > ~/.ssh/deploy_key
|
|
else
|
|
printf '%s' "$1" | base64 -d > ~/.ssh/deploy_key
|
|
fi
|
|
}
|
|
|
|
if [ -n "$SSH_KEY" ]; then
|
|
write_key "$SSH_KEY"
|
|
elif [ -n "$SSH_KEY_B64" ]; then
|
|
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/deploy_key
|
|
elif [ -n "$SSH_KEY_RAW" ]; then
|
|
write_key "$SSH_KEY_RAW"
|
|
else
|
|
echo "ERROR: No SSH key configured"
|
|
exit 1
|
|
fi
|
|
|
|
sed -i 's/\r$//' ~/.ssh/deploy_key
|
|
chmod 600 ~/.ssh/deploy_key
|
|
ssh-keyscan -p ${{ env.DEPLOY_PORT }} ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
echo "✓ SSH configured"
|
|
|
|
- name: Upload Release Artifact
|
|
run: |
|
|
ARTIFACT="${{ steps.fetch.outputs.artifact }}"
|
|
echo "Uploading: $ARTIFACT"
|
|
ls -lh "$ARTIFACT"
|
|
|
|
scp -i ~/.ssh/deploy_key \
|
|
-P ${{ env.DEPLOY_PORT }} \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
"$ARTIFACT" ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:/tmp/
|
|
echo "✓ Release artifact uploaded"
|
|
|
|
- name: Deploy & Verify
|
|
run: |
|
|
ARTIFACT="${{ steps.fetch.outputs.artifact }}"
|
|
RELEASE_TAG="${{ steps.fetch.outputs.tag }}"
|
|
COMMIT="${{ steps.fetch.outputs.commit }}"
|
|
SERVICE_NAME="${{ env.SERVICE_NAME }}"
|
|
|
|
# IMPORTANT: the heredoc below uses a QUOTED delimiter ('REMOTE'),
|
|
# so none of $ARTIFACT/$RELEASE_TAG/etc inside it are expanded by
|
|
# this (local runner) shell -- they must arrive as real
|
|
# environment variables on the remote bash process instead. The
|
|
# previous version of this script had the same quoted heredoc but
|
|
# relied on local expansion anyway, so every deploy printed the
|
|
# literal text "$ARTIFACT" and then failed on
|
|
# "tar: /tmp/$ARTIFACT: No such file or directory". Passing them
|
|
# as a prefix to `bash -s` is what actually gets them into the
|
|
# remote script's environment.
|
|
ssh -i ~/.ssh/deploy_key \
|
|
-p ${{ env.DEPLOY_PORT }} \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
|
|
"ARTIFACT='$ARTIFACT' RELEASE_TAG='$RELEASE_TAG' COMMIT='$COMMIT' SERVICE_NAME='$SERVICE_NAME' bash -s" << 'REMOTE'
|
|
set -e
|
|
|
|
DEPLOY_HOME=$HOME
|
|
DEPLOY_DIR="$DEPLOY_HOME/deployments/quantengine_${RELEASE_TAG}_${COMMIT}"
|
|
|
|
echo "=== Deployment Start ==="
|
|
echo "Release: $RELEASE_TAG"
|
|
echo "Artifact: $ARTIFACT"
|
|
echo "Commit: $COMMIT"
|
|
echo "Deploy Dir: $DEPLOY_DIR"
|
|
echo ""
|
|
|
|
# 1. Extract
|
|
echo "【 1/4 Extract Artifact 】"
|
|
mkdir -p "$DEPLOY_DIR"
|
|
tar -xzf "/tmp/$ARTIFACT" -C "$DEPLOY_DIR"
|
|
rm -f "/tmp/$ARTIFACT"
|
|
echo "✓ Extraction complete"
|
|
|
|
# 2. Verify
|
|
echo ""
|
|
echo "【 2/4 Verify Deployment 】"
|
|
if [ ! -f "$DEPLOY_DIR/QuantEngine.Web.dll" ]; then
|
|
echo "ERROR: QuantEngine.Web.dll not found"
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$DEPLOY_DIR/appsettings.Production.json" ]; then
|
|
echo "ERROR: appsettings.Production.json not found"
|
|
exit 1
|
|
fi
|
|
echo "✓ DLL verified"
|
|
echo "✓ Config verified"
|
|
|
|
# 3. Update Symlink
|
|
echo ""
|
|
echo "【 3/4 Update Symlink 】"
|
|
ln -sfn "$DEPLOY_DIR" "$DEPLOY_HOME/quantengine_active"
|
|
echo "✓ Active: $(readlink $DEPLOY_HOME/quantengine_active)"
|
|
|
|
# 4. Restart Service
|
|
echo ""
|
|
echo "【 4/4 Restart Service 】"
|
|
sudo systemctl restart "$SERVICE_NAME"
|
|
echo "✓ Service restarted"
|
|
|
|
REMOTE
|
|
|
|
post-deploy-check:
|
|
name: Health Check & Verification
|
|
runs-on: ubuntu-latest
|
|
needs: deploy
|
|
timeout-minutes: 10
|
|
|
|
steps:
|
|
- name: Setup SSH (for service check)
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
SSH_KEY="${{ secrets.SSH_PRIVATE_KEY }}"
|
|
SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}"
|
|
SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}"
|
|
|
|
if [ -n "$SSH_KEY" ]; then
|
|
if printf '%s' "$SSH_KEY" | grep -q 'BEGIN.*PRIVATE KEY'; then
|
|
printf '%b\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
|
else
|
|
printf '%s' "$SSH_KEY" | base64 -d > ~/.ssh/deploy_key
|
|
fi
|
|
elif [ -n "$SSH_KEY_B64" ]; then
|
|
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/deploy_key
|
|
elif [ -n "$SSH_KEY_RAW" ]; then
|
|
printf '%s' "$SSH_KEY_RAW" | base64 -d > ~/.ssh/deploy_key
|
|
fi
|
|
|
|
chmod 600 ~/.ssh/deploy_key 2>/dev/null || true
|
|
ssh-keyscan -p 22 ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
|
|
- name: Health Check
|
|
run: |
|
|
# IMPORTANT: quantengine.service binds ASPNETCORE_URLS to
|
|
# http://127.0.0.1:5000 (loopback only) -- Nginx is the only
|
|
# thing that reaches it from outside, via quant.taxbaik.com.
|
|
# The Gitea Actions runner is a separate host/container, so
|
|
# `curl http://$DEPLOY_HOST:5000/...` from here always hits a
|
|
# closed port and times out ("000") -- confirmed directly:
|
|
# curl --connect-timeout 5 http://178.104.200.7:5000/... -> 000
|
|
# Every previous run's Health Check silently burned through all
|
|
# 20 retries on this before failing, even on deployments that
|
|
# actually worked (see Run #2005: Deploy job succeeded, site was
|
|
# reachable over HTTPS and journalctl was clean the whole time).
|
|
# Fix: run the HTTP/CSS checks *on* the server against
|
|
# 127.0.0.1:5000, the same way the service-status and DB-error
|
|
# checks already correctly do via SSH.
|
|
ssh -i ~/.ssh/deploy_key \
|
|
-p ${{ env.DEPLOY_PORT }} \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s << 'REMOTE'
|
|
set -e
|
|
ATTEMPTS=20
|
|
|
|
echo "【 Health Checks (max ${ATTEMPTS} attempts) 】"
|
|
|
|
for i in $(seq 1 $ATTEMPTS); do
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/Account/Login 2>/dev/null || echo "000")
|
|
if [ "$HTTP_CODE" = "200" ]; then
|
|
echo "✓ [1/6] HTTP 200 OK (attempt $i)"
|
|
|
|
LOGIN_BODY=$(curl -s http://127.0.0.1:5000/Account/Login 2>/dev/null || echo "")
|
|
if echo "$LOGIN_BODY" | grep -q "login\|Login\|로그인"; then
|
|
echo "✓ [2/6] Login page content verified"
|
|
else
|
|
echo "⚠ [2/6] Login page content verification skipped"
|
|
fi
|
|
|
|
CSS_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/css/admin.css 2>/dev/null || echo "000")
|
|
if [ "$CSS_CODE" = "200" ]; then
|
|
echo "✓ [3/6] CSS file loaded"
|
|
else
|
|
echo "⚠ [3/6] CSS file check skipped (status: $CSS_CODE)"
|
|
fi
|
|
|
|
SERVICE_STATUS=$(systemctl is-active quantengine 2>/dev/null || echo "unknown")
|
|
if [ "$SERVICE_STATUS" = "active" ]; then
|
|
echo "✓ [4/6] Service active (running)"
|
|
else
|
|
echo "⚠ [4/6] Service status: $SERVICE_STATUS"
|
|
fi
|
|
|
|
echo "✓ [5/6] Deployment release: ${{ needs.deploy.outputs.release-tag }} (commit: ${{ needs.deploy.outputs.commit-hash }})"
|
|
|
|
# Check 6: DB connectivity (GET /Account/Login returns 200 even when
|
|
# the DB password is stale -- the page itself has no DB dependency.
|
|
# Only an actual login POST, or the app logs, reveal a broken
|
|
# connection string. See CLAUDE.md "DB Secret Management" incident
|
|
# 2026-07-12: this check would have caught it, the HTTP check alone
|
|
# did not.)
|
|
sleep 2
|
|
DB_ERRORS=$(journalctl -u quantengine --since '1 minute ago' --no-pager 2>/dev/null | grep -c '28P01\|password authentication failed' || echo "0")
|
|
if [ "$DB_ERRORS" = "0" ]; then
|
|
echo "✓ [6/6] No DB authentication errors in recent logs"
|
|
else
|
|
echo "❌ [6/6] DB authentication errors found in logs ($DB_ERRORS occurrences)"
|
|
echo ""
|
|
echo "❌ FAILED: Deployment reachable over HTTP but DB connection is broken"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "✅ All health checks passed!"
|
|
exit 0
|
|
fi
|
|
|
|
if [ $i -lt $ATTEMPTS ]; then
|
|
echo " Attempt $i/$ATTEMPTS... (HTTP $HTTP_CODE, retrying in 3s)"
|
|
sleep 3
|
|
else
|
|
echo ""
|
|
echo "❌ FAILED: Service did not respond after $ATTEMPTS attempts"
|
|
exit 1
|
|
fi
|
|
done
|
|
REMOTE
|
|
|
|
post-deploy-report:
|
|
name: Deployment Report
|
|
runs-on: ubuntu-latest
|
|
if: always()
|
|
needs: [ deploy, post-deploy-check ]
|
|
|
|
steps:
|
|
- name: Report Status
|
|
run: |
|
|
RELEASE="${{ needs.deploy.outputs.release-tag }}"
|
|
COMMIT="${{ needs.deploy.outputs.commit-hash }}"
|
|
ARTIFACT="${{ needs.deploy.outputs.artifact-name }}"
|
|
DEPLOY_STATUS="${{ needs.deploy.result }}"
|
|
CHECK_STATUS="${{ needs.post-deploy-check.result }}"
|
|
|
|
echo "╔════════════════════════════════════════════╗"
|
|
echo "║ Deployment Report ║"
|
|
echo "╚════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo "Release: $RELEASE"
|
|
echo "Commit: $COMMIT"
|
|
echo "Artifact: $ARTIFACT"
|
|
echo ""
|
|
echo "【 Status 】"
|
|
echo "Deploy: $([ "$DEPLOY_STATUS" = "success" ] && echo "✓" || echo "✗") $DEPLOY_STATUS"
|
|
echo "Health: $([ "$CHECK_STATUS" = "success" ] && echo "✓" || echo "✗") $CHECK_STATUS"
|
|
echo ""
|
|
|
|
if [ "$DEPLOY_STATUS" = "success" ] && [ "$CHECK_STATUS" = "success" ]; then
|
|
echo "✅ Deployment Successful"
|
|
echo "Server: 178.104.200.7"
|
|
echo "Release: $RELEASE"
|
|
exit 0
|
|
else
|
|
echo "❌ Deployment Failed"
|
|
exit 1
|
|
fi
|