Scraper services depend on dapr-placement (dapr.yml) and nats (nats.yml). When only scraper.yml changed, docker compose pull/up failed with 'undefined service' because dependent compose files were excluded. Now always include ALL compose files for dependency resolution, but selectively pull and up only the target services during partial updates.
240 lines
9.6 KiB
YAML
240 lines
9.6 KiB
YAML
name: Deploy Docker to VPS
|
||
|
||
on:
|
||
workflow_run:
|
||
workflows: ['Build and Push Docker Images']
|
||
types:
|
||
- completed
|
||
branches:
|
||
- main
|
||
push:
|
||
branches:
|
||
- main
|
||
paths:
|
||
- 'infra/**'
|
||
- '.github/workflows/deploy-docker.yml'
|
||
- '.github/workflows/docker-build-push.yml'
|
||
workflow_dispatch:
|
||
|
||
# Prevent multiple deployments from running simultaneously
|
||
concurrency:
|
||
group: deploy-vps
|
||
cancel-in-progress: false
|
||
|
||
permissions:
|
||
contents: read
|
||
packages: read
|
||
|
||
jobs:
|
||
deploy:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 30
|
||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.workflow_run.conclusion == 'success'
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v6
|
||
with:
|
||
fetch-depth: 1
|
||
submodules: false
|
||
|
||
- name: Deploy to VPS
|
||
env:
|
||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||
VPS_USER: ${{ secrets.VPS_USER }}
|
||
VPS_TARGET_DIR: ${{ secrets.VPS_TARGET_DIR }}
|
||
ENV_FILE_PRODUCTION: ${{ secrets.ENV_FILE_PRODUCTION }}
|
||
GHCR_USERNAME: ${{ github.actor }}
|
||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
echo "Deploy event: ${{ github.event_name }}"
|
||
echo "Deploy ref: ${{ github.ref }}"
|
||
echo "Deploy sha: ${{ github.sha }}"
|
||
|
||
if [ -z "${SSH_PRIVATE_KEY:-}" ] || [ -z "${VPS_HOST:-}" ] || [ -z "${VPS_USER:-}" ] || [ -z "${VPS_TARGET_DIR:-}" ]; then
|
||
echo "❌ Deployment secrets are not fully configured. Please set SSH_PRIVATE_KEY, VPS_HOST, VPS_USER, and VPS_TARGET_DIR."
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p ~/.ssh
|
||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||
chmod 600 ~/.ssh/id_rsa
|
||
ssh-keyscan -H -t ed25519,rsa "$VPS_HOST" >> ~/.ssh/known_hosts
|
||
|
||
# Use SSH multiplexing for faster subsequent commands
|
||
SSH_OPTS=(-o ControlMaster=auto -o ControlPath=/tmp/ssh-%r@%h:%p -o ControlPersist=600 -o StrictHostKeyChecking=yes)
|
||
|
||
ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_TARGET_DIR && mkdir -p $VPS_TARGET_DIR/infra/compose"
|
||
echo "$ENV_FILE_PRODUCTION" > .env.prod
|
||
scp "${SSH_OPTS[@]}" .env.prod "$VPS_USER@$VPS_HOST:$VPS_TARGET_DIR/.env"
|
||
|
||
echo "🔐 Logging in to GitHub Container Registry..."
|
||
printf '%s' "$GHCR_TOKEN" | ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "docker login ghcr.io -u '$GHCR_USERNAME' --password-stdin"
|
||
|
||
ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "export VPS_TARGET_DIR=$VPS_TARGET_DIR; bash -s" <<'EOF'
|
||
set -euo pipefail
|
||
cd "$VPS_TARGET_DIR"
|
||
|
||
# Ensure shared network exists
|
||
docker network inspect app-shared-net >/dev/null 2>&1 || docker network create app-shared-net
|
||
|
||
echo "🔄 Synchronizing repository..."
|
||
if [ ! -d ".git" ]; then
|
||
echo "Initializing git repository..."
|
||
git init
|
||
git remote add origin https://github.com/asepharyana/asepharyana-hub.git
|
||
fi
|
||
git fetch origin main --depth=1 || true
|
||
|
||
# Detect changed files before resetting
|
||
ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/nats.yml infra/compose/dapr.yml"
|
||
TRAEFIK_DYNAMIC_DIR="infra/traefik/dynamic"
|
||
|
||
if git rev-parse HEAD >/dev/null 2>&1; then
|
||
BEFORE_REV=$(git rev-parse HEAD)
|
||
git reset --hard FETCH_HEAD
|
||
AFTER_REV=$(git rev-parse HEAD)
|
||
|
||
if [ "$BEFORE_REV" = "$AFTER_REV" ]; then
|
||
echo "ℹ️ No new commits detected. Using full file list for safety."
|
||
TARGET_COMPOSE=""
|
||
else
|
||
CHANGED=$(git diff --name-only "$BEFORE_REV" "$AFTER_REV" || true)
|
||
echo "📄 Changed files:"
|
||
echo "$CHANGED"
|
||
|
||
# Detect compose stack changes
|
||
CHANGED_COMPOSE=$(echo "$CHANGED" | grep '^infra/compose/.*\.yml$' || true)
|
||
TARGET_COMPOSE=""
|
||
for f in $CHANGED_COMPOSE; do
|
||
case " $ALL_COMPOSE_FILES " in
|
||
*" $f "*) TARGET_COMPOSE="$TARGET_COMPOSE $f" ;;
|
||
esac
|
||
done
|
||
TARGET_COMPOSE=$(printf '%s' "$TARGET_COMPOSE" | xargs || true)
|
||
if [ -n "$TARGET_COMPOSE" ]; then
|
||
echo "🎯 Detected compose stack changes in: $TARGET_COMPOSE"
|
||
else
|
||
echo "ℹ️ No stack compose files changed."
|
||
fi
|
||
|
||
# Detect Traefik dynamic config changes
|
||
CHANGED_TRAEFIK=$(echo "$CHANGED" | grep "^$TRAEFIK_DYNAMIC_DIR/" || true)
|
||
if [ -n "$CHANGED_TRAEFIK" ]; then
|
||
echo "🎯 Detected Traefik dynamic config changes:"
|
||
echo "$CHANGED_TRAEFIK"
|
||
RELOAD_TRAEFIK="true"
|
||
else
|
||
echo "ℹ️ No Traefik dynamic config changes."
|
||
fi
|
||
|
||
# Detect infra file changes (Dockerfiles, config, traefik static)
|
||
CHANGED_INFRA=$(echo "$CHANGED" | grep '^infra/' | grep -v '^infra/compose/' || true)
|
||
if [ -n "$CHANGED_INFRA" ]; then
|
||
echo "📦 Detected other infra file changes:"
|
||
echo "$CHANGED_INFRA"
|
||
fi
|
||
fi
|
||
else
|
||
git reset --hard FETCH_HEAD
|
||
TARGET_COMPOSE=""
|
||
fi
|
||
|
||
if command -v "docker" >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||
COMPOSE_CMD="docker compose"
|
||
elif command -v docker-compose >/dev/null 2>&1; then
|
||
COMPOSE_CMD="docker-compose"
|
||
else
|
||
echo "❌ docker compose is not installed on the remote host."
|
||
exit 1
|
||
fi
|
||
|
||
# Always include ALL compose files for dependency resolution
|
||
COMPOSE_ARGS=""
|
||
for f in $ALL_COMPOSE_FILES; do
|
||
if [ -f "$f" ]; then
|
||
COMPOSE_ARGS="$COMPOSE_ARGS -f $f"
|
||
fi
|
||
done
|
||
|
||
if [ -n "$TARGET_COMPOSE" ]; then
|
||
# Extract service names from target compose file(s) for selective up
|
||
TARGET_SERVICES=""
|
||
for f in $TARGET_COMPOSE; do
|
||
if [ -f "$f" ]; then
|
||
svcs=$(grep -E '^\s{2}[a-zA-Z0-9_-]+:' "$f" | grep -v 'app-shared-net' | sed 's/://g' | xargs)
|
||
TARGET_SERVICES="$TARGET_SERVICES $svcs"
|
||
fi
|
||
done
|
||
TARGET_SERVICES=$(echo "$TARGET_SERVICES" | xargs) # trim whitespace
|
||
echo "🎯 Selective update for services: $TARGET_SERVICES"
|
||
else
|
||
echo "🚀 Performing full deployment of all services..."
|
||
TARGET_SERVICES=""
|
||
fi
|
||
|
||
echo "📥 Pulling images for target services..."
|
||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||
PULL_SUCCESS=false
|
||
# Retry pull up to 3 times to handle transient Docker attestation lease errors
|
||
for attempt in 1 2 3; do
|
||
echo "Pull attempt $attempt/3..."
|
||
if $COMPOSE_CMD $COMPOSE_ARGS --env-file .env pull $TARGET_SERVICES; then
|
||
echo "✅ Pull succeeded on attempt $attempt"
|
||
PULL_SUCCESS=true
|
||
break
|
||
else
|
||
echo "⚠️ Pull attempt $attempt failed. Retrying in 5s..."
|
||
sleep 5
|
||
fi
|
||
done
|
||
if [ "$PULL_SUCCESS" != "true" ]; then
|
||
echo "❌ Failed to pull images after 3 attempts."
|
||
exit 1
|
||
fi
|
||
|
||
echo "🧹 Clearing Git locks..."
|
||
rm -f .git/shallow.lock || true
|
||
|
||
echo "🧹 Removing stale target containers by container_name..."
|
||
# Extract all explicitly defined container_names from compose files and remove them to prevent conflicts
|
||
if [ -n "$TARGET_COMPOSE" ]; then
|
||
for f in $TARGET_COMPOSE; do
|
||
if [ -f "$f" ]; then
|
||
grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do
|
||
docker rm -f "$cname" >/dev/null 2>&1 || true
|
||
done
|
||
fi
|
||
done
|
||
else
|
||
for f in $ALL_COMPOSE_FILES; do
|
||
if [ -f "$f" ]; then
|
||
grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do
|
||
docker rm -f "$cname" >/dev/null 2>&1 || true
|
||
done
|
||
fi
|
||
done
|
||
fi
|
||
|
||
echo "🆙 Starting services..."
|
||
echo "🔍 Debug: Current docker containers:"
|
||
docker ps -a
|
||
if [ -n "$TARGET_SERVICES" ]; then
|
||
$COMPOSE_CMD $COMPOSE_ARGS --env-file .env up -d $TARGET_SERVICES
|
||
else
|
||
$COMPOSE_CMD $COMPOSE_ARGS --env-file .env up -d --remove-orphans
|
||
fi
|
||
|
||
# ── Traefik reload ──
|
||
if [ "${RELOAD_TRAEFIK:-false}" = "true" ]; then
|
||
echo "🔄 Traefik dynamic config changed — reloading Traefik..."
|
||
# Traefik watches the dynamic config dir (providers.file.watch=true),
|
||
# but send SIGHUP as insurance
|
||
docker kill --signal HUP traefik 2>/dev/null || docker exec traefik kill -HUP 1 2>/dev/null || true
|
||
echo "✅ Traefik reload signal sent"
|
||
fi
|
||
EOF
|
||
|