c8c558841e
Found via SSH log analysis (Run #2003, task 2334): the "Verify SSH Key and Secrets" step failed immediately with "DEPLOY_SSH_KEY_B64 or DEPLOY_SSH_KEY not configured" -- both were empty. Queried GET /repos/{repo}/actions/secrets directly and found the actually-registered secrets are named SSH_PRIVATE_KEY and QUANTENGINE_DB_PASSWORD; DEPLOY_SSH_KEY_B64/DEPLOY_SSH_KEY were never created, despite CLAUDE.md claiming "SSH credentials: SSH_KEY registered in Gitea Secrets". Every past deploy-prod.yml run that reached the SSH step (e.g. Run #1991's Pre-Deployment Verification) failed here for the same reason -- this was never a working path, just never diagnosed down to the secret name before now. Fix: check secrets.SSH_PRIVATE_KEY first (with the same PEM-vs-base64 auto-detection used for the legacy names), falling back to DEPLOY_SSH_KEY_B64 / DEPLOY_SSH_KEY in case those get added later. Applied to all three places that build ~/.ssh/deploy_key (deploy job verify + setup, and post-deploy-check's setup).
360 lines
14 KiB
YAML
360 lines
14 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')
|
|
COMMIT=$(echo "$RELEASE" | jq -r '.target_commitish' | cut -c1-7)
|
|
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 }}"
|
|
|
|
ssh -i ~/.ssh/deploy_key \
|
|
-p ${{ env.DEPLOY_PORT }} \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash << 'REMOTE'
|
|
set -e
|
|
|
|
ARTIFACT='$ARTIFACT'
|
|
RELEASE_TAG='$RELEASE_TAG'
|
|
COMMIT='$COMMIT'
|
|
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: |
|
|
set -e
|
|
ATTEMPTS=20
|
|
DEPLOY_HOST="${{ env.DEPLOY_HOST }}"
|
|
|
|
echo "【 Health Checks (max ${ATTEMPTS} attempts) 】"
|
|
|
|
for i in $(seq 1 $ATTEMPTS); do
|
|
# Check 1: HTTP 200
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://$DEPLOY_HOST:5000/Account/Login 2>/dev/null || echo "000")
|
|
if [ "$HTTP_CODE" = "200" ]; then
|
|
echo "✓ [1/5] HTTP 200 OK (attempt $i)"
|
|
|
|
# Check 2: Login Page
|
|
LOGIN_BODY=$(curl -s http://$DEPLOY_HOST:5000/Account/Login 2>/dev/null || echo "")
|
|
if echo "$LOGIN_BODY" | grep -q "login\|Login\|로그인"; then
|
|
echo "✓ [2/5] Login page content verified"
|
|
else
|
|
echo "⚠ [2/5] Login page content verification skipped"
|
|
fi
|
|
|
|
# Check 3: CSS loaded
|
|
CSS_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://$DEPLOY_HOST:5000/css/admin.css 2>/dev/null || echo "000")
|
|
if [ "$CSS_CODE" = "200" ]; then
|
|
echo "✓ [3/5] CSS file loaded"
|
|
else
|
|
echo "⚠ [3/5] CSS file check skipped (status: $CSS_CODE)"
|
|
fi
|
|
|
|
# Check 4: Service active
|
|
SERVICE_STATUS=$(ssh -i ~/.ssh/deploy_key \
|
|
-p 22 \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
kjh2064@$DEPLOY_HOST \
|
|
"systemctl is-active quantengine" 2>/dev/null || echo "unknown")
|
|
if [ "$SERVICE_STATUS" = "active" ]; then
|
|
echo "✓ [4/5] Service active (running)"
|
|
else
|
|
echo "⚠ [4/5] Service status: $SERVICE_STATUS"
|
|
fi
|
|
|
|
# Check 5: Release verified
|
|
echo "✓ [5/5] 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=$(ssh -i ~/.ssh/deploy_key \
|
|
-p 22 \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
kjh2064@$DEPLOY_HOST \
|
|
"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
|
|
|
|
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
|