Skip to content

fix: skip PhotoSize entries with null FileId when downloading product… #41

fix: skip PhotoSize entries with null FileId when downloading product…

fix: skip PhotoSize entries with null FileId when downloading product… #41

Workflow file for this run

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 }}