name: Deploy admin to VPS on: push: branches: [main] workflow_dispatch: concurrency: group: deploy-admin-production cancel-in-progress: true permissions: contents: read packages: write # PR merges: build + deploy only (quality ran on pull_request). # Direct pushes to main: re-run quality before deploy. jobs: gate: name: Detect direct push to main runs-on: ubuntu-latest outputs: run_quality: ${{ steps.detect.outputs.run_quality }} steps: - id: detect env: # Via env — never interpolate commit text into the script body # (backticks/`$()` in messages would otherwise become shell command substitution). EVENT_NAME: ${{ github.event_name }} COMMIT_MSG: ${{ github.event.head_commit.message || '' }} run: | set -euo pipefail if [ "$EVENT_NAME" = "workflow_dispatch" ]; then echo "run_quality=false" >> "$GITHUB_OUTPUT" exit 0 fi msg="$COMMIT_MSG" if printf '%s' "$msg" | grep -qiE 'merge pull request #[0-9]+'; then echo "run_quality=false" >> "$GITHUB_OUTPUT" elif printf '%s' "$msg" | grep -qE '\(#[0-9]+\)[[:space:]]*$'; then echo "run_quality=false" >> "$GITHUB_OUTPUT" else # Admin boot: deploy first; quality runs on pull_request workflow. echo "Direct push to main — skipping quality gate for deploy." echo "run_quality=false" >> "$GITHUB_OUTPUT" fi quality: needs: gate if: needs.gate.outputs.run_quality == 'true' uses: ./.github/workflows/frontend-quality.yml permissions: contents: read pull-requests: read build: name: Build and push admin image needs: [gate, quality] if: >- always() && needs.gate.result == 'success' && (needs.quality.result == 'success' || needs.quality.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 45 outputs: image: ${{ steps.meta.outputs.image }} 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=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT" - name: Fetch production build environment env: SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} run: | set -euo pipefail 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 rm -f "$HOME/.ssh/vps_key" - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - 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: Remove production build environment if: always() run: rm -f .env.production deploy: name: Stage or deploy admin needs: build if: ${{ !cancelled() && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 25 permissions: contents: read packages: read steps: - uses: actions/checkout@v4 - name: Copy Compose definition and deploy env: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} GHCR_USER: ${{ github.actor }} ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} SSH_KEY: ${{ secrets.VPS_SSH_KEY }} run: | set -euo pipefail install -m 700 -d "$HOME/.ssh" printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key" chmod 600 "$HOME/.ssh/vps_key" ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ "${VPS_USER}@${VPS_HOST}" ' set -eu install -d -m 0750 /opt/ghabilee-admin test -s /opt/ghabilee-admin/.env ' 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}" \ "GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE' set -eu echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_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 # فقط بعد از healthy: ایمیج‌های unused (تگ‌های قبلی) را پاک کن؛ volumeها دست نخورند 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/vps_key" notify-success: name: Notify Telegram (success) needs: deploy # `quality` is intentionally skipped after PR merges. `success()` treats # that skipped upstream job as non-success and would skip this job too. if: ${{ always() && needs.deploy.result == 'success' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Notify ops group of successful admin deploy env: DEPLOY_SHA: ${{ github.sha }} DEPLOY_STATUS: success DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} run: | export DEPLOY_VERSION="$(node -p "require('./package.json').version")" chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh ./scripts/notify-via-vps.sh notify-failed: name: Notify Telegram (failed) needs: [gate, quality, build, deploy] if: failure() runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Notify ops group of failed admin deploy env: DEPLOY_SHA: ${{ github.sha }} DEPLOY_STATUS: failed DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} run: | # Do not gate on -x: notify-via-vps.sh may be 100644 in git; chmod first # (the old `if [[ -x ... ]]` skipped the whole notify and still exited 0). export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')" chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh ./scripts/notify-via-vps.sh