fix: skip PhotoSize entries with null FileId when downloading product… #41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CD — Build, Push & Deploy | |
| on: | |
| push: | |
| branches: [master] | |
| tags: | |
| - 'v*.*.*' | |
| workflow_dispatch: | |
| inputs: | |
| environment: | |
| description: 'Deployment target' | |
| required: true | |
| default: staging | |
| type: choice | |
| options: [staging, production] | |
| image_tag: | |
| description: 'Image tag to deploy (leave empty to use latest build)' | |
| required: false | |
| default: '' | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: ${{ github.repository_owner }}/aqlliagronom-api | |
| DOTNET_NOLOGO: 'true' | |
| DOTNET_CLI_TELEMETRY_OPTOUT: 'true' | |
| concurrency: | |
| group: deploy-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| # ── 1. Build & Push Docker Image ───────────────────────────────────────────── | |
| build-and-push: | |
| name: Build & Push Docker Image | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| outputs: | |
| image_version: ${{ steps.meta.outputs.version }} | |
| image_tags: ${{ steps.meta.outputs.tags }} | |
| image_name: ${{ steps.lower.outputs.name }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Compute lowercase image name | |
| id: lower | |
| run: | | |
| echo "name=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/aqlliagronom-api" >> "$GITHUB_OUTPUT" | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Extract Docker metadata | |
| id: meta | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: ${{ env.REGISTRY }}/${{ steps.lower.outputs.name }} | |
| tags: | | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=sha,prefix=sha-,format=short | |
| type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} | |
| type=raw,value=edge,enable=${{ github.ref == 'refs/heads/master' }} | |
| labels: | | |
| org.opencontainers.image.title=AqlliAgronom API | |
| org.opencontainers.image.description=AI-powered smart agriculture assistant backend | |
| org.opencontainers.image.vendor=AqlliAgronom | |
| - name: Build and push Docker image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| platforms: linux/amd64,linux/arm64 | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max,ignore-error=true | |
| build-args: | | |
| BUILD_VERSION=${{ steps.meta.outputs.version }} | |
| BUILD_REVISION=${{ github.sha }} | |
| # ── 2. Deploy to Staging ────────────────────────────────────────────────────── | |
| deploy-staging: | |
| name: Deploy to Staging | |
| runs-on: ubuntu-latest | |
| needs: build-and-push | |
| environment: | |
| name: staging | |
| url: https://agronom.fastergo.uz | |
| if: >- | |
| github.ref == 'refs/heads/master' || | |
| startsWith(github.ref, 'refs/tags/v') || | |
| (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging') | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Resolve image tag | |
| id: resolve | |
| run: | | |
| MANUAL_TAG="${{ github.event.inputs.image_tag }}" | |
| if [ -n "$MANUAL_TAG" ]; then | |
| echo "tag=${MANUAL_TAG}" >> "$GITHUB_OUTPUT" | |
| elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then | |
| echo "tag=${{ needs.build-and-push.outputs.image_version }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "tag=edge" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Configure SSH for staging | |
| env: | |
| SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} | |
| SSH_HOST: ${{ secrets.STAGING_HOST }} | |
| run: | | |
| mkdir -p ~/.ssh && chmod 700 ~/.ssh | |
| # tr -d '[:space:]' strips whitespace/newlines that break base64 decoding. | |
| # Fallback (||) handles plain-text PEM keys stored without base64 encoding. | |
| echo "${SSH_KEY}" | tr -d '[:space:]' | base64 -d > ~/.ssh/deploy_key 2>/dev/null \ | |
| || printf '%s\n' "${SSH_KEY}" | tr -d '\r' > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| echo "--- Key fingerprint ---" | |
| ssh-keygen -l -f ~/.ssh/deploy_key | |
| echo "-----------------------" | |
| ssh-keyscan -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null | |
| echo "SSH configured for ${SSH_HOST}" | |
| - name: Copy nginx config to staging server | |
| env: | |
| SSH_HOST: ${{ secrets.STAGING_HOST }} | |
| SSH_USER: ${{ secrets.STAGING_USER }} | |
| run: | | |
| ssh -i ~/.ssh/deploy_key "${SSH_USER}@${SSH_HOST}" \ | |
| mkdir -p /opt/aqlliagronom/staging/nginx | |
| scp -i ~/.ssh/deploy_key \ | |
| nginx/conf.d/staging.conf \ | |
| "${SSH_USER}@${SSH_HOST}:/opt/aqlliagronom/staging/nginx/app.conf" | |
| - name: Deploy to staging via SSH | |
| env: | |
| SSH_HOST: ${{ secrets.STAGING_HOST }} | |
| SSH_USER: ${{ secrets.STAGING_USER }} | |
| IMAGE_FULL: ${{ env.REGISTRY }}/${{ needs.build-and-push.outputs.image_name }}:${{ steps.resolve.outputs.tag }} | |
| GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GHCR_USER: ${{ github.actor }} | |
| DB_CONN: ${{ secrets.STAGING_DB_CONNECTION }} | |
| REDIS_CONN: ${{ secrets.STAGING_REDIS_CONNECTION }} | |
| JWT_SECRET: ${{ secrets.JWT_SECRET_KEY }} | |
| CLAUDE_KEY: ${{ secrets.CLAUDE_API_KEY }} | |
| VOYAGE_KEY: ${{ secrets.VOYAGE_API_KEY }} | |
| TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TG_WEBHOOK: ${{ secrets.STAGING_WEBHOOK_URL }} | |
| QDRANT_KEY: ${{ secrets.QDRANT_API_KEY }} | |
| CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }} | |
| run: | | |
| ssh -i ~/.ssh/deploy_key \ | |
| -o ConnectTimeout=30 \ | |
| -o ServerAliveInterval=15 \ | |
| "${SSH_USER}@${SSH_HOST}" bash << ENDSSH | |
| set -euo pipefail | |
| IMAGE_FULL="${IMAGE_FULL}" | |
| GHCR_TOKEN="${GHCR_TOKEN}" | |
| GHCR_USER="${GHCR_USER}" | |
| DB_CONN="${DB_CONN}" | |
| REDIS_CONN="${REDIS_CONN}" | |
| JWT_SECRET="${JWT_SECRET}" | |
| CLAUDE_KEY="${CLAUDE_KEY}" | |
| VOYAGE_KEY="${VOYAGE_KEY}" | |
| TG_TOKEN="${TG_TOKEN}" | |
| TG_WEBHOOK="${TG_WEBHOOK}" | |
| QDRANT_KEY="${QDRANT_KEY}" | |
| CERTBOT_EMAIL="${CERTBOT_EMAIL}" | |
| # ── Fixed container names (always used on this host) ───────────────── | |
| PG_CONTAINER="postgres" | |
| REDIS_CONTAINER="redis" | |
| QDRANT_CONTAINER="qdrant" | |
| # ── Parse credentials from secrets ─────────────────────────────────── | |
| get_kv() { echo "\$1" | tr ';' '\n' | grep -i "^\$2=" | cut -d= -f2-; } | |
| PG_PORT=\$(get_kv "\${DB_CONN}" "Port"); PG_PORT=\${PG_PORT:-5432} | |
| PG_DB=\$(get_kv "\${DB_CONN}" "Database") | |
| PG_USER=\$(get_kv "\${DB_CONN}" "Username") | |
| PG_PASS=\$(get_kv "\${DB_CONN}" "Password") | |
| REDIS_PORT=\$(echo "\${REDIS_CONN}" | cut -d: -f2 | cut -d, -f1); REDIS_PORT=\${REDIS_PORT:-6379} | |
| REDIS_PASS=\$(echo "\${REDIS_CONN}" | grep -oP '(?<=password=)[^,]+' || echo "") | |
| # Rewrite host in connection strings to Docker container names so the | |
| # API container resolves them via the Docker network (not "localhost"). | |
| DB_CONN_DOCKER=\$(echo "\${DB_CONN}" | sed "s/Host=[^;]*/Host=\${PG_CONTAINER}/I") | |
| REDIS_CONN_DOCKER="\${REDIS_CONTAINER}:\${REDIS_PORT}\$(echo "\${REDIS_CONN}" | grep -oP ',.*' || echo '')" | |
| echo "==> PG container: \${PG_CONTAINER}, DB: \${PG_DB}, User: \${PG_USER}" | |
| echo "==> Redis container: \${REDIS_CONTAINER}:\${REDIS_PORT}" | |
| echo "==> Network & directories..." | |
| docker network create aqlliagronom-network 2>/dev/null || true | |
| mkdir -p /opt/aqlliagronom/staging/logs | |
| docker volume create postgres_data 2>/dev/null || true | |
| docker volume create redis_data 2>/dev/null || true | |
| docker volume create qdrant_data 2>/dev/null || true | |
| # ── PostgreSQL ─────────────────────────────────────────────────────── | |
| echo "==> Ensuring PostgreSQL is running..." | |
| if ! docker ps --format '{{.Names}}' | grep -q "^\${PG_CONTAINER}\$"; then | |
| docker start "\${PG_CONTAINER}" 2>/dev/null || \ | |
| docker run -d \ | |
| --name "\${PG_CONTAINER}" \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| -e POSTGRES_DB="\${PG_DB}" \ | |
| -e POSTGRES_USER="\${PG_USER}" \ | |
| -e POSTGRES_PASSWORD="\${PG_PASS}" \ | |
| -v postgres_data:/var/lib/postgresql/data \ | |
| postgres:16-alpine | |
| fi | |
| echo "Waiting for PostgreSQL..." | |
| for i in \$(seq 1 12); do | |
| docker exec "\${PG_CONTAINER}" pg_isready -U "\${PG_USER}" > /dev/null 2>&1 && break | |
| [ "\$i" -eq 12 ] && echo "ERROR: PostgreSQL not ready" && exit 1 | |
| sleep 5 | |
| done | |
| echo "PostgreSQL ready." | |
| # ── Redis ──────────────────────────────────────────────────────────── | |
| echo "==> Ensuring Redis is running..." | |
| if ! docker ps --format '{{.Names}}' | grep -q "^\${REDIS_CONTAINER}\$"; then | |
| docker start "\${REDIS_CONTAINER}" 2>/dev/null || \ | |
| docker run -d \ | |
| --name "\${REDIS_CONTAINER}" \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| redis:7-alpine \ | |
| sh -c "redis-server --appendonly yes \$([ -n \"\${REDIS_PASS}\" ] && echo \"--requirepass \${REDIS_PASS}\")" | |
| fi | |
| echo "Redis ready." | |
| # ── Qdrant ─────────────────────────────────────────────────────────── | |
| echo "==> Ensuring Qdrant is running..." | |
| if ! docker ps --format '{{.Names}}' | grep -q "^\${QDRANT_CONTAINER}\$"; then | |
| docker start "\${QDRANT_CONTAINER}" 2>/dev/null || \ | |
| docker run -d \ | |
| --name "\${QDRANT_CONTAINER}" \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| -v qdrant_data:/qdrant/storage \ | |
| qdrant/qdrant:v1.13.0 | |
| fi | |
| sleep 3 | |
| echo "Qdrant ready." | |
| # ── API ────────────────────────────────────────────────────────────── | |
| echo "==> Logging in to GHCR..." | |
| echo "\${GHCR_TOKEN}" | docker login ghcr.io -u "\${GHCR_USER}" --password-stdin | |
| echo "==> Pulling image: \${IMAGE_FULL}" | |
| docker pull "\${IMAGE_FULL}" | |
| echo "==> Stopping old API container..." | |
| docker stop aqlliagronom-staging 2>/dev/null || true | |
| docker rm aqlliagronom-staging 2>/dev/null || true | |
| echo "==> Starting API container..." | |
| mkdir -p /opt/aqlliagronom/staging/uploads | |
| docker volume create staging_uploads_data 2>/dev/null || true | |
| docker run -d \ | |
| --name aqlliagronom-staging \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| -p 8080:8080 \ | |
| -e ASPNETCORE_ENVIRONMENT=Staging \ | |
| -e ASPNETCORE_URLS=http://+:8080 \ | |
| -e "ConnectionStrings__PostgreSQL=\${DB_CONN_DOCKER}" \ | |
| -e "Redis__ConnectionString=\${REDIS_CONN_DOCKER}" \ | |
| -e "Jwt__SecretKey=\${JWT_SECRET}" \ | |
| -e "Claude__ApiKey=\${CLAUDE_KEY}" \ | |
| -e "Embedding__ApiKey=\${VOYAGE_KEY}" \ | |
| -e "Telegram__BotToken=\${TG_TOKEN}" \ | |
| -e "Telegram__WebhookUrl=\${TG_WEBHOOK}" \ | |
| -e "Telegram__UseWebhook=true" \ | |
| -e "Qdrant__Host=\${QDRANT_CONTAINER}" \ | |
| -e "Qdrant__ApiKey=\${QDRANT_KEY}" \ | |
| -e "FileStorage__BasePath=/app/uploads" \ | |
| -e "FileStorage__BaseUrl=https://agronom.fastergo.uz/uploads" \ | |
| -v /opt/aqlliagronom/staging/logs:/app/logs \ | |
| -v staging_uploads_data:/app/uploads \ | |
| "\${IMAGE_FULL}" | |
| echo "==> Waiting for health check (up to 120s)..." | |
| for i in \$(seq 1 24); do | |
| if curl -sf http://localhost:8080/health/live > /dev/null 2>&1; then | |
| echo "==> Healthy after attempt \${i}. Deploy complete!" | |
| break | |
| fi | |
| if [ "\$i" -eq 24 ]; then | |
| echo "ERROR: Health check failed after 120s. Container logs:" | |
| docker logs --tail 200 aqlliagronom-staging 2>&1 || true | |
| docker stop aqlliagronom-staging 2>/dev/null || true | |
| docker rm aqlliagronom-staging 2>/dev/null || true | |
| exit 1 | |
| fi | |
| echo " Attempt \${i}/24 — waiting 5s..." | |
| sleep 5 | |
| done | |
| # ── Nginx + Certbot (SSL) ───────────────────────────────────────────── | |
| NGINX_DOMAIN="agronom.fastergo.uz" | |
| NGINX_CONF_DIR="/opt/aqlliagronom/staging/nginx" | |
| docker volume create certbot_staging_certs 2>/dev/null || true | |
| docker volume create certbot_staging_www 2>/dev/null || true | |
| # Generate temporary self-signed cert on first deploy so nginx can start | |
| if ! docker run --rm -v certbot_staging_certs:/etc/letsencrypt alpine \ | |
| test -f "/etc/letsencrypt/live/\${NGINX_DOMAIN}/fullchain.pem" 2>/dev/null; then | |
| echo "==> Generating temporary self-signed cert for \${NGINX_DOMAIN}..." | |
| docker run --rm -v certbot_staging_certs:/etc/letsencrypt alpine sh -c \ | |
| "apk add --no-cache openssl >/dev/null 2>&1 && \ | |
| mkdir -p /etc/letsencrypt/live/\${NGINX_DOMAIN} && \ | |
| openssl req -x509 -nodes -newkey rsa:2048 \ | |
| -keyout /etc/letsencrypt/live/\${NGINX_DOMAIN}/privkey.pem \ | |
| -out /etc/letsencrypt/live/\${NGINX_DOMAIN}/fullchain.pem \ | |
| -days 1 -subj /CN=\${NGINX_DOMAIN} 2>/dev/null" | |
| fi | |
| if docker ps --format '{{.Names}}' | grep -q '^aqlliagronom-nginx\$'; then | |
| echo "==> Reloading nginx config..." | |
| docker exec aqlliagronom-nginx nginx -s reload || true | |
| else | |
| docker stop aqlliagronom-nginx 2>/dev/null || true | |
| docker rm aqlliagronom-nginx 2>/dev/null || true | |
| echo "==> Starting nginx..." | |
| docker run -d \ | |
| --name aqlliagronom-nginx \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| -p 80:80 -p 443:443 \ | |
| -v "\${NGINX_CONF_DIR}:/etc/nginx/conf.d:ro" \ | |
| -v certbot_staging_certs:/etc/letsencrypt:ro \ | |
| -v certbot_staging_www:/var/www/certbot:ro \ | |
| -v staging_uploads_data:/app/uploads:ro \ | |
| nginx:1.28-alpine | |
| fi | |
| if [ -n "\${CERTBOT_EMAIL}" ]; then | |
| echo "==> Running certbot for \${NGINX_DOMAIN}..." | |
| docker run --rm \ | |
| -v certbot_staging_certs:/etc/letsencrypt \ | |
| -v certbot_staging_www:/var/www/certbot \ | |
| certbot/certbot certonly \ | |
| --webroot -w /var/www/certbot \ | |
| -d "\${NGINX_DOMAIN}" \ | |
| --non-interactive --agree-tos \ | |
| -m "\${CERTBOT_EMAIL}" \ | |
| --keep-until-expiring 2>&1 | tail -5 || true | |
| # Certbot creates <domain>-0001 when a self-signed cert already occupies | |
| # <domain>/. Copy the real cert files over the self-signed placeholder so | |
| # nginx always finds the cert at the path in its config. | |
| docker run --rm -v certbot_staging_certs:/etc/letsencrypt alpine sh -c \ | |
| "if [ -d /etc/letsencrypt/live/\${NGINX_DOMAIN}-0001 ]; then | |
| cp -L /etc/letsencrypt/live/\${NGINX_DOMAIN}-0001/fullchain.pem \ | |
| /etc/letsencrypt/live/\${NGINX_DOMAIN}/fullchain.pem | |
| cp -L /etc/letsencrypt/live/\${NGINX_DOMAIN}-0001/privkey.pem \ | |
| /etc/letsencrypt/live/\${NGINX_DOMAIN}/privkey.pem | |
| echo 'Real cert copied from -0001 to \${NGINX_DOMAIN}' | |
| fi" 2>/dev/null || true | |
| docker exec aqlliagronom-nginx nginx -s reload 2>/dev/null || true | |
| fi | |
| echo "==> https://\${NGINX_DOMAIN} ready" | |
| docker image prune -f || true | |
| ENDSSH | |
| # ── 3. Smoke Test Staging ───────────────────────────────────────────────────── | |
| smoke-test-staging: | |
| name: Smoke Test — Staging | |
| runs-on: ubuntu-latest | |
| needs: deploy-staging | |
| steps: | |
| - name: Health check with retries | |
| run: | | |
| BASE="https://agronom.fastergo.uz" | |
| for i in $(seq 1 6); do | |
| STATUS=$(curl -sk -o /dev/null -w "%{http_code}" --connect-timeout 10 "${BASE}/health/live") | |
| if [ "$STATUS" = "200" ]; then | |
| echo "Health check passed (HTTP $STATUS)" | |
| break | |
| fi | |
| if [ "$i" -eq 6 ]; then | |
| echo "ERROR: Health check returned $STATUS after 6 attempts" | |
| exit 1 | |
| fi | |
| echo "Attempt $i/6 → HTTP $STATUS, retrying in 10s..." | |
| sleep 10 | |
| done | |
| - name: API smoke test | |
| run: | | |
| BASE="https://agronom.fastergo.uz" | |
| STATUS=$(curl -sk -o /dev/null -w "%{http_code}" --connect-timeout 10 \ | |
| -X POST "${BASE}/api/v1/auth/register" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"fullName":"","phone":"","password":""}') | |
| echo "Auth endpoint → HTTP $STATUS" | |
| [ "$STATUS" = "422" ] || { echo "Expected 422, got $STATUS"; exit 1; } | |
| echo "All smoke tests passed!" | |
| # ── 4. Deploy to Production (Blue-Green) ───────────────────────────────────── | |
| deploy-production: | |
| name: Deploy to Production | |
| runs-on: ubuntu-latest | |
| needs: [build-and-push, smoke-test-staging] | |
| environment: | |
| name: production | |
| url: https://api.aqlliagronom.uz | |
| if: >- | |
| startsWith(github.ref, 'refs/tags/v') || | |
| (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') | |
| steps: | |
| - name: Resolve production image tag | |
| id: resolve | |
| run: | | |
| MANUAL_TAG="${{ github.event.inputs.image_tag }}" | |
| if [ -n "$MANUAL_TAG" ]; then | |
| echo "tag=${MANUAL_TAG}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "tag=${{ needs.build-and-push.outputs.image_version }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Configure SSH for production | |
| env: | |
| SSH_KEY: ${{ secrets.PROD_SSH_KEY }} | |
| SSH_HOST: ${{ secrets.PROD_HOST }} | |
| run: | | |
| mkdir -p ~/.ssh && chmod 700 ~/.ssh | |
| echo "${SSH_KEY}" | tr -d '[:space:]' | base64 -d > ~/.ssh/deploy_key 2>/dev/null \ | |
| || printf '%s\n' "${SSH_KEY}" | tr -d '\r' > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| echo "--- Key fingerprint ---" | |
| ssh-keygen -l -f ~/.ssh/deploy_key | |
| echo "-----------------------" | |
| ssh-keyscan -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null | |
| echo "SSH configured for ${SSH_HOST}" | |
| - name: Deploy to production via SSH (Blue-Green) | |
| env: | |
| SSH_HOST: ${{ secrets.PROD_HOST }} | |
| SSH_USER: ${{ secrets.PROD_USER }} | |
| IMAGE_TAG: ${{ steps.resolve.outputs.tag }} | |
| FULL_IMAGE: ${{ env.REGISTRY }}/${{ needs.build-and-push.outputs.image_name }}:${{ steps.resolve.outputs.tag }} | |
| GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GHCR_USER: ${{ github.actor }} | |
| DB_CONN: ${{ secrets.PROD_DB_CONNECTION }} | |
| REDIS_CONN: ${{ secrets.PROD_REDIS_CONNECTION }} | |
| JWT_SECRET: ${{ secrets.JWT_SECRET_KEY }} | |
| CLAUDE_KEY: ${{ secrets.CLAUDE_API_KEY }} | |
| VOYAGE_KEY: ${{ secrets.VOYAGE_API_KEY }} | |
| TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TG_WEBHOOK: ${{ secrets.PROD_WEBHOOK_URL }} | |
| QDRANT_KEY: ${{ secrets.QDRANT_API_KEY }} | |
| run: | | |
| ssh -i ~/.ssh/deploy_key \ | |
| -o ConnectTimeout=30 \ | |
| -o ServerAliveInterval=15 \ | |
| "${SSH_USER}@${SSH_HOST}" bash << ENDSSH | |
| set -euo pipefail | |
| FULL_IMAGE="${FULL_IMAGE}" | |
| IMAGE_TAG="${IMAGE_TAG}" | |
| GHCR_TOKEN="${GHCR_TOKEN}" | |
| GHCR_USER="${GHCR_USER}" | |
| DB_CONN="${DB_CONN}" | |
| REDIS_CONN="${REDIS_CONN}" | |
| JWT_SECRET="${JWT_SECRET}" | |
| CLAUDE_KEY="${CLAUDE_KEY}" | |
| VOYAGE_KEY="${VOYAGE_KEY}" | |
| TG_TOKEN="${TG_TOKEN}" | |
| TG_WEBHOOK="${TG_WEBHOOK}" | |
| QDRANT_KEY="${QDRANT_KEY}" | |
| LIVE_CONTAINER="aqlliagronom-api" | |
| GREEN_CONTAINER="aqlliagronom-api-green" | |
| PG_CONTAINER="postgres" | |
| REDIS_CONTAINER="redis" | |
| QDRANT_CONTAINER="qdrant" | |
| # Rewrite host in connection strings to Docker container names | |
| get_kv() { echo "\$1" | tr ';' '\n' | grep -i "^\$2=" | cut -d= -f2-; } | |
| REDIS_PORT=\$(echo "\${REDIS_CONN}" | cut -d: -f2 | cut -d, -f1); REDIS_PORT=\${REDIS_PORT:-6379} | |
| DB_CONN_DOCKER=\$(echo "\${DB_CONN}" | sed "s/Host=[^;]*/Host=\${PG_CONTAINER}/I") | |
| REDIS_CONN_DOCKER="\${REDIS_CONTAINER}:\${REDIS_PORT}\$(echo "\${REDIS_CONN}" | grep -oP ',.*' || echo '')" | |
| echo "==> Logging in to GHCR..." | |
| echo "\${GHCR_TOKEN}" | docker login ghcr.io -u "\${GHCR_USER}" --password-stdin | |
| echo "==> Pulling image: \${FULL_IMAGE}" | |
| docker pull "\${FULL_IMAGE}" | |
| echo "==> Ensuring network and log dir exist..." | |
| docker network create aqlliagronom-network 2>/dev/null || true | |
| mkdir -p /opt/aqlliagronom/prod/logs | |
| echo "==> Starting Green container on port 8081..." | |
| docker stop "\${GREEN_CONTAINER}" 2>/dev/null || true | |
| docker rm "\${GREEN_CONTAINER}" 2>/dev/null || true | |
| docker run -d \ | |
| --name "\${GREEN_CONTAINER}" \ | |
| --network aqlliagronom-network \ | |
| -p 8081:8080 \ | |
| -e ASPNETCORE_ENVIRONMENT=Production \ | |
| -e ASPNETCORE_URLS=http://+:8080 \ | |
| -e "ConnectionStrings__PostgreSQL=\${DB_CONN_DOCKER}" \ | |
| -e "Redis__ConnectionString=\${REDIS_CONN_DOCKER}" \ | |
| -e "Jwt__SecretKey=\${JWT_SECRET}" \ | |
| -e "Claude__ApiKey=\${CLAUDE_KEY}" \ | |
| -e "Embedding__ApiKey=\${VOYAGE_KEY}" \ | |
| -e "Telegram__BotToken=\${TG_TOKEN}" \ | |
| -e "Telegram__WebhookUrl=\${TG_WEBHOOK}" \ | |
| -e "Telegram__UseWebhook=true" \ | |
| -e "Qdrant__Host=\${QDRANT_CONTAINER}" \ | |
| -e "Qdrant__ApiKey=\${QDRANT_KEY}" \ | |
| -v /opt/aqlliagronom/prod/logs:/app/logs \ | |
| "\${FULL_IMAGE}" | |
| echo "==> Validating Green (up to 120s)..." | |
| GREEN_OK=false | |
| for i in \$(seq 1 24); do | |
| if curl -sf http://localhost:8081/health/live > /dev/null 2>&1; then | |
| echo "==> Green healthy on attempt \${i}" | |
| GREEN_OK=true | |
| break | |
| fi | |
| echo " Attempt \${i}/24 — waiting 5s..." | |
| sleep 5 | |
| done | |
| if [ "\${GREEN_OK}" = "false" ]; then | |
| echo "ERROR: Green failed — Blue container untouched" | |
| docker stop "\${GREEN_CONTAINER}" 2>/dev/null || true | |
| docker rm "\${GREEN_CONTAINER}" 2>/dev/null || true | |
| exit 1 | |
| fi | |
| echo "==> Switching: stop Green temp, stop Blue, start new on :8080..." | |
| docker stop "\${GREEN_CONTAINER}" && docker rm "\${GREEN_CONTAINER}" | |
| docker stop "\${LIVE_CONTAINER}" 2>/dev/null || true | |
| docker rm "\${LIVE_CONTAINER}" 2>/dev/null || true | |
| docker run -d \ | |
| --name "\${LIVE_CONTAINER}" \ | |
| --restart unless-stopped \ | |
| --network aqlliagronom-network \ | |
| -p 8080:8080 \ | |
| -e ASPNETCORE_ENVIRONMENT=Production \ | |
| -e ASPNETCORE_URLS=http://+:8080 \ | |
| -e "ConnectionStrings__PostgreSQL=\${DB_CONN_DOCKER}" \ | |
| -e "Redis__ConnectionString=\${REDIS_CONN_DOCKER}" \ | |
| -e "Jwt__SecretKey=\${JWT_SECRET}" \ | |
| -e "Claude__ApiKey=\${CLAUDE_KEY}" \ | |
| -e "Embedding__ApiKey=\${VOYAGE_KEY}" \ | |
| -e "Telegram__BotToken=\${TG_TOKEN}" \ | |
| -e "Telegram__WebhookUrl=\${TG_WEBHOOK}" \ | |
| -e "Telegram__UseWebhook=true" \ | |
| -e "Qdrant__Host=\${QDRANT_CONTAINER}" \ | |
| -e "Qdrant__ApiKey=\${QDRANT_KEY}" \ | |
| -v /opt/aqlliagronom/prod/logs:/app/logs \ | |
| "\${FULL_IMAGE}" | |
| echo "==> Final health check on port 8080..." | |
| for i in \$(seq 1 12); do | |
| if curl -sf http://localhost:8080/health/live > /dev/null 2>&1; then | |
| echo "==> Production deploy complete! Version: \${IMAGE_TAG}" | |
| break | |
| fi | |
| if [ "\$i" -eq 12 ]; then | |
| echo "ERROR: Final health check failed" | |
| exit 1 | |
| fi | |
| echo " Attempt \${i}/12 — waiting 5s..." | |
| sleep 5 | |
| done | |
| docker image prune -f || true | |
| ENDSSH | |
| - name: Notify success | |
| if: success() | |
| uses: appleboy/[email protected] | |
| with: | |
| to: ${{ secrets.TELEGRAM_DEPLOY_CHAT_ID }} | |
| token: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| format: markdown | |
| message: | | |
| ✅ *AqlliAgronom* — Production Deploy SUCCESS | |
| Version: `${{ needs.build-and-push.outputs.image_version }}` | |
| By: `${{ github.actor }}` | |
| - name: Notify failure | |
| if: failure() | |
| uses: appleboy/[email protected] | |
| with: | |
| to: ${{ secrets.TELEGRAM_DEPLOY_CHAT_ID }} | |
| token: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| format: markdown | |
| message: | | |
| ❌ *AqlliAgronom* — Production Deploy FAILED | |
| By: `${{ github.actor }}` | |
| Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |