diff --git a/.gitea/workflows/deploy-vps.yml b/.gitea/workflows/deploy-vps.yml index 12b66fb..6ecb59e 100644 --- a/.gitea/workflows/deploy-vps.yml +++ b/.gitea/workflows/deploy-vps.yml @@ -1,7 +1,7 @@ name: Deploy admin to VPS -# Single-job flow — Gitea act_runner is more reliable without multi-job graphs -# (same lesson as telegrambot deploy). Quality still runs on pull_request. +# act_runner job containers have no Docker daemon. Build on the VPS (same +# pattern as telegrambot) after rsyncing sources. on: push: branches: [main] @@ -13,109 +13,50 @@ concurrency: jobs: deploy: - name: Build, push, and deploy admin + name: Rsync and deploy admin runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - - name: Image metadata - id: meta - run: | - set -euo pipefail - owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" - repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" - echo "image=git.ghabilee.ir/${owner}/${repo}" >> "$GITHUB_OUTPUT" - echo "registry=git.ghabilee.ir" >> "$GITHUB_OUTPUT" - - - name: Fetch production build environment + - name: Deploy over SSH env: SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} run: | set -euo pipefail + test -n "${SSH_KEY:-}" + test -n "${VPS_HOST:-}" + test -n "${VPS_USER:-}" + install -m 700 -d "$HOME/.ssh" - printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key" - chmod 600 "$HOME/.ssh/vps_key" - env_path="$(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - "${VPS_USER}@${VPS_HOST}" \ - 'test -s /opt/ghabilee-admin/.env && printf %s /opt/ghabilee-admin/.env')" - scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - "${VPS_USER}@${VPS_HOST}:${env_path}" .env.production - test -s .env.production + printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/deploy_key" + chmod 600 "$HOME/.ssh/deploy_key" + + SSH=(ssh -i "$HOME/.ssh/deploy_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new) - - name: Install nginx site definition on VPS - env: - SSH_KEY: ${{ secrets.VPS_SSH_KEY }} - VPS_HOST: ${{ secrets.VPS_HOST }} - VPS_USER: ${{ secrets.VPS_USER }} - run: | - set -euo pipefail - SSH=(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new) - scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - deploy/nginx/backoffice.conf \ - "${VPS_USER}@${VPS_HOST}:/etc/nginx/sites-available/backoffice" "${SSH[@]}" "${VPS_USER}@${VPS_HOST}" ' set -eu - ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice - nginx -t - systemctl reload nginx + install -d -m 0750 /opt/ghabilee-admin /opt/ghabilee-admin/src + test -s /opt/ghabilee-admin/.env ' - - uses: docker/setup-buildx-action@v3 + rsync -az --delete \ + -e "ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + --exclude '.git' \ + --exclude 'node_modules' \ + --exclude '.next' \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude 'test-results' \ + --exclude 'playwright-report' \ + ./ "${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/src/" - - name: Log in to Gitea container registry - uses: docker/login-action@v3 - with: - registry: ${{ steps.meta.outputs.registry }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + "${SSH[@]}" "${VPS_USER}@${VPS_HOST}" \ + 'chmod +x /opt/ghabilee-admin/src/scripts/deploy-on-vps.sh && APP_DIR=/opt/ghabilee-admin SRC_DIR=/opt/ghabilee-admin/src /opt/ghabilee-admin/src/scripts/deploy-on-vps.sh' - - name: Build and push - env: - ADMIN_ENV_FILE: .env.production - IMAGE_REPO: ${{ steps.meta.outputs.image }} - IMAGE_TAG: ${{ github.sha }} - run: ./scripts/ci-build-image.sh - - - name: Deploy container on VPS - env: - REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - REGISTRY_HOST: git.ghabilee.ir - ADMIN_IMAGE: ${{ steps.meta.outputs.image }}:${{ github.sha }} - VPS_HOST: ${{ secrets.VPS_HOST }} - VPS_USER: ${{ secrets.VPS_USER }} - run: | - set -euo pipefail - scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - deploy/docker-compose.production.yml \ - "${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml" - ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - "${VPS_USER}@${VPS_HOST}" \ - "REGISTRY_TOKEN='${REGISTRY_TOKEN}' REGISTRY_USER='${REGISTRY_USER}' REGISTRY_HOST='${REGISTRY_HOST}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE' - set -eu - echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin - docker pull "$ADMIN_IMAGE" - cd /opt/ghabilee-admin - ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin - for attempt in $(seq 1 36); do - health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)" - echo "Admin health ${attempt}/36: ${health}" - if [ "$health" = healthy ]; then - if [ -x /opt/ghabilee/scripts/docker-prune.sh ]; then - /opt/ghabilee/scripts/docker-prune.sh full - else - docker image prune -af - fi - exit 0 - fi - case "$health" in unhealthy|exited|dead|missing) exit 1;; esac - sleep 5 - done - exit 1 - REMOTE + rm -f "$HOME/.ssh/deploy_key" - name: Notify Telegram if: always() @@ -128,11 +69,9 @@ jobs: VPS_USER: ${{ secrets.VPS_USER }} run: | export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')" - # Map Gitea job.status to notify script expectations case "${DEPLOY_STATUS}" in success) export DEPLOY_STATUS=success ;; *) export DEPLOY_STATUS=failed ;; esac chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh || true ./scripts/notify-via-vps.sh || true - rm -f .env.production "$HOME/.ssh/vps_key" diff --git a/scripts/deploy-on-vps.sh b/scripts/deploy-on-vps.sh new file mode 100755 index 0000000..41acdda --- /dev/null +++ b/scripts/deploy-on-vps.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Build and run admin on the VPS (idempotent). +# Expects app sources in APP_DIR (CI rsync) and a filled APP_DIR/.env. +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/ghabilee-admin}" +SRC_DIR="${SRC_DIR:-$APP_DIR/src}" +IMAGE_TAG="${IMAGE_TAG:-ghabilee-admin:local}" + +cd "$APP_DIR" + +if [ ! -f "$APP_DIR/.env" ]; then + echo "Missing $APP_DIR/.env — copy from .env.example and fill production values." >&2 + exit 2 +fi + +if [ ! -f "$SRC_DIR/Dockerfile" ]; then + echo "Missing $SRC_DIR/Dockerfile — rsync the repo before deploy." >&2 + exit 2 +fi + +install -m 0644 "$SRC_DIR/deploy/docker-compose.production.yml" "$APP_DIR/docker-compose.yml" +if [ -f "$SRC_DIR/deploy/nginx/backoffice.conf" ]; then + install -m 0644 "$SRC_DIR/deploy/nginx/backoffice.conf" /etc/nginx/sites-available/backoffice + ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice + nginx -t + systemctl reload nginx +fi + +# shellcheck disable=SC1091 +set -a +# shellcheck disable=SC1090 +source "$APP_DIR/.env" +set +a + +env_or_empty() { + local key="$1" + printf '%s' "${!key-}" +} + +sanitize_api_proxy_target() { + local target + target="$(env_or_empty API_PROXY_TARGET)" + case "$target" in + *127.0.0.1*|*localhost*|*'::1'*) + echo "Warning: refusing loopback API_PROXY_TARGET for production image build" >&2 + printf '' + ;; + *) + printf '%s' "$target" + ;; + esac +} + +BUILD_ARGS=( + --build-arg "NEXT_PUBLIC_API_URL=$(env_or_empty NEXT_PUBLIC_API_URL)" + --build-arg "NEXT_PUBLIC_FILE_SERVER_URL=$(env_or_empty NEXT_PUBLIC_FILE_SERVER_URL)" + --build-arg "MAP_API_KEY=$(env_or_empty MAP_API_KEY)" + --build-arg "NEXT_PUBLIC_MAP_API_KEY=$(env_or_empty NEXT_PUBLIC_MAP_API_KEY)" + --build-arg "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$(env_or_empty NEXT_PUBLIC_VAPID_PUBLIC_KEY)" + --build-arg "NEXT_PUBLIC_BASE_PATH=$(env_or_empty NEXT_PUBLIC_BASE_PATH)" + --build-arg "NEXT_PUBLIC_SITE_URL=$(env_or_empty NEXT_PUBLIC_SITE_URL)" + --build-arg "NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=$(env_or_empty NEXT_PUBLIC_OBSERVABILITY_ENDPOINT)" + --build-arg "NEXT_PUBLIC_SENTRY_DSN=$(env_or_empty NEXT_PUBLIC_SENTRY_DSN)" + --build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=$(env_or_empty NEXT_PUBLIC_SENTRY_ENVIRONMENT)" + --build-arg "NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$(env_or_empty NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)" + --build-arg "NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=$(env_or_empty NEXT_PUBLIC_ARCAPTCHA_SITE_KEY)" + --build-arg "SENTRY_AUTH_TOKEN=$(env_or_empty SENTRY_AUTH_TOKEN)" + --build-arg "SENTRY_ORG=$(env_or_empty SENTRY_ORG)" + --build-arg "SENTRY_PROJECT=$(env_or_empty SENTRY_PROJECT)" + --build-arg "API_PROXY_TARGET=$(sanitize_api_proxy_target)" +) + +echo "Building ${IMAGE_TAG} on VPS (build arguments redacted)" +docker build \ + --platform linux/amd64 \ + -t "$IMAGE_TAG" \ + "${BUILD_ARGS[@]}" \ + "$SRC_DIR" + +ADMIN_IMAGE="$IMAGE_TAG" docker compose -f "$APP_DIR/docker-compose.yml" up -d --no-deps ghabilee-admin + +for attempt in $(seq 1 36); do + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)" + echo "Admin health ${attempt}/36: ${health}" + if [ "$health" = "healthy" ]; then + docker image prune -af >/dev/null 2>&1 || true + echo "Deploy OK" + exit 0 + fi + case "$health" in + unhealthy|exited|dead|missing) exit 1 ;; + esac + sleep 5 +done + +echo "Timed out waiting for healthy admin container" >&2 +exit 1