mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-11 06:01:02 +00:00
2cbfd21539
docker/login-action writes ~/.docker/config.json but cosign on self- hosted runner does not always read it. Add 'cosign login ghcr.io' before sign (build-image) and verify (deploy-app, deploy-pex) so the registry blob upload/download authenticates correctly. The previous run signed via Sigstore (Fulcio cert + Rekor tlog entry created) but failed at the final 'push signature blob to GHCR' step with UNAUTHORIZED. Explicit cosign login solves this.
676 lines
25 KiB
YAML
676 lines
25 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: pwap-runner
|
|
|
|
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: pwap-runner
|
|
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
|
|
id-token: write # cosign keyless signing via Sigstore OIDC
|
|
outputs:
|
|
image_sha: ${{ steps.meta.outputs.image_sha }}
|
|
image_digest: ${{ steps.build.outputs.digest }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
|
|
- name: Install cosign
|
|
uses: sigstore/cosign-installer@v3
|
|
with:
|
|
cosign-release: 'v2.4.1'
|
|
|
|
- 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
|
|
id: build
|
|
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
|
|
|
|
- name: Sign image with cosign (keyless, Sigstore Fulcio)
|
|
env:
|
|
COSIGN_EXPERIMENTAL: '1'
|
|
run: |
|
|
IMAGE_DIGEST="${{ steps.meta.outputs.image }}@${{ steps.build.outputs.digest }}"
|
|
# cosign needs its own registry auth — docker/login-action only writes
|
|
# ~/.docker/config.json which cosign on self-hosted runner can't read
|
|
echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin
|
|
echo "Signing $IMAGE_DIGEST"
|
|
cosign sign --yes "$IMAGE_DIGEST"
|
|
echo "✅ Image signed (transparency log: rekor.sigstore.dev)"
|
|
|
|
# ========================================
|
|
# 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: pwap-runner
|
|
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: pwap-runner
|
|
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: Capture currently-live SHA (for auto-rollback)
|
|
id: prev
|
|
run: |
|
|
# /.deploy-sha is written into every deploy; read what's live now
|
|
PREV=$(curl -sf --max-time 5 "https://${{ env.DOMAIN }}/.deploy-sha" | head -c 40 | tr -dc 'a-f0-9' || echo "")
|
|
echo "Previous live SHA: ${PREV:-unknown}"
|
|
echo "prev=$PREV" >> $GITHUB_OUTPUT
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Install cosign (for verify)
|
|
uses: sigstore/cosign-installer@v3
|
|
with:
|
|
cosign-release: 'v2.4.1'
|
|
|
|
- name: Verify image signature (cosign keyless)
|
|
env:
|
|
COSIGN_EXPERIMENTAL: '1'
|
|
run: |
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.sha }}"
|
|
echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin
|
|
echo "Verifying signature for $IMAGE"
|
|
cosign verify "$IMAGE" \
|
|
--certificate-identity-regexp "^https://github.com/pezkuwichain/pwap/.github/workflows/quality-gate.yml@" \
|
|
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
|
> /dev/null
|
|
echo "✅ Signature valid — image was built by trusted CI"
|
|
|
|
- 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
|
|
# Stamp this build's SHA into dist so future deploys can read PREV
|
|
echo "${{ steps.sha.outputs.sha }}" > dist/.deploy-sha
|
|
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
|
|
|
|
# ── Automatic rollback: pull PREV SHA image, redeploy, recheck ──
|
|
- name: Auto-rollback to previous SHA
|
|
id: rollback
|
|
if: failure() && steps.healthcheck.conclusion == 'failure' && steps.prev.outputs.prev != ''
|
|
run: |
|
|
PREV="${{ steps.prev.outputs.prev }}"
|
|
echo "🔄 Rolling back ${{ env.DOMAIN }} to $PREV"
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV"
|
|
docker pull "$IMAGE"
|
|
CID=$(docker create "$IMAGE")
|
|
rm -rf dist && mkdir dist
|
|
docker cp "$CID:/dist/." dist/
|
|
docker rm "$CID" >/dev/null
|
|
echo "$PREV" > dist/.deploy-sha
|
|
echo "rollback_sha=$PREV" >> $GITHUB_OUTPUT
|
|
|
|
- name: SCP rollback artifact
|
|
if: steps.rollback.outcome == 'success'
|
|
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: Re-health-check after rollback
|
|
if: steps.rollback.outcome == 'success'
|
|
id: healthcheck_rb
|
|
run: |
|
|
for i in 1 2 3 4 5 6; do
|
|
if curl -sf --max-time 10 "https://${{ env.DOMAIN }}/" >/dev/null; then
|
|
echo "✅ Rolled back successfully — ${{ env.DOMAIN }} healthy on ${{ steps.rollback.outputs.rollback_sha }}"
|
|
exit 0
|
|
fi
|
|
sleep 10
|
|
done
|
|
echo "❌ Rollback also failed!"
|
|
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 }}
|
|
NEW_SHA: ${{ steps.sha.outputs.sha }}
|
|
PREV_SHA: ${{ steps.prev.outputs.prev }}
|
|
ROLLBACK_OUTCOME: ${{ steps.rollback.outcome }}
|
|
RECHECK_OUTCOME: ${{ steps.healthcheck_rb.outcome }}
|
|
run: |
|
|
if [ "$RECHECK_OUTCOME" = "success" ]; then
|
|
MSG="⚠️ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed health check, AUTO-ROLLED-BACK to $PREV_SHA. Site healthy."
|
|
elif [ "$ROLLBACK_OUTCOME" = "success" ]; then
|
|
MSG="🚨 pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed AND rollback to $PREV_SHA also failed. Manual intervention needed."
|
|
elif [ -z "$PREV_SHA" ]; then
|
|
MSG="❌ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed. No previous SHA available (first deploy?). Manual rollback: gh workflow run quality-gate.yml -f rollback_to=<sha>"
|
|
else
|
|
MSG="❌ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed. Auto-rollback was not attempted. Manual: gh workflow run quality-gate.yml -f rollback_to=$PREV_SHA"
|
|
fi
|
|
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG"
|
|
|
|
# ========================================
|
|
# DEPLOY TO pex.mom (VPS3 — geo-redundant mirror)
|
|
# ========================================
|
|
deploy-pex:
|
|
name: Deploy pex.mom
|
|
runs-on: pwap-runner
|
|
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: Capture currently-live SHA (for auto-rollback)
|
|
id: prev
|
|
run: |
|
|
PREV=$(curl -sf --max-time 5 "https://${{ env.DOMAIN }}/.deploy-sha" | head -c 40 | tr -dc 'a-f0-9' || echo "")
|
|
echo "Previous live SHA: ${PREV:-unknown}"
|
|
echo "prev=$PREV" >> $GITHUB_OUTPUT
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Install cosign (for verify)
|
|
uses: sigstore/cosign-installer@v3
|
|
with:
|
|
cosign-release: 'v2.4.1'
|
|
|
|
- name: Verify image signature (cosign keyless)
|
|
env:
|
|
COSIGN_EXPERIMENTAL: '1'
|
|
run: |
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.sha }}"
|
|
echo "Verifying signature for $IMAGE"
|
|
cosign verify "$IMAGE" \
|
|
--certificate-identity-regexp "^https://github.com/pezkuwichain/pwap/.github/workflows/quality-gate.yml@" \
|
|
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
|
> /dev/null
|
|
echo "✅ Signature valid — image was built by trusted CI"
|
|
|
|
- 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 "${{ steps.sha.outputs.sha }}" > dist/.deploy-sha
|
|
|
|
- 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)
|
|
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: Auto-rollback to previous SHA
|
|
id: rollback
|
|
if: failure() && steps.healthcheck.conclusion == 'failure' && steps.prev.outputs.prev != ''
|
|
run: |
|
|
PREV="${{ steps.prev.outputs.prev }}"
|
|
echo "🔄 Rolling back ${{ env.DOMAIN }} to $PREV"
|
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV"
|
|
docker pull "$IMAGE"
|
|
CID=$(docker create "$IMAGE")
|
|
rm -rf dist && mkdir dist
|
|
docker cp "$CID:/dist/." dist/
|
|
docker rm "$CID" >/dev/null
|
|
echo "$PREV" > dist/.deploy-sha
|
|
echo "rollback_sha=$PREV" >> $GITHUB_OUTPUT
|
|
|
|
- name: SCP rollback artifact
|
|
if: steps.rollback.outcome == 'success'
|
|
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: Re-health-check after rollback
|
|
if: steps.rollback.outcome == 'success'
|
|
id: healthcheck_rb
|
|
run: |
|
|
for i in 1 2 3 4 5 6; do
|
|
if curl -sf --max-time 10 "https://${{ env.DOMAIN }}/" >/dev/null; then
|
|
echo "✅ Rolled back successfully — ${{ env.DOMAIN }} healthy on ${{ steps.rollback.outputs.rollback_sha }}"
|
|
exit 0
|
|
fi
|
|
sleep 10
|
|
done
|
|
echo "❌ Rollback also failed!"
|
|
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 }}
|
|
NEW_SHA: ${{ steps.sha.outputs.sha }}
|
|
PREV_SHA: ${{ steps.prev.outputs.prev }}
|
|
ROLLBACK_OUTCOME: ${{ steps.rollback.outcome }}
|
|
RECHECK_OUTCOME: ${{ steps.healthcheck_rb.outcome }}
|
|
run: |
|
|
if [ "$RECHECK_OUTCOME" = "success" ]; then
|
|
MSG="⚠️ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed health check, AUTO-ROLLED-BACK to $PREV_SHA. Site healthy."
|
|
elif [ "$ROLLBACK_OUTCOME" = "success" ]; then
|
|
MSG="🚨 pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed AND rollback to $PREV_SHA also failed. Manual intervention needed."
|
|
elif [ -z "$PREV_SHA" ]; then
|
|
MSG="❌ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed. No previous SHA available (first deploy?). Manual rollback: gh workflow run quality-gate.yml -f rollback_to=<sha>"
|
|
else
|
|
MSG="❌ pwap/web ${{ env.DOMAIN }}: deploy ($NEW_SHA) failed. Auto-rollback was not attempted. Manual: gh workflow run quality-gate.yml -f rollback_to=$PREV_SHA"
|
|
fi
|
|
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG"
|
|
|
|
# ========================================
|
|
# SECURITY CHECKS (BLOCKING)
|
|
# npm audit (high + critical) + TruffleHog secret scan
|
|
# ========================================
|
|
security-audit:
|
|
name: Security Audit
|
|
runs-on: pwap-runner
|
|
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: pwap-runner
|
|
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')
|
|
"
|