name: ci-deploy on: push: branches: [main] workflow_dispatch: # Single deploy at a time per host — pve-201's docker container name # `flights-web` is a shared mutex. Without this, back-to-back pushes # race on `docker stop / rm / run`, with the second run hitting # "container name already in use". Queue, don't cancel. concurrency: group: ci-deploy-pve-201 cancel-in-progress: false jobs: build-deploy-test: runs-on: ubuntu-latest timeout-minutes: 30 env: # MAP_TILE_URL / API_BASE_URL are intentionally NOT exported at job level — # vitest validates them via Zod and rejects relative paths. Build args are # set inline on the docker_build step instead. BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} FLIGHTS_WEB_PORT: '3002' steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Notify start if: ${{ env.TELEGRAM_BOT_TOKEN != '' }} run: scripts/ci/notify-telegram.sh start ci-deploy - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Restore pnpm cache uses: actions/cache@v4 with: path: ~/.pnpm-store key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: pnpm- - name: Install dependencies id: deps run: pnpm install --frozen-lockfile - name: Typecheck id: typecheck run: pnpm typecheck - name: Lint id: lint run: pnpm lint - name: Unit tests id: unit # tests/eslint/* are skipped in CI: typescript-eslint's project cache # doesn't see runtime-generated probe files inside the runner container, # though they pass locally. They're a dev-time eslint-config-drift guard # and re-run on `pnpm test` locally before commit. run: pnpm test -- --exclude 'tests/eslint/**' - name: CI script tests id: citest run: pnpm test:ci - name: Build SSR image id: docker_build env: # Both must be full URLs — Zod's .url() validator in src/env/index.ts # rejects relative paths at runtime in the browser. Same-origin works # because the public host is also where nginx is. MAP_TILE_URL: ${{ secrets.MAP_TILE_URL || 'https://ui-dashboard.gnerim.ru/map/api/tile/{z}/{x}/{y}.jpeg' }} API_BASE_URL: ${{ secrets.API_BASE_URL || 'https://ui-dashboard.gnerim.ru/api' }} run: | docker build -f Dockerfile.react \ --build-arg "MAP_TILE_URL=${MAP_TILE_URL}" \ --build-arg "API_BASE_URL=${API_BASE_URL}" \ -t "flights-web:${GITHUB_SHA:0:7}" \ . - name: Swap container id: swap run: scripts/ci/deploy-container.sh swap - name: Wait for health id: health env: BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} run: scripts/ci/wait-for-url.sh https://ui-dashboard.gnerim.ru/ 30 2 - name: Rollback on failure (post-deploy steps) if: failure() && (steps.swap.outcome == 'failure' || steps.health.outcome == 'failure') id: rollback run: scripts/ci/deploy-container.sh rollback - name: Capture container logs (on failure) if: failure() run: docker logs flights-web --tail 500 > container.log 2>&1 || true - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v3 with: name: ci-deploy-failure-${{ github.run_id }} path: container.log retention-days: 7 - name: Prune old images if: success() run: | docker images flights-web --format '{{.Tag}} {{.ID}}' \ | grep -vE '^(current|previous)\b' \ | tail -n +6 \ | awk '{print $2}' \ | xargs -r docker rmi 2>/dev/null || true - name: Notify (success) if: success() && env.TELEGRAM_BOT_TOKEN != '' run: scripts/ci/notify-telegram.sh ok ci-deploy - name: Notify (failure) if: failure() && env.TELEGRAM_BOT_TOKEN != '' run: scripts/ci/notify-telegram.sh fail ci-deploy "see run for details" container.log