name: Deploy to Production # Deploy only after CI succeeds (needs: [ci] does NOT work across workflow files). on: workflow_run: workflows: ["CI"] types: [completed] branches: [main] workflow_dispatch: jobs: deploy: name: Build and Deploy runs-on: ubuntu-24.04 if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: - name: Resolve target commit id: target run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then SHA="${{ github.sha }}" else SHA="${{ github.event.workflow_run.head_sha }}" fi echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "Deploying commit: $SHA" - name: Setup SSH run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H 192.168.1.190 >> ~/.ssh/known_hosts - name: Update .env.docker from Gitea vars & secrets env: APP_URL: ${{ vars.APP_URL }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }} ALLOW_REGISTRATION: ${{ vars.ALLOW_REGISTRATION }} POSTGRES_USER: ${{ vars.POSTGRES_USER }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} POSTGRES_DB: ${{ vars.POSTGRES_DB }} POSTGRES_PORT: ${{ vars.POSTGRES_PORT }} AI_PROVIDER_TAGS: ${{ vars.AI_PROVIDER_TAGS }} AI_MODEL_TAGS: ${{ vars.AI_MODEL_TAGS }} AI_PROVIDER_EMBEDDING: ${{ vars.AI_PROVIDER_EMBEDDING }} AI_MODEL_EMBEDDING: ${{ vars.AI_MODEL_EMBEDDING }} AI_PROVIDER_CHAT: ${{ vars.AI_PROVIDER_CHAT }} AI_MODEL_CHAT: ${{ vars.AI_MODEL_CHAT }} CUSTOM_OPENAI_BASE_URL: ${{ vars.CUSTOM_OPENAI_BASE_URL }} CUSTOM_OPENAI_API_KEY: ${{ secrets.CUSTOM_OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OLLAMA_BASE_URL: ${{ vars.OLLAMA_BASE_URL }} EMAIL_PROVIDER: ${{ vars.EMAIL_PROVIDER }} SMTP_FROM: ${{ vars.SMTP_FROM }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} SMTP_HOST: ${{ vars.SMTP_HOST }} SMTP_PORT: ${{ vars.SMTP_PORT }} SMTP_USER: ${{ vars.SMTP_USER }} SMTP_PASS: ${{ secrets.SMTP_PASS }} SMTP_SECURE: ${{ vars.SMTP_SECURE }} SMTP_IGNORE_CERT: ${{ vars.SMTP_IGNORE_CERT }} MCP_MODE: ${{ vars.MCP_MODE }} MCP_PORT: ${{ vars.MCP_PORT }} WEB_SEARCH_PROVIDER: ${{ vars.WEB_SEARCH_PROVIDER }} SEARXNG_URL: ${{ vars.SEARXNG_URL }} BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} JINA_API_KEY: ${{ secrets.JINA_API_KEY }} run: | ssh root@192.168.1.190 bash << 'ENDSSH' ENV_FILE="/opt/memento/.env.docker" touch "$ENV_FILE" upsert() { local key="$1" val="$2" [ -z "$val" ] && return sed -i "/^[[:space:]]*${key}=/d" "$ENV_FILE" echo "${key}=\"${val}\"" >> "$ENV_FILE" } upsert NEXTAUTH_URL "$APP_URL" upsert NEXTAUTH_SECRET "$NEXTAUTH_SECRET" upsert ADMIN_EMAIL "$ADMIN_EMAIL" upsert ALLOW_REGISTRATION "$ALLOW_REGISTRATION" upsert POSTGRES_USER "$POSTGRES_USER" upsert POSTGRES_PASSWORD "$POSTGRES_PASSWORD" upsert POSTGRES_DB "$POSTGRES_DB" upsert POSTGRES_PORT "$POSTGRES_PORT" upsert AI_PROVIDER_TAGS "$AI_PROVIDER_TAGS" upsert AI_MODEL_TAGS "$AI_MODEL_TAGS" upsert AI_PROVIDER_EMBEDDING "$AI_PROVIDER_EMBEDDING" upsert AI_MODEL_EMBEDDING "$AI_MODEL_EMBEDDING" upsert AI_PROVIDER_CHAT "$AI_PROVIDER_CHAT" upsert AI_MODEL_CHAT "$AI_MODEL_CHAT" upsert CUSTOM_OPENAI_BASE_URL "$CUSTOM_OPENAI_BASE_URL" upsert CUSTOM_OPENAI_API_KEY "$CUSTOM_OPENAI_API_KEY" upsert OPENAI_API_KEY "$OPENAI_API_KEY" upsert OLLAMA_BASE_URL "$OLLAMA_BASE_URL" upsert EMAIL_PROVIDER "$EMAIL_PROVIDER" upsert SMTP_FROM "$SMTP_FROM" upsert RESEND_API_KEY "$RESEND_API_KEY" upsert SMTP_HOST "$SMTP_HOST" upsert SMTP_PORT "$SMTP_PORT" upsert SMTP_USER "$SMTP_USER" upsert SMTP_PASS "$SMTP_PASS" upsert SMTP_SECURE "$SMTP_SECURE" upsert SMTP_IGNORE_CERT "$SMTP_IGNORE_CERT" upsert MCP_MODE "$MCP_MODE" upsert MCP_PORT "$MCP_PORT" upsert WEB_SEARCH_PROVIDER "$WEB_SEARCH_PROVIDER" upsert SEARXNG_URL "$SEARXNG_URL" upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY" upsert JINA_API_KEY "$JINA_API_KEY" echo ".env.docker updated (container restart after build in deploy step)" ENDSSH - name: Tag current image as rollback run: | ssh root@192.168.1.190 << 'ENDSSH' docker tag memento-note_memento-note:latest memento-note_memento-note:rollback 2>/dev/null && echo "Rollback tag saved" || echo "No existing image to tag" ENDSSH - name: Deploy via SSH run: | ssh root@192.168.1.190 << 'ENDSSH' set -e cd /opt/memento echo "=== Git pull ===" git config --global --add safe.directory /opt/memento git fetch origin main git reset --hard origin/main echo "=== Ensure postgres is running (no recreate — prod data) ===" docker compose up -d postgres echo "=== Waiting for postgres healthy ===" for i in $(seq 1 30); do if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-memento}" >/dev/null 2>&1; then echo "Postgres healthy after $((i * 2))s" break fi if [ "$i" -eq 30 ]; then echo "ERROR: Postgres not healthy after 60s" docker compose logs postgres --tail=30 exit 1 fi sleep 2 done echo "=== Create vector extension ===" docker compose exec -T postgres psql -U "${POSTGRES_USER:-memento}" -d "${POSTGRES_DB:-memento}" -c "CREATE EXTENSION IF NOT EXISTS vector;" echo "=== Dump database before migration ===" DUMP_FILE="/opt/memento/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz" mkdir -p /opt/memento/backups docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-memento}" -d "${POSTGRES_DB:-memento}" --clean --if-exists | gzip > "$DUMP_FILE" DUMP_SIZE=$(stat -c%s "$DUMP_FILE") if [ "$DUMP_SIZE" -lt 1048576 ]; then echo "ERROR: Dump is only $DUMP_SIZE bytes (< 1MB). Aborting deploy!" exit 1 fi echo "Backup saved: $DUMP_FILE ($(( DUMP_SIZE / 1024 ))KB)" echo "=== Building app images ===" export GIT_COMMIT="$(git rev-parse HEAD)" echo "GIT_COMMIT=$GIT_COMMIT" export COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build memento-note docker compose build mcp-server echo "=== Starting app containers with new images ===" docker compose up -d --remove-orphans --force-recreate memento-note mcp-server docker compose ps echo "=== Deployed commit (git) ===" git log -1 --oneline docker inspect memento-web --format 'Image: {{.Image}} Created: {{.Created}}' echo "=== Reload reverse proxy (if nginx on host) ===" nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true ENDSSH - name: Verify deployed commit on app (port 3000) id: health-check env: EXPECTED_SHA: ${{ steps.target.outputs.sha }} run: | echo "Expected commit: $EXPECTED_SHA" for i in $(seq 1 36); do BODY=$(ssh root@192.168.1.190 "curl -sf --max-time 5 http://127.0.0.1:3000/api/build-info" 2>/dev/null || true) ACTUAL=$(echo "$BODY" | jq -r '.commit // empty' 2>/dev/null || true) echo " [$((i * 5))s] build-info commit=$ACTUAL" if [ -n "$ACTUAL" ] && [ "$ACTUAL" = "$EXPECTED_SHA" ]; then echo "Deploy verified: container serves commit $ACTUAL" echo "healthy=true" >> $GITHUB_OUTPUT exit 0 fi sleep 5 done echo "healthy=false" >> $GITHUB_OUTPUT echo "ERROR: App on :3000 does not serve expected commit." echo " expected=$EXPECTED_SHA" echo " got=$ACTUAL" ssh root@192.168.1.190 "cd /opt/memento && git log -1 --oneline && docker logs memento-web --tail=40" exit 0 - name: Rollback on failure if: steps.health-check.outputs.healthy == 'false' run: | ssh root@192.168.1.190 << 'ENDSSH' echo "=== ROLLBACK: Restoring previous image ===" docker tag memento-note_memento-note:rollback memento-note_memento-note:latest cd /opt/memento && docker compose up -d --force-recreate memento-note echo "Rollback complete" ENDSSH - name: Notify Telegram if: always() run: | HEALTHY="${{ steps.health-check.outputs.healthy }}" if [ "$HEALTHY" = "true" ]; then MSG="✅ Memento deploy SUCCESS%nCommit: ${{ steps.target.outputs.sha }}" else MSG="❌ Memento deploy FAILED%nCommit: ${{ steps.target.outputs.sha }}%nAction: rollback to previous image" fi curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \ -d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \ -d text="$(printf "$MSG")" \ -d parse_mode="HTML" || true - name: Fail if unhealthy if: steps.health-check.outputs.healthy == 'false' run: exit 1 - name: Cleanup if: always() run: | ssh root@192.168.1.190 << 'ENDSSH' docker image prune -f cd /opt/memento/backups 2>/dev/null && ls -t pre-migrate-*.sql.gz | tail -n +11 | xargs -r rm -f && echo "Old backups cleaned" || true ENDSSH