All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 26s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 29s
Deploy to Staging / Verify Staging (pull_request) Successful in 37s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The staging workflow was not copying docker-compose.yml to the server, causing configuration changes (like Stripe secrets) to not take effect. Added rsync step to sync config, scripts, and compose files before deployment, matching the production workflow behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
306 lines
12 KiB
YAML
306 lines
12 KiB
YAML
# MotoVaultPro Staging Deployment Workflow
|
|
# Triggers on push to main or any pull request, builds and deploys to staging.motovaultpro.com
|
|
# After verification, sends notification with link to trigger production deploy
|
|
|
|
name: Deploy to Staging
|
|
run-name: "Staging - ${{ gitea.event.pull_request.title || gitea.ref_name }}"
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
pull_request:
|
|
types: [opened, synchronize, reopened]
|
|
|
|
env:
|
|
REGISTRY: git.motovaultpro.com
|
|
DEPLOY_PATH: /opt/motovaultpro
|
|
COMPOSE_FILE: docker-compose.yml
|
|
COMPOSE_STAGING: docker-compose.staging.yml
|
|
HEALTH_CHECK_TIMEOUT: "60"
|
|
|
|
jobs:
|
|
# ============================================
|
|
# BUILD - Build and push images
|
|
# ============================================
|
|
build:
|
|
name: Build Images
|
|
runs-on: stage
|
|
outputs:
|
|
backend_image: ${{ steps.tags.outputs.backend_image }}
|
|
frontend_image: ${{ steps.tags.outputs.frontend_image }}
|
|
short_sha: ${{ steps.tags.outputs.short_sha }}
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Login to Gitea Container Registry
|
|
run: |
|
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY"
|
|
|
|
- name: Set image tags
|
|
id: tags
|
|
run: |
|
|
SHORT_SHA="${{ gitea.sha }}"
|
|
SHORT_SHA="${SHORT_SHA:0:7}"
|
|
echo "backend_image=$REGISTRY/egullickson/backend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
|
echo "frontend_image=$REGISTRY/egullickson/frontend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
|
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
|
|
|
- name: Build backend image
|
|
run: |
|
|
docker build \
|
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
--build-arg REGISTRY_MIRRORS=$REGISTRY/egullickson/mirrors \
|
|
--cache-from $REGISTRY/egullickson/backend:latest \
|
|
-t ${{ steps.tags.outputs.backend_image }} \
|
|
-t $REGISTRY/egullickson/backend:latest \
|
|
-f backend/Dockerfile \
|
|
.
|
|
|
|
- name: Build frontend image
|
|
run: |
|
|
docker build \
|
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
--build-arg REGISTRY_MIRRORS=$REGISTRY/egullickson/mirrors \
|
|
--build-arg VITE_AUTH0_DOMAIN=${{ vars.VITE_AUTH0_DOMAIN }} \
|
|
--build-arg VITE_AUTH0_CLIENT_ID=${{ vars.VITE_AUTH0_CLIENT_ID }} \
|
|
--build-arg VITE_AUTH0_AUDIENCE=${{ vars.VITE_AUTH0_AUDIENCE }} \
|
|
--build-arg VITE_API_BASE_URL=/api \
|
|
--build-arg VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }} \
|
|
--cache-from $REGISTRY/egullickson/frontend:latest \
|
|
-t ${{ steps.tags.outputs.frontend_image }} \
|
|
-t $REGISTRY/egullickson/frontend:latest \
|
|
-f frontend/Dockerfile \
|
|
frontend
|
|
|
|
- name: Push images
|
|
run: |
|
|
docker push ${{ steps.tags.outputs.backend_image }}
|
|
docker push ${{ steps.tags.outputs.frontend_image }}
|
|
docker push $REGISTRY/egullickson/backend:latest
|
|
docker push $REGISTRY/egullickson/frontend:latest
|
|
|
|
# ============================================
|
|
# DEPLOY STAGING - Deploy to staging server
|
|
# ============================================
|
|
deploy-staging:
|
|
name: Deploy to Staging
|
|
runs-on: stage
|
|
needs: build
|
|
env:
|
|
BACKEND_IMAGE: ${{ needs.build.outputs.backend_image }}
|
|
FRONTEND_IMAGE: ${{ needs.build.outputs.frontend_image }}
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Sync config, scripts, and compose files to deploy path
|
|
run: |
|
|
rsync -av --delete "$GITHUB_WORKSPACE/config/" "$DEPLOY_PATH/config/"
|
|
rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/"
|
|
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
|
|
cp "$GITHUB_WORKSPACE/docker-compose.staging.yml" "$DEPLOY_PATH/"
|
|
|
|
- name: Login to registry
|
|
run: |
|
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY"
|
|
|
|
- name: Inject secrets
|
|
run: |
|
|
cd "$DEPLOY_PATH"
|
|
chmod +x scripts/inject-secrets.sh
|
|
SECRETS_DIR="$DEPLOY_PATH/secrets/app" ./scripts/inject-secrets.sh
|
|
env:
|
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
|
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
|
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
|
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
|
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
|
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
|
|
|
- name: Initialize data directories
|
|
run: |
|
|
cd "$DEPLOY_PATH"
|
|
sudo mkdir -p data/backups data/documents data/traefik
|
|
sudo chown -R 1001:1001 data/backups data/documents
|
|
sudo chmod 755 data/backups data/documents
|
|
# Traefik acme.json requires 600 permissions
|
|
if [ ! -f data/traefik/acme.json ]; then
|
|
sudo touch data/traefik/acme.json
|
|
fi
|
|
sudo chmod 600 data/traefik/acme.json
|
|
|
|
- name: Pull new images
|
|
run: |
|
|
docker pull $BACKEND_IMAGE
|
|
docker pull $FRONTEND_IMAGE
|
|
|
|
- name: Deploy staging stack
|
|
run: |
|
|
cd "$DEPLOY_PATH"
|
|
export BACKEND_IMAGE=$BACKEND_IMAGE
|
|
export FRONTEND_IMAGE=$FRONTEND_IMAGE
|
|
docker compose -f $COMPOSE_FILE -f $COMPOSE_STAGING down --timeout 30 || true
|
|
docker compose -f $COMPOSE_FILE -f $COMPOSE_STAGING up -d
|
|
|
|
- name: Wait for services
|
|
run: sleep 15
|
|
|
|
# ============================================
|
|
# VERIFY STAGING - Health checks
|
|
# ============================================
|
|
verify-staging:
|
|
name: Verify Staging
|
|
runs-on: stage
|
|
needs: [build, deploy-staging]
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Check container status and health
|
|
run: |
|
|
for service in mvp-frontend-staging mvp-backend-staging mvp-postgres-staging mvp-redis-staging; do
|
|
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
|
if [ "$status" != "running" ]; then
|
|
echo "ERROR: $service is not running (status: $status)"
|
|
docker logs $service --tail 50 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
echo "OK: $service is running"
|
|
done
|
|
|
|
# Wait for Docker healthchecks to complete (services with healthcheck defined)
|
|
echo ""
|
|
echo "Waiting for Docker healthchecks..."
|
|
for service in mvp-frontend-staging mvp-backend-staging mvp-postgres-staging mvp-redis-staging; do
|
|
# Check if service has a healthcheck defined
|
|
has_healthcheck=$(docker inspect --format='{{if .Config.Healthcheck}}true{{else}}false{{end}}' $service 2>/dev/null || echo "false")
|
|
if [ "$has_healthcheck" = "true" ]; then
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
|
if [ "$health" = "healthy" ]; then
|
|
echo "OK: $service is healthy"
|
|
break
|
|
elif [ "$health" = "unhealthy" ]; then
|
|
echo "ERROR: $service is unhealthy"
|
|
docker logs $service --tail 50 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
if [ $i -eq 10 ]; then
|
|
echo "ERROR: $service health check timed out (status: $health)"
|
|
docker logs $service --tail 50 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
echo "Waiting for $service healthcheck... (attempt $i/10, status: $health)"
|
|
sleep 5
|
|
done
|
|
else
|
|
echo "SKIP: $service has no healthcheck defined"
|
|
fi
|
|
done
|
|
|
|
- name: Wait for backend health
|
|
run: |
|
|
for i in 1 2 3 4 5 6; do
|
|
if docker exec mvp-backend-staging curl -sf http://localhost:3001/health > /dev/null 2>&1; then
|
|
echo "OK: Backend health check passed"
|
|
exit 0
|
|
fi
|
|
if [ $i -eq 6 ]; then
|
|
echo "ERROR: Backend health check failed after 6 attempts"
|
|
docker logs mvp-backend-staging --tail 100
|
|
exit 1
|
|
fi
|
|
echo "Attempt $i/6: Backend not ready, waiting 10s..."
|
|
sleep 10
|
|
done
|
|
|
|
- name: Check external endpoint
|
|
run: |
|
|
REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]'
|
|
|
|
for i in 1 2 3 4 5 6; do
|
|
RESPONSE=$(curl -sf https://staging.motovaultpro.com/api/health 2>/dev/null) || {
|
|
echo "Attempt $i/6: Connection failed, waiting 10s..."
|
|
sleep 10
|
|
continue
|
|
}
|
|
|
|
# Check status is "healthy"
|
|
STATUS=$(echo "$RESPONSE" | jq -r '.status')
|
|
if [ "$STATUS" != "healthy" ]; then
|
|
echo "Attempt $i/6: Status is '$STATUS', not 'healthy'. Waiting 10s..."
|
|
sleep 10
|
|
continue
|
|
fi
|
|
|
|
# Check all required features are present
|
|
MISSING=$(echo "$RESPONSE" | jq -r --argjson required "$REQUIRED_FEATURES" '
|
|
$required - .features | if length > 0 then . else empty end | @json
|
|
')
|
|
|
|
if [ -n "$MISSING" ]; then
|
|
echo "Attempt $i/6: Missing features: $MISSING. Waiting 10s..."
|
|
sleep 10
|
|
continue
|
|
fi
|
|
|
|
FEATURE_COUNT=$(echo "$RESPONSE" | jq '.features | length')
|
|
echo "OK: Staging health check passed - status: healthy, features: $FEATURE_COUNT"
|
|
exit 0
|
|
done
|
|
|
|
echo "ERROR: Staging health check failed after 6 attempts"
|
|
echo "Last response: $RESPONSE"
|
|
exit 1
|
|
|
|
# ============================================
|
|
# NOTIFY - Staging ready for production
|
|
# ============================================
|
|
notify-staging-ready:
|
|
name: Notify Staging Ready
|
|
runs-on: stage
|
|
needs: [build, verify-staging]
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Send staging ready notification
|
|
run: |
|
|
cd "$DEPLOY_PATH"
|
|
chmod +x scripts/ci/notify.sh
|
|
./scripts/ci/notify.sh staging_ready \
|
|
"Staging verified for commit ${{ needs.build.outputs.short_sha }}. Review at https://staging.motovaultpro.com then trigger production deploy at https://git.motovaultpro.com/egullickson/motovaultpro/actions" \
|
|
${{ needs.build.outputs.short_sha }}
|
|
env:
|
|
DEPLOY_NOTIFY_EMAIL: ${{ vars.DEPLOY_NOTIFY_EMAIL }}
|
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
|
|
|
# ============================================
|
|
# NOTIFY FAILURE - Staging failed
|
|
# ============================================
|
|
notify-staging-failure:
|
|
name: Notify Staging Failure
|
|
runs-on: stage
|
|
needs: [build, deploy-staging, verify-staging]
|
|
if: failure()
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Send failure notification
|
|
run: |
|
|
cd "$DEPLOY_PATH"
|
|
chmod +x scripts/ci/notify.sh
|
|
SHORT_SHA="${{ gitea.sha }}"
|
|
SHORT_SHA="${SHORT_SHA:0:7}"
|
|
./scripts/ci/notify.sh failure "Staging deployment failed for commit $SHORT_SHA" $SHORT_SHA
|
|
env:
|
|
DEPLOY_NOTIFY_EMAIL: ${{ vars.DEPLOY_NOTIFY_EMAIL }}
|
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|