mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-09 21:21:03 +00:00
faba2dee5d
Vite aliases @pezkuwi/utils → ../shared/utils, so the Docker build context must include both web/ and shared/. Previous context: ./web missed shared/ which caused 'Could not load /shared/utils/formatting' at module resolution. Changes: - Dockerfile WORKDIR=/build/web; COPY web/* and shared/* explicitly - Workflow context: ./ + file: ./web/Dockerfile - Move .dockerignore from web/ to pwap root (matches new context)
496 lines
17 KiB
YAML
496 lines
17 KiB
YAML
name: Quality Gate & Deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [ main, develop ]
|
|
pull_request:
|
|
branches: [ main, develop ]
|
|
workflow_dispatch:
|
|
inputs:
|
|
rollback_to:
|
|
description: 'Rollback to git SHA (skips build, redeploys old image). Empty = normal deploy.'
|
|
required: false
|
|
default: ''
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: write # version bump commit
|
|
packages: write # GHCR push
|
|
|
|
env:
|
|
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
|
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: pezkuwichain/pwap-web
|
|
|
|
jobs:
|
|
# ========================================
|
|
# WEB APP - LINT, TEST & BUILD
|
|
# ========================================
|
|
web:
|
|
name: Web App
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Checkout Pezkuwi-SDK (for docs generation)
|
|
run: |
|
|
git clone https://git.pezkuwichain.io/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK || \
|
|
git clone https://github.com/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Cache npm dependencies
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: web/node_modules
|
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-web-
|
|
|
|
- name: Install dependencies
|
|
working-directory: ./web
|
|
run: npm install
|
|
|
|
- name: Run Linter
|
|
working-directory: ./web
|
|
run: npm run lint
|
|
|
|
- name: Run Tests
|
|
working-directory: ./web
|
|
run: npm run test
|
|
|
|
- name: Build Project
|
|
working-directory: ./web
|
|
run: npm run build
|
|
env:
|
|
VITE_NETWORK: MAINNET
|
|
VITE_WS_ENDPOINT: wss://rpc.pezkuwichain.io
|
|
VITE_WS_ENDPOINT_FALLBACK_1: wss://mainnet.pezkuwichain.io
|
|
VITE_ASSET_HUB_ENDPOINT: wss://asset-hub-rpc.pezkuwichain.io
|
|
VITE_PEOPLE_CHAIN_ENDPOINT: wss://people-rpc.pezkuwichain.io
|
|
VITE_WALLETCONNECT_PROJECT_ID: 8292a793b7640e8364c378e331e76d04
|
|
|
|
- name: Upload build artifact
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: web-dist
|
|
path: web/dist/
|
|
|
|
# ========================================
|
|
# BUILD & PUSH DOCKER IMAGE TO GHCR
|
|
# Immutable artifact for audit + rollback (vs ephemeral GHA artifact).
|
|
# Tagged with git SHA so any commit can be redeployed by SHA.
|
|
# ========================================
|
|
build-image:
|
|
name: Build & Push Image
|
|
runs-on: ubuntu-latest
|
|
needs: [web, telegram-gate]
|
|
if: |
|
|
github.ref == 'refs/heads/main' &&
|
|
(github.event_name == 'push' ||
|
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to == ''))
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
outputs:
|
|
image_sha: ${{ steps.meta.outputs.image_sha }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Extract image metadata
|
|
id: meta
|
|
run: |
|
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
|
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
|
echo "image_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
|
echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Build and push
|
|
uses: docker/build-push-action@v6
|
|
with:
|
|
context: ./
|
|
file: ./web/Dockerfile
|
|
push: true
|
|
tags: |
|
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
|
${{ steps.meta.outputs.image }}:latest
|
|
build-args: |
|
|
VITE_NETWORK=MAINNET
|
|
VITE_WS_ENDPOINT=wss://rpc.pezkuwichain.io
|
|
VITE_WS_ENDPOINT_FALLBACK_1=wss://mainnet.pezkuwichain.io
|
|
VITE_ASSET_HUB_ENDPOINT=wss://asset-hub-rpc.pezkuwichain.io
|
|
VITE_PEOPLE_CHAIN_ENDPOINT=wss://people-rpc.pezkuwichain.io
|
|
VITE_WALLETCONNECT_PROJECT_ID=8292a793b7640e8364c378e331e76d04
|
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache
|
|
cache-to: type=registry,ref=${{ steps.meta.outputs.image }}:cache,mode=max
|
|
provenance: false
|
|
|
|
# ========================================
|
|
# TELEGRAM CEO APPROVAL GATE
|
|
# Runs on self-hosted pwap-runner (DEV VPS) where pexsec-bot.service
|
|
# writes the gate file to /tmp/pexsec-gates/<sha> when CEO clicks
|
|
# Approve/Cancel in Telegram. 30-minute timeout = deploy cancelled.
|
|
# ========================================
|
|
telegram-gate:
|
|
name: Telegram deploy approval
|
|
runs-on: pwap-runner
|
|
needs: [web, security-audit]
|
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
|
|
steps:
|
|
- name: Send approval request and wait
|
|
env:
|
|
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
|
|
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
|
|
SHA: ${{ github.sha }}
|
|
ACTOR: ${{ github.actor }}
|
|
MESSAGE: ${{ github.event.head_commit.message }}
|
|
run: |
|
|
SHORT="${SHA:0:7}"
|
|
GATE_DIR="/tmp/pexsec-gates"
|
|
mkdir -p "$GATE_DIR" 2>/dev/null || true
|
|
rm -f "$GATE_DIR/$SHORT" 2>/dev/null || true
|
|
|
|
# Strip Markdown special chars to prevent Telegram parse errors
|
|
SAFE_MSG=$(echo "${MESSAGE}" | head -1 | tr -d '_*`[]()#|{}!' | cut -c1-120)
|
|
|
|
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{
|
|
\"chat_id\": \"${CEO_CHAT_ID}\",
|
|
\"parse_mode\": \"Markdown\",
|
|
\"text\": \"🚀 *pwap/web Deploy Approval*\\n\\n\`${SHORT}\` — ${ACTOR}\\n\\n_${SAFE_MSG}_\\n\\nTargets: app.pezkuwichain.io + pex.mom\",
|
|
\"reply_markup\": {
|
|
\"inline_keyboard\": [[
|
|
{\"text\": \"✅ Approve\", \"callback_data\": \"deploy_approve:${SHORT}\"},
|
|
{\"text\": \"❌ Cancel\", \"callback_data\": \"deploy_cancel:${SHORT}\"}
|
|
]]
|
|
}
|
|
}"
|
|
|
|
echo "Waiting for Telegram approval (max 30 min)..."
|
|
TIMEOUT=1800
|
|
ELAPSED=0
|
|
while [ $ELAPSED -lt $TIMEOUT ]; do
|
|
if [ -f "$GATE_DIR/$SHORT" ]; then
|
|
DECISION=$(cat "$GATE_DIR/$SHORT")
|
|
rm -f "$GATE_DIR/$SHORT" 2>/dev/null || true
|
|
if [ "$DECISION" = "approved" ]; then
|
|
echo "Deploy approved."
|
|
exit 0
|
|
else
|
|
echo "Deploy cancelled."
|
|
exit 1
|
|
fi
|
|
fi
|
|
sleep 10
|
|
ELAPSED=$((ELAPSED + 10))
|
|
done
|
|
echo "No approval received within 30 minutes — deploy cancelled."
|
|
exit 1
|
|
|
|
# ========================================
|
|
# VERSION BUMP (RUNS BEFORE BOTH DEPLOYS)
|
|
# ========================================
|
|
bump-version:
|
|
name: Bump Version
|
|
runs-on: ubuntu-latest
|
|
needs: [web, security-audit, telegram-gate, build-image]
|
|
# Skip on rollback (workflow_dispatch with rollback_to set)
|
|
if: |
|
|
github.ref == 'refs/heads/main' &&
|
|
(github.event_name == 'push' ||
|
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to == ''))
|
|
outputs:
|
|
new_version: ${{ steps.bump.outputs.version }}
|
|
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Configure Git
|
|
run: |
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
|
|
- name: Bump version
|
|
id: bump
|
|
working-directory: ./web
|
|
run: |
|
|
npm version patch --no-git-tag-version
|
|
VERSION=$(node -p "require('./package.json').version")
|
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
cd ..
|
|
git add web/package.json
|
|
git commit -m "chore(web): bump version to $VERSION [skip ci]" || echo "No version change"
|
|
git push || echo "Nothing to push"
|
|
|
|
# ========================================
|
|
# DEPLOY TO app.pezkuwichain.io (DEV VPS)
|
|
# Pulls SHA-tagged image from GHCR, extracts /dist, scp to VPS.
|
|
# Health check + auto-rollback to .deploy-tag-prev on failure.
|
|
# ========================================
|
|
deploy-app:
|
|
name: Deploy app.pezkuwichain.io
|
|
runs-on: ubuntu-latest
|
|
needs: [telegram-gate, bump-version, build-image]
|
|
if: |
|
|
always() &&
|
|
needs.telegram-gate.result == 'success' &&
|
|
((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') ||
|
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
env:
|
|
DOMAIN: app.pezkuwichain.io
|
|
TARGET_PATH: /var/www/subdomains/app
|
|
|
|
steps:
|
|
- name: Determine image SHA
|
|
id: sha
|
|
run: |
|
|
if [ -n "${{ github.event.inputs.rollback_to }}" ]; then
|
|
echo "sha=${{ github.event.inputs.rollback_to }}" >> $GITHUB_OUTPUT
|
|
echo "Rolling back to: ${{ github.event.inputs.rollback_to }}"
|
|
else
|
|
echo "sha=${{ needs.build-image.outputs.image_sha }}" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Extract /dist from image
|
|
run: |
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.sha }}"
|
|
docker pull "$IMAGE"
|
|
CID=$(docker create "$IMAGE")
|
|
mkdir -p dist
|
|
docker cp "$CID:/dist/." dist/
|
|
docker rm "$CID" >/dev/null
|
|
echo "Extracted dist/ contents:"
|
|
ls -la dist/ | head -10
|
|
|
|
- name: Deploy to DEV VPS
|
|
uses: appleboy/scp-action@v1.0.0
|
|
with:
|
|
host: ${{ secrets.VPS_HOST }}
|
|
username: ${{ secrets.VPS_USER }}
|
|
key: ${{ secrets.VPS_SSH_KEY }}
|
|
port: ${{ secrets.VPS_SSH_PORT || 2222 }}
|
|
source: 'dist/*'
|
|
target: '/var/www/subdomains/app'
|
|
strip_components: 1
|
|
|
|
- name: Health check (60s window)
|
|
id: healthcheck
|
|
run: |
|
|
for i in 1 2 3 4 5 6; do
|
|
if curl -sf --max-time 10 "https://${{ env.DOMAIN }}/" >/dev/null; then
|
|
echo "✅ ${{ env.DOMAIN }} healthy"
|
|
exit 0
|
|
fi
|
|
echo "Attempt $i/6 failed, retrying in 10s..."
|
|
sleep 10
|
|
done
|
|
echo "❌ Health check failed for ${{ env.DOMAIN }}"
|
|
exit 1
|
|
|
|
- name: Post-deploy notification
|
|
if: success()
|
|
run: |
|
|
echo "✅ Deployed image ${{ steps.sha.outputs.sha }} to ${{ env.DOMAIN }}"
|
|
|
|
- name: Notify failure (Telegram)
|
|
if: failure()
|
|
env:
|
|
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
|
|
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
|
|
run: |
|
|
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=${CEO_CHAT_ID}" \
|
|
-d "text=❌ pwap/web deploy FAILED: ${{ env.DOMAIN }} (sha ${{ steps.sha.outputs.sha }}). Health check did not pass after deploy. Manual rollback needed: gh workflow run quality-gate.yml -f rollback_to=<previous-sha>"
|
|
|
|
# ========================================
|
|
# DEPLOY TO pex.mom (VPS3 — geo-redundant mirror)
|
|
# ========================================
|
|
deploy-pex:
|
|
name: Deploy pex.mom
|
|
runs-on: ubuntu-latest
|
|
needs: [telegram-gate, bump-version, build-image]
|
|
if: |
|
|
always() &&
|
|
needs.telegram-gate.result == 'success' &&
|
|
((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') ||
|
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
env:
|
|
DOMAIN: pex.mom
|
|
TARGET_PATH: /var/www/pex.mom
|
|
|
|
steps:
|
|
- name: Determine image SHA
|
|
id: sha
|
|
run: |
|
|
if [ -n "${{ github.event.inputs.rollback_to }}" ]; then
|
|
echo "sha=${{ github.event.inputs.rollback_to }}" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "sha=${{ needs.build-image.outputs.image_sha }}" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Extract /dist from image
|
|
run: |
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.sha }}"
|
|
docker pull "$IMAGE"
|
|
CID=$(docker create "$IMAGE")
|
|
mkdir -p dist
|
|
docker cp "$CID:/dist/." dist/
|
|
docker rm "$CID" >/dev/null
|
|
|
|
- name: Deploy to VPS3
|
|
uses: appleboy/scp-action@v1.0.0
|
|
with:
|
|
host: ${{ secrets.VPS_PEX_HOST }}
|
|
username: ${{ secrets.VPS_PEX_USER }}
|
|
key: ${{ secrets.VPS_PEX_SSH_KEY }}
|
|
port: ${{ secrets.VPS_PEX_SSH_PORT || 22 }}
|
|
source: 'dist/*'
|
|
target: '/var/www/pex.mom'
|
|
strip_components: 1
|
|
|
|
- name: Health check (60s window)
|
|
run: |
|
|
for i in 1 2 3 4 5 6; do
|
|
if curl -sf --max-time 10 "https://${{ env.DOMAIN }}/" >/dev/null; then
|
|
echo "✅ ${{ env.DOMAIN }} healthy"
|
|
exit 0
|
|
fi
|
|
echo "Attempt $i/6 failed, retrying in 10s..."
|
|
sleep 10
|
|
done
|
|
echo "❌ Health check failed for ${{ env.DOMAIN }}"
|
|
exit 1
|
|
|
|
- name: Post-deploy notification
|
|
if: success()
|
|
run: |
|
|
echo "✅ Deployed image ${{ steps.sha.outputs.sha }} to ${{ env.DOMAIN }}"
|
|
|
|
- name: Notify failure (Telegram)
|
|
if: failure()
|
|
env:
|
|
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
|
|
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
|
|
run: |
|
|
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=${CEO_CHAT_ID}" \
|
|
-d "text=❌ pwap/web deploy FAILED: ${{ env.DOMAIN }} (sha ${{ steps.sha.outputs.sha }}). Health check did not pass. Rollback: gh workflow run quality-gate.yml -f rollback_to=<previous-sha>"
|
|
|
|
# ========================================
|
|
# SECURITY CHECKS (BLOCKING)
|
|
# npm audit (high + critical) + TruffleHog secret scan
|
|
# ========================================
|
|
security-audit:
|
|
name: Security Audit
|
|
runs-on: ubuntu-latest
|
|
needs: [web]
|
|
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Web — npm audit (high + critical)
|
|
working-directory: ./web
|
|
run: |
|
|
npm install
|
|
npm audit --audit-level=high
|
|
|
|
- name: TruffleHog — PR diff (verified secrets only)
|
|
if: github.event_name == 'pull_request'
|
|
uses: trufflesecurity/trufflehog@main
|
|
with:
|
|
base: ${{ github.event.pull_request.base.sha }}
|
|
head: ${{ github.event.pull_request.head.sha }}
|
|
extra_args: --only-verified
|
|
|
|
- name: TruffleHog — full repo scan (verified secrets only)
|
|
if: github.event_name != 'pull_request'
|
|
uses: trufflesecurity/trufflehog@main
|
|
with:
|
|
path: ./
|
|
extra_args: --only-verified
|
|
|
|
# ========================================
|
|
# CI GATE — explicit merge-block
|
|
# All required checks must succeed (or be skipped, e.g. for rollback path).
|
|
# Branch protection on main should require this job's success.
|
|
# ========================================
|
|
ci-gate:
|
|
name: CI Gate ✅
|
|
runs-on: ubuntu-latest
|
|
needs: [web, security-audit]
|
|
if: always()
|
|
|
|
steps:
|
|
- name: Verify all required jobs succeeded or were intentionally skipped
|
|
run: |
|
|
results='${{ toJSON(needs) }}'
|
|
echo "$results" | python3 -c "
|
|
import json, sys
|
|
needs = json.load(sys.stdin)
|
|
failed = [name for name, job in needs.items() if job['result'] not in ('success', 'skipped')]
|
|
if failed:
|
|
print('❌ Required jobs failed: ' + ', '.join(failed))
|
|
sys.exit(1)
|
|
print('✅ All required CI jobs passed or skipped')
|
|
"
|