Files
QuantEngineByItz/.gitea/workflows/deploy-prod.yml
T
kjh2064 6e9a9aa41b docs+fix: Harness the 2026-07-12 DB password incident into pipeline
- CLAUDE.md: Add "DB Secret Management" section documenting the
  incident, the root cause (stale password baked into
  appsettings.Production.json, real password only ever lived in
  /home/kjh2064/.config/quantengine.env, never wired into systemd),
  and the permanent fix (EnvironmentFile= drop-in, applied by hand
  on 2026-07-12 with 'sudo systemctl restart quantengine' verified
  active and journalctl clean).
- CLAUDE.md: Refresh the stale "Gitea Actions Workflows" section
  (was still describing an on:push deploy-prod.yml with a single
  Build stage; now lists prepare-release.yml + deploy-prod.yml
  correctly as workflow_dispatch-only, 6-point health check).
- deploy-prod.yml: Add Check 6 (DB authentication) to the health
  check step. The existing checks only hit GET /Account/Login, which
  returns HTTP 200 even when ConnectionStrings is broken -- that's
  exactly why tonight's outage passed every prior health check. The
  new check greps journalctl for '28P01'/'password authentication
  failed' in the minute after restart and fails the deployment if
  found, so a broken DB connection string can no longer masquerade
  as a successful deploy.
2026-07-12 00:43:31 +09:00

387 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:
fetch-release:
name: Fetch Release Artifact
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
release-tag: ${{ steps.fetch.outputs.tag }}
artifact-name: ${{ steps.fetch.outputs.artifact }}
artifact-size: ${{ steps.fetch.outputs.size }}
commit-hash: ${{ steps.fetch.outputs.commit }}
steps:
- 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
# Fetch latest release
RELEASE_URL="https://gitea.taxbaik.com/api/v1/repos/$REPO/releases/latest"
else
# Fetch specific release
RELEASE_URL="https://gitea.taxbaik.com/api/v1/repos/$REPO/releases/tags/$RELEASE_INPUT"
fi
RELEASE=$(curl -s -H "Authorization: token $TOKEN" "$RELEASE_URL")
TAG=$(echo "$RELEASE" | jq -r '.tag_name')
COMMIT=$(echo "$RELEASE" | jq -r '.target_commitish' | cut -c1-7)
if [ "$TAG" = "null" ] || [ -z "$TAG" ]; then
echo "ERROR: Release not found"
exit 1
fi
# Find artifact in assets
ARTIFACT=$(echo "$RELEASE" | jq -r '.assets[0].name')
SIZE=$(echo "$RELEASE" | jq -r '.assets[0].size')
if [ "$ARTIFACT" = "null" ] || [ -z "$ARTIFACT" ]; then
echo "ERROR: No artifacts found in release $TAG"
exit 1
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "size=${SIZE}" >> $GITHUB_OUTPUT
echo "commit=${COMMIT}" >> $GITHUB_OUTPUT
echo "✓ Release: $TAG"
echo "✓ Artifact: $ARTIFACT"
echo "✓ Size: $SIZE bytes"
- name: Download Release Artifact
run: |
TAG="${{ steps.fetch.outputs.tag }}"
ARTIFACT="${{ steps.fetch.outputs.artifact }}"
TOKEN="${{ secrets.GITEA_TOKEN }}"
REPO="${{ env.REPO }}"
DOWNLOAD_URL="https://gitea.taxbaik.com/api/v1/repos/$REPO/releases/download/$TAG/$ARTIFACT"
echo "Downloading: $DOWNLOAD_URL"
curl -L -H "Authorization: token $TOKEN" \
-o "$ARTIFACT" \
"$DOWNLOAD_URL"
if [ ! -f "$ARTIFACT" ]; then
echo "ERROR: Failed to download artifact"
exit 1
fi
echo "✓ Downloaded: $(du -sh $ARTIFACT)"
- name: Upload to Actions
uses: actions/upload-artifact@v4
with:
name: release-artifact
path: quantengine_*.tar.gz
retention-days: 1
pre-deploy-check:
name: Pre-Deployment Verification
runs-on: ubuntu-latest
needs: fetch-release
timeout-minutes: 5
steps:
- name: Verify SSH Key
run: |
SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}"
SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}"
if [ -z "$SSH_KEY_B64" ] && [ -z "$SSH_KEY_RAW" ]; then
echo "ERROR: DEPLOY_SSH_KEY_B64 or DEPLOY_SSH_KEY not configured"
exit 1
fi
echo "✓ SSH key configured"
- name: Verify Secrets
run: |
[ -z "${{ secrets.DEPLOY_HOST }}" ] && { echo "ERROR: DEPLOY_HOST not configured"; exit 1; }
[ -z "${{ secrets.DEPLOY_USER }}" ] && { echo "ERROR: DEPLOY_USER not configured"; exit 1; }
echo "✓ All secrets configured"
- name: Verify Release Artifact
run: |
if [ "${{ needs.fetch-release.outputs.artifact-name }}" = "" ]; then
echo "ERROR: Release artifact not found"
exit 1
fi
echo "✓ Release: ${{ needs.fetch-release.outputs.release-tag }}"
echo "✓ Artifact: ${{ needs.fetch-release.outputs.artifact-name }}"
echo "✓ Commit: ${{ needs.fetch-release.outputs.commit-hash }}"
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [ fetch-release, pre-deploy-check ]
timeout-minutes: 30
steps:
- name: Download Release Artifact
uses: actions/download-artifact@v4
with:
name: release-artifact
- 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/deploy_key
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/deploy_key
else
printf '%s' "$SSH_KEY_RAW" | base64 -d > ~/.ssh/deploy_key
fi
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="${{ needs.fetch-release.outputs.artifact-name }}"
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="${{ needs.fetch-release.outputs.artifact-name }}"
RELEASE_TAG="${{ needs.fetch-release.outputs.release-tag }}"
COMMIT="${{ needs.fetch-release.outputs.commit-hash }}"
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: [ fetch-release, deploy ]
timeout-minutes: 10
steps:
- name: Setup SSH (for service check)
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/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.fetch-release.outputs.release-tag }} (commit: ${{ needs.fetch-release.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: [ fetch-release, deploy, post-deploy-check ]
steps:
- name: Report Status
run: |
RELEASE="${{ needs.fetch-release.outputs.release-tag }}"
COMMIT="${{ needs.fetch-release.outputs.commit-hash }}"
ARTIFACT="${{ needs.fetch-release.outputs.artifact-name }}"
FETCH_STATUS="${{ needs.fetch-release.result }}"
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 "Fetch: $([ "$FETCH_STATUS" = "success" ] && echo "✓" || echo "✗") $FETCH_STATUS"
echo "Deploy: $([ "$DEPLOY_STATUS" = "success" ] && echo "✓" || echo "✗") $DEPLOY_STATUS"
echo "Health: $([ "$CHECK_STATUS" = "success" ] && echo "✓" || echo "✗") $CHECK_STATUS"
echo ""
if [ "$FETCH_STATUS" = "success" ] && [ "$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