name: Deploy Production on: push: branches: - master workflow_dispatch: concurrency: group: production cancel-in-progress: false permissions: contents: read packages: write env: REGISTRY: ghcr.io NEXT_PUBLIC_API_URL: /api/v1 NEXT_API_PROXY_TARGET: http://server:3001 jobs: build-push-deploy: name: Build, push, deploy, verify runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Prepare image metadata id: meta shell: bash run: | repo_lc="${GITHUB_REPOSITORY,,}" build_time="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" echo "repo_lc=${repo_lc}" >> "$GITHUB_OUTPUT" echo "build_time=${build_time}" >> "$GITHUB_OUTPUT" echo "web_image=${REGISTRY}/${repo_lc}/web:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" echo "server_image=${REGISTRY}/${repo_lc}/server:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" echo "web_image_master=${REGISTRY}/${repo_lc}/web:master" >> "$GITHUB_OUTPUT" echo "server_image_master=${REGISTRY}/${repo_lc}/server:master" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push web image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile.web push: true tags: | ${{ steps.meta.outputs.web_image }} ${{ steps.meta.outputs.web_image_master }} build-args: | NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }} NEXT_API_PROXY_TARGET=${{ env.NEXT_API_PROXY_TARGET }} APP_VERSION=${{ github.sha }} APP_BUILD_TIME=${{ steps.meta.outputs.build_time }} APP_IMAGE_TAG=${{ steps.meta.outputs.web_image }} - name: Build and push server image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile.server push: true tags: | ${{ steps.meta.outputs.server_image }} ${{ steps.meta.outputs.server_image_master }} build-args: | APP_VERSION=${{ github.sha }} APP_BUILD_TIME=${{ steps.meta.outputs.build_time }} APP_IMAGE_TAG=${{ steps.meta.outputs.server_image }} - name: Deploy over SSH uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.PROD_HOST }} username: ${{ secrets.PROD_USER }} key: ${{ secrets.PROD_SSH_KEY }} script_stop: true script: | set -euo pipefail cd "${{ secrets.PROD_APP_DIR }}" test -f .env.production git fetch origin master git checkout master git pull --ff-only origin master set_env() { key="$1" value="$2" if grep -q "^${key}=" .env.production; then sed -i "s|^${key}=.*|${key}=${value}|" .env.production else printf "\n%s=%s\n" "${key}" "${value}" >> .env.production fi } set_env APP_VERSION "${{ github.sha }}" set_env APP_BUILD_TIME "${{ steps.meta.outputs.build_time }}" set_env WEB_IMAGE "${{ steps.meta.outputs.web_image }}" set_env SERVER_IMAGE "${{ steps.meta.outputs.server_image }}" if [ -n "${{ secrets.GHCR_READ_TOKEN }}" ]; then echo "${{ secrets.GHCR_READ_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin fi docker compose --env-file .env.production -f docker-compose.prod.yml pull web server docker compose --env-file .env.production -f docker-compose.prod.yml up -d postgres redis docker compose --env-file .env.production -f docker-compose.prod.yml run --rm -T server pnpm --filter server db:deploy docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans for attempt in $(seq 1 30); do if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e " const expected = process.argv[1]; fetch('http://nginx/api/v1/health/version') .then(async (response) => { if (!response.ok) throw new Error('HTTP ' + response.status); const payload = await response.json(); if (payload.version !== expected) { throw new Error('Expected ' + expected + ', got ' + payload.version); } console.log('Runtime version verified: ' + payload.version); }) .catch((error) => { console.error(error.message); process.exit(1); }); " "${{ github.sha }}"; then exit 0 fi sleep 2 done echo "Runtime version check failed after retries" exit 1