Claude/cpu guard script dn d8u (#130) #332
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy | ||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| types: [closed] | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: 'Dry run — skip VPS changes, just run quality gates and notify' | ||
| required: false | ||
| default: 'false' | ||
| skip_tests: | ||
| description: 'Skip unit tests (emergency deploy only)' | ||
| required: false | ||
| default: 'false' | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| # ADMIN_IDS stored as a GitHub secret to avoid exposing Telegram user IDs in plaintext | ||
| ADMIN_IDS: ${{ secrets.ADMIN_IDS }} | ||
| AFFILIATE_SOURCE: GambleCodez | ||
| DISCORD_CODE_GENERATION_IMAGE_URL: "https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_code_generation.png" | ||
| DISCORD_VERIFY_IMAGE_URL: "https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_verify.png" | ||
| MAX_ANALYTICS_EVENTS: "2000" | ||
| MAX_ONBOARDING_STEPS_HISTORY: "500" | ||
| MINI_APP_CLAIM_URL: "https://t.me/RuneWager_bot/claim" | ||
| MINI_APP_PLAY_URL: "https://t.me/RuneWager_bot/Play" | ||
| MINI_APP_PROFILE_URL: "https://t.me/RuneWager_bot/profile" | ||
| PORT: "3000" | ||
| PROMO_AMOUNT_SC: "3.5" | ||
| PROMO_BONUS_RULE: "Bonus added once your account is verified and meets eligibility requirements. Limit once per account/IP. New users are defined as accounts that have never redeemed a promo code before." | ||
| PROMO_CLAIM_LIMIT: "600" | ||
| PROMO_CODE: SPORTS3.5 | ||
| PROMO_ENTRY_IMAGE_URL: "https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/promo_entry.png" | ||
| RW_DISCORD_JOIN: "https://discord.gg/runewagers" | ||
| RW_DISCORD_LINK: "https://discord.com/channels/1100486422395355197/1249181934811349052" | ||
| RW_DISCORD_SUPPORT: "https://discord.com/channels/1100486422395355197/1249182067296567338" | ||
| DEPLOY_MODE: normal | ||
| jobs: | ||
| # --------------------------------------------------------------------------- | ||
| # Job 1: Quality Gates — syntax, tests, audit, metadata, secrets, guards | ||
| # --------------------------------------------------------------------------- | ||
| quality-gates: | ||
| name: Quality Gates | ||
| runs-on: ubuntu-latest | ||
| # Run on: direct push to main, merged PR to main, or manual workflow_dispatch. | ||
| # Closed-without-merge PRs are excluded by the merged == true check. | ||
| if: > | ||
| github.event_name == 'push' || | ||
| github.event_name == 'workflow_dispatch' || | ||
| (github.event_name == 'pull_request' && | ||
| github.event.pull_request.merged == true) | ||
| outputs: | ||
| commit_hash: ${{ steps.meta.outputs.commit_hash }} | ||
| deploy_time: ${{ steps.meta.outputs.deploy_time }} | ||
| version: ${{ steps.meta.outputs.version }} | ||
| started_at: ${{ steps.start.outputs.started_at }} | ||
| duration_seconds: ${{ steps.duration.outputs.duration_seconds }} | ||
| steps: | ||
| - name: Record start time | ||
| id: start | ||
| run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" | ||
| - name: Guard — verify valid merge to main | ||
| if: github.event_name == 'pull_request' | ||
| run: | | ||
| echo "PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" | ||
| echo "Merged into: ${{ github.event.pull_request.base.ref }}" | ||
| echo "Merge commit: ${{ github.event.pull_request.merge_commit_sha }}" | ||
| echo "✅ Valid merge to main confirmed — proceeding with quality gates" | ||
| - uses: actions/checkout@v4 | ||
| - name: Guard — skip if commit says so | ||
| run: | | ||
| MSG=$(git log -1 --format="%s %b") | ||
| if echo "$MSG" | grep -qi '\[skip deploy\]'; then | ||
| echo "Commit message contains [skip deploy] — aborting." | ||
| exit 1 | ||
| fi | ||
| echo "✅ No [skip deploy] flag found" | ||
| - name: Verify required secrets exist | ||
| env: | ||
| _BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| _SERVER_KEY: ${{ secrets.SERVER_KEY }} | ||
| _SERVER_HOST: ${{ secrets.SERVER_HOST }} | ||
| _SERVER_USER: ${{ secrets.SERVER_USER }} | ||
| run: | | ||
| MISSING=0 | ||
| [ -z "$_BOT_TOKEN" ] && echo "❌ Missing secret: BOT_TOKEN" && MISSING=1 | ||
| [ -z "$_SERVER_KEY" ] && echo "❌ Missing secret: SERVER_KEY" && MISSING=1 | ||
| [ -z "$_SERVER_HOST" ]&& echo "❌ Missing secret: SERVER_HOST"&& MISSING=1 | ||
| [ -z "$_SERVER_USER" ]&& echo "❌ Missing secret: SERVER_USER"&& MISSING=1 | ||
| [ "$MISSING" -eq 1 ] && exit 1 | ||
| echo "✅ All required secrets present" | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '20' | ||
| cache: 'npm' | ||
| - name: Verify Node version is 20.x | ||
| run: | | ||
| echo "Node: $(node -v)" | ||
| echo "npm: $(npm -v)" | ||
| NODE_MAJOR=$(node -v | sed 's/v\([0-9]*\).*/\1/') | ||
| if [ "$NODE_MAJOR" -lt 20 ]; then | ||
| echo "ERROR: Node 20+ required, found $(node -v)" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "✅ Node $(node -v) OK" | ||
| - name: Install deps | ||
| run: npm ci | ||
| - name: Build metadata | ||
| id: meta | ||
| run: | | ||
| echo "commit_hash=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" | ||
| echo "deploy_time=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" | ||
| echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" | ||
| - name: Syntax check | ||
| run: npm run check | ||
| - name: Tests | ||
| if: github.event.inputs.skip_tests != 'true' | ||
| run: npm test | ||
| - name: Security audit (non-blocking) | ||
| run: npm audit --audit-level=high || true | ||
| - name: Compute duration | ||
| id: duration | ||
| run: | | ||
| STARTED="${{ steps.start.outputs.started_at }}" | ||
| NOW=$(date +%s) | ||
| echo "duration_seconds=$((NOW - STARTED))" >> "$GITHUB_OUTPUT" | ||
| - name: Notify deploy start | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "🚀 *Deploy Starting* | ||
| Repo: \`${{ github.repository }}\` | ||
| Branch: \`${{ github.ref_name }}\` | ||
| Commit: \`${{ steps.meta.outputs.commit_hash }}\` | ||
| Version: \`${{ steps.meta.outputs.version }}\` | ||
| Mode: \`${DEPLOY_MODE}\` | ||
| By: \`${{ github.actor }}\`" | ||
| # --------------------------------------------------------------------------- | ||
| # Job 2: Deploy to VPS — pull latest main, restart service, health check | ||
| # Only triggers after quality gates pass on a valid PR merge to main. | ||
| # --------------------------------------------------------------------------- | ||
| deploy: | ||
| name: Deploy to VPS | ||
| runs-on: ubuntu-latest | ||
| needs: quality-gates | ||
| environment: Production | ||
| if: needs.quality-gates.result == 'success' | ||
| env: | ||
| DEPLOY_PASS: ${{ secrets.DEPLOY_PASS }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - name: Install sshpass (password fallback) | ||
| run: sudo apt-get install -y sshpass | ||
| - name: Setup SSH | ||
| env: | ||
| SERVER_KEY: ${{ secrets.SERVER_KEY }} | ||
| SERVER_HOST: ${{ secrets.SERVER_HOST }} | ||
| run: | | ||
| mkdir -p ~/.ssh | ||
| chmod 700 ~/.ssh | ||
| printf '%s\n' "$SERVER_KEY" > ~/.ssh/deploy_key | ||
| chmod 600 ~/.ssh/deploy_key | ||
| ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts | ||
| cat >> ~/.ssh/config <<EOF | ||
| Host deploy_target | ||
| HostName $SERVER_HOST | ||
| User ${{ secrets.SERVER_USER }} | ||
| IdentityFile ~/.ssh/deploy_key | ||
| StrictHostKeyChecking yes | ||
| ConnectTimeout 15 | ||
| EOF | ||
| - name: Notify — deploy job started | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "🚀 *Deploy Job Started* | ||
| Repo: \`${{ github.repository }}\` | ||
| Branch: \`${{ github.ref_name }}\` | ||
| Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\` | ||
| Version: \`${{ needs.quality-gates.outputs.version }}\` | ||
| Actor: \`${{ github.actor }}\`" | ||
| - name: Save pre-deploy SHA | ||
| id: pre | ||
| run: | | ||
| SHA=$(ssh deploy_target "cd /var/www/html/Runewager && git rev-parse HEAD || echo none") | ||
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | ||
| - name: Pre-deploy health snapshot | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| ssh deploy_target " | ||
| set -e | ||
| cd /var/www/html/Runewager | ||
| echo 'Checking .env exists...' | ||
| if [ ! -f .env ]; then | ||
| echo 'ERROR: .env missing on VPS' >&2 | ||
| exit 1 | ||
| fi | ||
| echo 'Checking port 3000 usage...' | ||
| lsof -i :3000 || true | ||
| echo 'Checking memory/disk...' | ||
| free -m || true | ||
| df -h . || true | ||
| " | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "📋 *Pre-Deploy Health Snapshot* | ||
| Path: \`/var/www/html/Runewager\` | ||
| Commit (pre): \`${{ steps.pre.outputs.sha }}\`" | ||
| - name: Deploy via deploy.sh (key first, then password fallback) | ||
| env: | ||
| SERVER_HOST: ${{ secrets.SERVER_HOST }} | ||
| SERVER_USER: ${{ secrets.SERVER_USER }} | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| chmod +x scripts/notify-telegram.sh | ||
| DEPLOY_CMD=' | ||
| set -e | ||
| cd /var/www/html/Runewager | ||
| echo "Pulling latest main..." | ||
| git fetch origin main | ||
| git reset --hard origin/main | ||
| echo "Ensuring .env exists and is preserved..." | ||
| if [ ! -f .env ]; then | ||
| echo "ERROR: .env missing on VPS" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Stopping systemd service (if running)..." | ||
| sudo systemctl stop runewager.service || true | ||
| echo "Killing any stray bot processes (scoped to this project only)..." | ||
| pkill -f "Runewager/index\.js" || true | ||
| echo "Installing production dependencies..." | ||
| npm ci --omit=dev | ||
| echo "Starting systemd service cleanly..." | ||
| sudo systemctl daemon-reload || true | ||
| sudo systemctl start runewager.service | ||
| echo "Checking systemd status..." | ||
| sudo systemctl is-active --quiet runewager.service | ||
| ' | ||
| ./scripts/notify-telegram.sh "🔑 *Deploy Phase — SSH Key Attempt* | ||
| Host: \`${SERVER_HOST}\`" | ||
| if ssh deploy_target "$DEPLOY_CMD"; then | ||
| echo "✅ Deployed via SSH key" | ||
| ./scripts/notify-telegram.sh "✅ *Deploy Succeeded via SSH Key* | ||
| Host: \`${SERVER_HOST}\`" | ||
| else | ||
| echo "⚠️ SSH key failed — waiting 120 seconds before password retry..." | ||
| ./scripts/notify-telegram.sh "⚠️ *SSH Key Failed — Waiting 2 Minutes Before Password Fallback* | ||
| Host: \`${SERVER_HOST}\`" | ||
| sleep 120 | ||
| if sshpass -p "$DEPLOY_PASS" ssh \ | ||
| -o StrictHostKeyChecking=yes \ | ||
| -o ConnectTimeout=15 \ | ||
| -o UserKnownHostsFile=~/.ssh/known_hosts \ | ||
| "${SERVER_USER}@${SERVER_HOST}" "$DEPLOY_CMD"; then | ||
| echo "✅ Deployed via password fallback" | ||
| ./scripts/notify-telegram.sh "✅ *Deploy Succeeded via Password Fallback* | ||
| Host: \`${SERVER_HOST}\`" | ||
| else | ||
| echo "❌ Both SSH attempts failed" | ||
| ./scripts/notify-telegram.sh "❌ *Deploy Failed — SSH Key + Password Fallback Both Failed* | ||
| Host: \`${SERVER_HOST}\` | ||
| Action required: manual SSH + service check." | ||
| exit 1 | ||
| fi | ||
| fi | ||
| - name: Download deploy report from VPS | ||
| if: always() | ||
| run: | | ||
| ssh deploy_target "cat /tmp/deploy-report.txt 2>/dev/null || echo 'No deploy report file found.'" \ | ||
| > /tmp/deploy-report.txt || echo 'No deploy report available.' > /tmp/deploy-report.txt | ||
| - name: Upload deploy report as artifact | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: deploy-summary | ||
| path: /tmp/deploy-report.txt | ||
| if-no-files-found: ignore | ||
| retention-days: 7 | ||
| - name: Post-deploy health check (30s delay) | ||
| if: success() | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "⏳ *Post-Deploy Health Check in 30s*" | ||
| sleep 30 | ||
| HEALTH_STATUS="FAIL" | ||
| if ssh deploy_target "curl -fsS http://127.0.0.1:3000/health" >/dev/null 2>&1; then | ||
| HEALTH_STATUS="OK" | ||
| fi | ||
| ./scripts/notify-telegram.sh "📡 *Post-Deploy Health Check* | ||
| Status: \`${HEALTH_STATUS}\` | ||
| Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`" | ||
| - name: Rollback on failure | ||
| if: failure() | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| PRE="${{ steps.pre.outputs.sha }}" | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "❌ *Deploy Failed — Starting Rollback* | ||
| Previous SHA: \`${PRE}\`" | ||
| ssh deploy_target " | ||
| cd /var/www/html/Runewager | ||
| if [ \"$PRE\" != \"none\" ] && [ -n \"$PRE\" ]; then | ||
| git reset --hard \"$PRE\" | ||
| npm ci --omit=dev || true | ||
| sudo systemctl stop runewager.service || true | ||
| pkill -f \"Runewager/index\.js\" || true | ||
| sudo systemctl daemon-reload || true | ||
| sudo systemctl start runewager.service || true | ||
| else | ||
| echo 'No previous SHA recorded, skipping git rollback' | ||
| fi | ||
| " | ||
| ./scripts/notify-telegram.sh "🔄 *Rollback Complete* | ||
| Restored to: \`${PRE}\`" | ||
| - name: Final deploy report | ||
| if: always() | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| STATUS="${{ job.status }}" | ||
| COMMIT="${{ needs.quality-gates.outputs.commit_hash }}" | ||
| VERSION="${{ needs.quality-gates.outputs.version }}" | ||
| TIME="${{ needs.quality-gates.outputs.deploy_time }}" | ||
| REPORT_BODY=$(cat /tmp/deploy-report.txt 2>/dev/null || echo "No deploy report available.") | ||
| if [ "$STATUS" = "success" ]; then | ||
| MSG="🎉 *Final Deploy Report* | ||
| Status: ✅ Success | ||
| Commit: \`$COMMIT\` | ||
| Version: \`$VERSION\` | ||
| Time: \`$TIME\` | ||
| $REPORT_BODY" | ||
| else | ||
| MSG="⚠️ *Final Deploy Report* | ||
| Status: ❌ Failure | ||
| Commit: \`$COMMIT\` | ||
| Version: \`$VERSION\` | ||
| Time: \`$TIME\` | ||
| $REPORT_BODY" | ||
| fi | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "$MSG" | ||
| # --------------------------------------------------------------------------- | ||
| # Job 3: Dry-run report (only when dry_run=true) | ||
| # --------------------------------------------------------------------------- | ||
| dry-run-report: | ||
| name: Dry Run Report | ||
| runs-on: ubuntu-latest | ||
| needs: quality-gates | ||
| if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - name: Send dry-run notification | ||
| env: | ||
| BOT_TOKEN: ${{ secrets.BOT_TOKEN }} | ||
| run: | | ||
| chmod +x scripts/notify-telegram.sh | ||
| ./scripts/notify-telegram.sh "🧪 *Dry Run Only — No VPS Changes* | ||
| Repo: \`${{ github.repository }}\` | ||
| Branch: \`${{ github.ref_name }}\` | ||
| Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\` | ||
| Version: \`${{ needs.quality-gates.outputs.version }}\` | ||
| Time: \`${{ needs.quality-gates.outputs.deploy_time }}\` | ||
| By: \`${{ github.actor }}\` | ||
| Quality gates passed. VPS was NOT modified." | ||