Skip to content

fix(prompts): v1.71.2 framing-aware assembly + trigger_pool expansion #454

fix(prompts): v1.71.2 framing-aware assembly + trigger_pool expansion

fix(prompts): v1.71.2 framing-aware assembly + trigger_pool expansion #454

Workflow file for this run

name: CI / CD
on:
push:
branches: [main]
pull_request:
branches: [main]
# Manual re-run hook. v1.24.0 briefly used a ``run_paid_smoke`` input
# to gate paid FAL/OpenRouter probes inside this workflow — removed in
# v1.24.1 when the whole post-deploy smoke block was extracted to the
# dedicated ``diag-*.yml`` / ``smoke-live.yml`` workflows (see the
# comment in ``deploy-backend`` for why).
workflow_dispatch:
# Do not cancel main-branch deploys mid-flight (Railway can be left inconsistent).
# Only cancel redundant pull_request runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ─── Test & Lint ────────────────────────────────────────────
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ratemeai
POSTGRES_PASSWORD: ratemeai
POSTGRES_DB: ratemeai
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U ratemeai"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
pip install -r requirements-dev.txt
pip install opencv-python-headless>=4.6.0
- name: Lint with ruff
run: ruff check src/ tests/ --select=E,F,W --ignore=E501
- name: Run pytest
env:
DATABASE_URL: postgresql+asyncpg://ratemeai:ratemeai@127.0.0.1:5432/ratemeai
REDIS_URL: redis://127.0.0.1:6379/0
APP_ENV: dev
API_BASE_URL: http://127.0.0.1:8000
run: python -m pytest tests/ -v --tb=short
# Non-blocking PR0 shadow diff: measures how much prompts would
# change if the PromptEngine lambdas were replaced with direct
# builder calls (see scripts/shadow_diff_prompt_engine.py and the
# style-schema-v2 migration plan). Report is uploaded as an
# artifact regardless of exit code.
- name: Prompt engine shadow diff (non-blocking)
if: always()
continue-on-error: true
env:
DATABASE_URL: postgresql+asyncpg://ratemeai:ratemeai@127.0.0.1:5432/ratemeai
REDIS_URL: redis://127.0.0.1:6379/0
APP_ENV: dev
run: python -m scripts.shadow_diff_prompt_engine --output _diag/prompt_shadow_diff.md
- name: Upload shadow diff report
if: always()
uses: actions/upload-artifact@v4
with:
name: prompt-shadow-diff
path: _diag/prompt_shadow_diff.md
if-no-files-found: ignore
- name: Typecheck frontend
working-directory: web
run: |
npm ci
npx tsc --noEmit
VITE_API_BASE_URL=http://127.0.0.1:8000 VITE_MARKET_ID=global npm run build
# 1.59.0 — vitest unit/integration tests for the SPA (initially
# focused on landing-cms fallbacks + Pricing render). Runs after
# tsc/build so the test suite executes against the same module
# graph the production bundle compiles.
- name: Vitest (frontend)
working-directory: web
run: npm test -- --reporter=verbose
# ─── Deploy backend (Railway: app + worker + bot) ───────────
deploy-backend:
needs: [test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
GLOBAL_API_BASE_URL: ${{ secrets.RAILWAY_API_URL }}
# Variant B: global SPA lives on Vercel under ailookstudio.vercel.app.
# OAuth redirect & payment-success URLs are derived from this value.
GLOBAL_WEB_BASE_URL: https://ailookstudio.vercel.app
# RU edge follower URL — used as the CMS push target and Xsolla
# webhook fallback. Defaults to the production RU domain so the
# value can be omitted from GitHub Secrets after the cut-over.
RU_PUBLIC_BASE_URL: ${{ secrets.RU_PUBLIC_BASE_URL || 'https://ailookstudio.ru' }}
# Optional dedicated HMAC secret for CMS replication; falls back
# to INTERNAL_API_KEY when empty (no extra rotation required).
CMS_REPLICATION_SECRET: ${{ secrets.CMS_REPLICATION_SECRET }}
# 1.55.4 — ADMIN_EMAILS is now provisioned from CI to BOTH Railway
# (primary) and the RU edge .env.ru, replacing the brittle in-script
# ensure_env_line block in deploy/ru/update.sh that suffered from
# bash inode replacement during git pull. Source: optional GitHub
# repo secret; falls back to the known operator list so existing
# deploys keep working without manual secret config.
ADMIN_EMAILS: ${{ secrets.ADMIN_EMAILS || 'vladimir18kostyal@gmail.com,uk-tora@yandex.ru' }}
steps:
- uses: actions/checkout@v4
- name: Validate secrets
run: |
ok=true
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
ok=false
fi
if [ -z "$RAILWAY_PROJECT_ID" ]; then
echo "::error::RAILWAY_PROJECT_ID is not set"
ok=false
fi
if [ -z "$GLOBAL_API_BASE_URL" ]; then
echo "::error::RAILWAY_API_URL is not set"
ok=false
fi
[ "$ok" = true ] || exit 1
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Set deploy SHA and edge config
env:
INTERNAL_API_KEY: ${{ secrets.INTERNAL_API_KEY }}
GC_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GC_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
FAL_API_KEY: ${{ secrets.FAL_API_KEY }}
ADMIN_EMAILS: ${{ env.ADMIN_EMAILS }}
run: |
set -e
FAILED=0
# Railway GraphQL (backboard.railway.com) периодически ловит
# transient timeouts при burst-записи переменных. Один-два
# случайных 30-сек. таймаута в серии из ~35 вызовов — это
# норма для их API, но раньше они валили весь шаг и требовали
# ручного `gh run rerun --failed`. Обёртка делает 3 попытки
# с экспоненциальным backoff (2s → 5s → 10s) — успешно
# переживаем любой одиночный сетевой сбой, и только устойчивый
# отказ помечает шаг как failed.
rl_set() {
local attempt=1
local max_attempts=3
local delay=2
while [ "$attempt" -le "$max_attempts" ]; do
if railway variables set "$@"; then
return 0
fi
if [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::railway variables set attempt $attempt failed for $1 — retrying in ${delay}s"
sleep "$delay"
delay=$((delay * 2 + 1))
fi
attempt=$((attempt + 1))
done
echo "::error::Failed to set Railway variable after ${max_attempts} attempts: $1"
FAILED=1
# Return 0 so `set -e` does not abort the whole step on the
# first terminal failure — we want every variable to get its
# shot and then fail the step at the end via FAILED != 0.
return 0
}
SHORT_SHA="${GITHUB_SHA:0:12}"
rl_set DEPLOY_GIT_SHA="$SHORT_SHA" -s app -e production --skip-deploys
rl_set DEPLOYMENT_MODE="primary" -s app -e production --skip-deploys
rl_set MARKET_ID="global" -s app -e production --skip-deploys
rl_set SERVICE_ROLE="api" -s app -e production --skip-deploys
rl_set COMPUTE_MODE="local" -s app -e production --skip-deploys
rl_set DEPLOY_GIT_SHA="$SHORT_SHA" -s worker -e production --skip-deploys
rl_set DEPLOYMENT_MODE="primary" -s worker -e production --skip-deploys
rl_set MARKET_ID="global" -s worker -e production --skip-deploys
rl_set SERVICE_ROLE="worker" -s worker -e production --skip-deploys
rl_set COMPUTE_MODE="local" -s worker -e production --skip-deploys
# Image generation policy (v1.18+ hybrid pipeline):
# IMAGE_GEN_STRATEGY=hybrid → StyleRouter picks
# fal-ai/pulid for identity_scene styles
# fal-ai/seedream/v4 for scene_preserve styles
# fal-ai/flux-2-pro as the emergency fallback
# IMAGE_GEN_PROVIDER is left on "auto" (the factory picks
# StyleRouter when strategy=hybrid). The old v1.14 pin to
# fal_flux (Kontext) is gone — Kontext is only reachable
# via a manual ``IMAGE_GEN_STRATEGY=legacy`` rollback.
# v1.20: Reve and Replicate providers were retired from
# the factory; REVE_MAX_RETRIES / REVE_API_TOKEN /
# REPLICATE_* are no longer synced.
if [ -n "$FAL_API_KEY" ]; then
echo "Syncing FAL_API_KEY to Railway services..."
rl_set FAL_API_KEY="$FAL_API_KEY" -s app -e production --skip-deploys
rl_set FAL_API_KEY="$FAL_API_KEY" -s worker -e production --skip-deploys
else
echo "::error::FAL_API_KEY secret not set — hybrid image-gen pipeline cannot run"
FAILED=1
fi
echo "Syncing hybrid image-gen strategy + feature flags to Railway..."
for SVC in app worker; do
rl_set IMAGE_GEN_STRATEGY="hybrid" -s "$SVC" -e production --skip-deploys
rl_set PULID_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set SEEDREAM_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set CODEFORMER_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set REAL_ESRGAN_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set GFPGAN_PRECLEAN_ENABLED="true" -s "$SVC" -e production --skip-deploys
done
# style-schema-v2 rollout (v1.25+): explicitly pin the three
# v2 feature flags so Railway dashboard overrides cannot
# silently disable the new prompt pipeline. All three default
# to true in src/config.py; we re-assert them here so every
# deploy heals any accidental dashboard tweak. Removing these
# lines requires updating src/config.py defaults first.
# v1.28.0: STYLE_SCHEMA_V3_ENABLED added to the same loop —
# default in src/config.py is false (Stage 1 ships additive),
# but production has it flipped to true so the v3 SlotSampler
# path is the live one. Re-asserting here heals dashboard
# drift the same way v2 flags do.
echo "Syncing style-schema feature flags to Railway (app + worker)..."
for SVC in app worker; do
rl_set STYLE_SCHEMA_V2_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set UNIFIED_PROMPT_V2_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set VARIATION_ENGINE_V2_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set STYLE_SCHEMA_V3_ENABLED="true" -s "$SVC" -e production --skip-deploys
# v1.27.2: pin the A/B router defaults so a Railway dashboard
# tweak cannot silently route Telegram-bot traffic away from
# GPT Image 2. The bot also sends image_model=gpt_image_2
# explicitly (see src/bot/handlers/mode_select.py) — this is
# the defense-in-depth layer for the same policy.
rl_set AB_TEST_ENABLED="true" -s "$SVC" -e production --skip-deploys
rl_set AB_DEFAULT_MODEL="gpt_image_2" -s "$SVC" -e production --skip-deploys
# Dead flag removed in v1.25 (lambda map fix became unconditional).
# Deleting proactively avoids stale env noise on each service.
railway variables delete PROMPT_ENGINE_MAP_FIX -s "$SVC" -e production 2>/dev/null || echo " (PROMPT_ENGINE_MAP_FIX absent on $SVC — skipped)"
done
rl_set DEPLOY_GIT_SHA="$SHORT_SHA" -s bot -e production --skip-deploys
rl_set DEPLOYMENT_MODE="primary" -s bot -e production --skip-deploys
rl_set MARKET_ID="global" -s bot -e production --skip-deploys
rl_set SERVICE_ROLE="bot" -s bot -e production --skip-deploys
rl_set COMPUTE_MODE="local" -s bot -e production --skip-deploys
if [ -n "$INTERNAL_API_KEY" ]; then
echo "Syncing INTERNAL_API_KEY to Railway app + bot..."
rl_set INTERNAL_API_KEY="$INTERNAL_API_KEY" -s app -e production --skip-deploys
# 1.62.2 — bot calls POST /api/v1/internal/bot/stars/grant with
# X-Internal-Key after Telegram Stars successful_payment. Without
# this var on the bot service users see «Сервис не настроен
# (internal key)» even though app has the key.
rl_set INTERNAL_API_KEY="$INTERNAL_API_KEY" -s bot -e production --skip-deploys
else
echo "::warning::INTERNAL_API_KEY secret not set — edge→primary auth may break"
fi
# 1.55.4 — ADMIN_EMAILS sync (primary side). Mirrors what the
# deploy-ru job writes into .env.ru below, so both regions
# share the same email whitelist without manual dashboard
# tweaking. Empty value = skip (we never want to clobber a
# manually-set production whitelist with an empty string).
if [ -n "$ADMIN_EMAILS" ]; then
ENTRIES=$(echo "$ADMIN_EMAILS" | tr ',' '\n' | grep -c .)
echo "Syncing ADMIN_EMAILS to Railway app service ($ENTRIES entries)..."
rl_set ADMIN_EMAILS="$ADMIN_EMAILS" -s app -e production --skip-deploys
else
echo "::warning::ADMIN_EMAILS not set — admin endpoints stay locked on primary"
fi
if [ -n "$GC_ID" ] && [ -n "$GC_SECRET" ]; then
echo "Syncing Google OAuth credentials to Railway app service..."
rl_set GOOGLE_CLIENT_ID="$GC_ID" -s app -e production --skip-deploys
rl_set GOOGLE_CLIENT_SECRET="$GC_SECRET" -s app -e production --skip-deploys
fi
echo "Syncing API_BASE_URL to Railway services..."
rl_set API_BASE_URL="$GLOBAL_API_BASE_URL" -s app -e production --skip-deploys
rl_set API_BASE_URL="$GLOBAL_API_BASE_URL" -s bot -e production --skip-deploys
rl_set API_BASE_URL="$GLOBAL_API_BASE_URL" -s worker -e production --skip-deploys
rl_set WEB_BASE_URL="$GLOBAL_WEB_BASE_URL" -s app -e production --skip-deploys
# 1.62.0 — per-language landing URLs in the single bot.
# ailookstudio.ru is used for RU-family language_codes, the
# Vercel landing for everyone else. The legacy single-value
# ``BOT_WEB_LANDING_URL`` is kept synced as a fallback for
# operators rolling back to <1.62.
rl_set BOT_WEB_LANDING_URL_RU="https://ailookstudio.ru" -s bot -e production --skip-deploys
rl_set BOT_WEB_LANDING_URL_RU="https://ailookstudio.ru" -s app -e production --skip-deploys
rl_set BOT_WEB_LANDING_URL_DEFAULT="$GLOBAL_WEB_BASE_URL" -s bot -e production --skip-deploys
rl_set BOT_WEB_LANDING_URL_DEFAULT="$GLOBAL_WEB_BASE_URL" -s app -e production --skip-deploys
rl_set BOT_WEB_LANDING_URL="$GLOBAL_WEB_BASE_URL" -s bot -e production --skip-deploys
rl_set BOT_WEB_LANDING_URL="$GLOBAL_WEB_BASE_URL" -s app -e production --skip-deploys
# 1.62.0 — single bot @AI_Look_Studio_bot on Railway. The
# legacy ``PEER_BOT_USERNAME`` field is ignored now (the
# language guard middleware was deleted), but we still pin
# the bot username so callers that depend on it (link.py)
# have a stable value.
rl_set TELEGRAM_BOT_USERNAME="AI_Look_Studio_bot" -s bot -e production --skip-deploys
rl_set TELEGRAM_BOT_USERNAME="AI_Look_Studio_bot" -s app -e production --skip-deploys
# Telegram Stars pack grid (credits:stars). Source of truth for prod bot pricing.
rl_set CREDIT_PACKS_XTR="5:127,10:227,20:427,50:927" -s bot -e production --skip-deploys
# 1.62.0 — EDGE_API_URL is no longer consumed by the bot
# (the single bot only talks to Railway). We unset it on
# the bot service to avoid drift and only keep it on ``app``
# if needed by web admin tooling.
# 1.62.0 — drop EDGE_API_URL / PRIMARY_API_URL from the bot
# service. The single bot now talks to its own region only.
railway variables delete EDGE_API_URL -s bot -e production 2>/dev/null || echo " (EDGE_API_URL absent on bot — skipped)"
railway variables delete PRIMARY_API_URL -s bot -e production 2>/dev/null || echo " (PRIMARY_API_URL absent on bot — skipped)"
# Удаляем любые YOOKASSA_* с primary-сервисов: платежи живут только на
# RU-edge (там свой .env.ru с live-кредами). Railway CLI запрещает
# пустые значения ("Invalid variable format: KEY="), поэтому используем
# `railway variables delete`. Если переменной нет — команда вернёт
# ненулевой код, это штатно, игнорируем через `|| true`. startup guard
# в src/main.py остаётся вторым уровнем защиты: даже если кто-то
# вручную вернёт YOOKASSA_* в Railway, сервис обнулит их в памяти.
echo "Removing YOOKASSA_* from Railway services (payments live only on RU edge)..."
for SVC in app worker bot; do
railway variables delete YOOKASSA_SHOP_ID -s "$SVC" -e production 2>/dev/null || echo " (YOOKASSA_SHOP_ID absent on $SVC — skipped)"
railway variables delete YOOKASSA_SECRET_KEY -s "$SVC" -e production 2>/dev/null || echo " (YOOKASSA_SECRET_KEY absent on $SVC — skipped)"
done
# Variant B: Railway is the CMS editor. ``CMS_FOLLOWER_URLS`` is
# the comma-separated list of follower base URLs; today it's the
# RU edge only. ``CMS_REPLICATION_SECRET`` is optional — when
# empty the receiver falls back to ``INTERNAL_API_KEY``, which
# we already sync above.
echo "Syncing CMS hub configuration to Railway services..."
for SVC in app worker; do
rl_set CMS_ROLE="editor" -s "$SVC" -e production --skip-deploys
rl_set CMS_FOLLOWER_URLS="$RU_PUBLIC_BASE_URL" -s "$SVC" -e production --skip-deploys
if [ -n "$CMS_REPLICATION_SECRET" ]; then
rl_set CMS_REPLICATION_SECRET="$CMS_REPLICATION_SECRET" -s "$SVC" -e production --skip-deploys
fi
done
# Xsolla return URL must point at the global SPA after the
# Variant B cut-over. The webhook stays on the Railway public
# API URL — that's still configured directly in the Xsolla
# Publisher Account dashboard.
echo "Syncing XSOLLA_RETURN_URL to Railway app/worker..."
for SVC in app worker; do
rl_set XSOLLA_RETURN_URL="${GLOBAL_WEB_BASE_URL}/payment-success" -s "$SVC" -e production --skip-deploys
done
if [ "$FAILED" -ne 0 ]; then
echo "::error::One or more Railway variable updates failed"
exit 1
fi
- name: Deploy Railway services (sequential)
env:
SHORT_SHA_TARGET: ${{ github.sha }}
run: |
set -e
# v1.62.6: tolerate the "Deploys have been paused temporarily"
# response from ``railway up``. Per Railway's own help station
# this message is returned during platform-wide high-demand
# windows: the deploy is *accepted into the queue* and will
# process automatically once the pause lifts, "no action
# required on your part". The CLI nevertheless exits with
# status 1, which used to fail the whole CI run even though
# the deploy itself was healthy and on its way.
# The new wrapper retries each ``railway up`` a few times,
# treating a ``paused`` response as a soft signal (deploy in
# queue → continue), and only fails the step on an actual
# CLI error.
deploy_with_retry() {
local service="$1"
local attempt=1
local max_attempts=5
local delay=20
local out_file
out_file="$(mktemp)"
while [ "$attempt" -le "$max_attempts" ]; do
echo "[$service] railway up attempt $attempt/$max_attempts"
if railway up -s "$service" -d -e production 2>&1 | tee "$out_file"; then
rm -f "$out_file"
return 0
fi
if grep -qi "paused temporarily" "$out_file"; then
echo "::notice::Railway returned 'Deploys have been paused temporarily' for $service — deploy queued, treating as soft success"
rm -f "$out_file"
return 0
fi
if [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::$service deploy attempt $attempt failed (non-paused), retrying in ${delay}s"
sleep "$delay"
delay=$((delay * 2))
fi
attempt=$((attempt + 1))
done
echo "::error::railway up -s $service failed after $max_attempts attempts"
rm -f "$out_file"
return 1
}
echo "Deploying app..."
deploy_with_retry app
echo "Deploying worker..."
deploy_with_retry worker
echo "Deploying bot..."
deploy_with_retry bot
- name: Wait for Railway build & deploy
run: sleep 180
- name: Health check
env:
DEPLOY_SHA_FULL: ${{ github.sha }}
run: |
API_URL="${GLOBAL_API_BASE_URL}"
SHORT_SHA="${DEPLOY_SHA_FULL:0:12}"
echo "Checking $API_URL/health, expecting commit=$SHORT_SHA ..."
# v1.62.6: extended polling window. When Railway queues the
# deploy due to platform high-demand the actual rollout can
# take several minutes longer than the usual sub-minute
# build-and-restart. The old 8 × 45 s budget (~6 min) was
# too short for the queued path. We now poll for up to
# ~25 min and explicitly look for the new commit SHA in
# /health so we don't false-positive on an older healthy
# container that has not been replaced yet. The step still
# exits early on the first match so happy-path deploys
# finish in under a minute.
MATCHED=0
for i in $(seq 1 34); do
RESP=$(curl -sf "$API_URL/health" || echo "FAIL")
echo "Attempt $i: $RESP"
if echo "$RESP" | grep -q '"status"\s*:\s*"ok"'; then
if echo "$RESP" | grep -q "$SHORT_SHA"; then
echo "Health check passed: status=ok and commit=$SHORT_SHA"
MATCHED=1
break
fi
echo "Health OK but commit not yet $SHORT_SHA (queued deploy still rolling out)"
fi
sleep 45
done
if [ "$MATCHED" -ne 1 ]; then
echo "::error::Health check did not observe commit $SHORT_SHA on /health within the polling window"
exit 1
fi
# v1.24.1: post-deploy provider smoke removed from the deploy
# pipeline entirely. Rationale:
#
# * The deploy pipeline's responsibility ends at "did our
# container come up and serve the right version" — which is
# exactly what the ``Health check`` step above verifies.
# External provider health (OpenRouter, FAL, Gemini) is not
# under the deploy's control, so a transient upstream 504
# should not paint the deploy red.
# * Provider liveness is continuously monitored by the hourly
# ``smoke-live.yml`` workflow (provider-probe +
# synthetic-analyze, with 3× retry and artifact upload).
# * Ad-hoc manual verification uses the dedicated
# ``diag-provider-probe.yml``, ``diag-synthetic-analyze.yml``
# and ``diag-image-gen-probe.yml`` workflows (each under
# ``workflow_dispatch``, the last one paid).
#
# Keeping the same probes in ``ci.yml`` duplicated that
# responsibility and made every push to main flaky on transient
# upstream errors (observed on 0d74bf1: OpenRouter vision_plain
# returned 504 three times in a row while the deploy itself was
# perfectly healthy). Done is better than duplicated.
- name: Deploy summary
if: always()
run: |
SHORT_SHA="${GITHUB_SHA:0:12}"
echo "## Deploy Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Commit | \`$SHORT_SHA\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Backend | [Railway Dashboard](https://railway.com/project/${{ secrets.RAILWAY_PROJECT_ID }}) |" >> $GITHUB_STEP_SUMMARY
echo "| Frontend | [Vercel](https://ailookstudio.vercel.app) |" >> $GITHUB_STEP_SUMMARY
API_URL="${GLOBAL_API_BASE_URL}"
echo "| API Health | [/health](${API_URL}/health) |" >> $GITHUB_STEP_SUMMARY
# ─── Deploy RU edge server (SSH) ──────────────────────────
deploy-ru:
needs: [test, deploy-backend]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Validate RU secrets
env:
RU_SSH_HOST: ${{ secrets.RU_SSH_HOST }}
RU_SSH_KEY: ${{ secrets.RU_SSH_KEY }}
RU_SSH_USER: ${{ secrets.RU_SSH_USER }}
RU_PUBLIC_BASE_URL: ${{ secrets.RU_PUBLIC_BASE_URL }}
run: |
if [ -z "$RU_SSH_HOST" ] || [ -z "$RU_SSH_KEY" ] || [ -z "$RU_SSH_USER" ] || [ -z "$RU_PUBLIC_BASE_URL" ]; then
echo "::warning::RU_SSH_HOST, RU_SSH_KEY, RU_SSH_USER, or RU_PUBLIC_BASE_URL not set — skipping RU deploy"
echo "SKIP_RU=true" >> $GITHUB_ENV
fi
- name: Deploy to RU edge server
if: env.SKIP_RU != 'true'
uses: appleboy/ssh-action@v1
env:
DEPLOY_GIT_SHA: ${{ github.sha }}
INTERNAL_API_KEY: ${{ secrets.INTERNAL_API_KEY }}
GLOBAL_API_BASE_URL: ${{ secrets.RAILWAY_API_URL }}
RU_PUBLIC_BASE_URL: ${{ secrets.RU_PUBLIC_BASE_URL || 'https://ailookstudio.ru' }}
# Optional dedicated HMAC secret for CMS replication; falls
# back to INTERNAL_API_KEY when empty.
CMS_REPLICATION_SECRET: ${{ secrets.CMS_REPLICATION_SECRET }}
# 1.55.4 — single source of truth for the admin whitelist on
# both regions, sourced from the GitHub secret with a known
# fallback so deploys never silently drop the admins.
ADMIN_EMAILS: ${{ secrets.ADMIN_EMAILS || 'vladimir18kostyal@gmail.com,uk-tora@yandex.ru' }}
# Variant B fix: OAuth credentials must be synced to the RU
# edge as well — without them /api/v1/auth/{provider}/init
# returns 503 ("…not configured on this server") and login
# buttons silently fail in the SPA. We sync only what GitHub
# secrets provide; any missing pair stays untouched in
# .env.ru so locally-configured creds are not wiped.
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
YANDEX_CLIENT_ID: ${{ secrets.YANDEX_CLIENT_ID }}
YANDEX_CLIENT_SECRET: ${{ secrets.YANDEX_CLIENT_SECRET }}
# VK ID OAuth on the RU edge reads VK_ID_APP_ID / VK_ID_APP_SECRET
# (see src/config.py:413-415). The legacy VK_CLIENT_* names used
# by an earlier draft of this workflow were ignored by Pydantic
# and silently broke the VK sign-in button on ailookstudio.ru.
VK_ID_APP_ID: ${{ secrets.VK_ID_APP_ID }}
VK_ID_APP_SECRET: ${{ secrets.VK_ID_APP_SECRET }}
VK_SERVICE_TOKEN: ${{ secrets.VK_SERVICE_TOKEN }}
# 1.61.0: ru.ailookstudio.ru fully removed (DNS gone, cert
# lineage deleted by deploy/ru/bootstrap-certs.sh, nginx.conf
# no longer references it). All RU traffic flows through
# https://ailookstudio.ru. Cert issuance is out-of-band via
# the ``Bootstrap RU edge cert`` workflow. update.sh is now
# pure rebuild + restart — no DNS branching at runtime.
# 1.62.0: RU-side Telegram bot service removed (РКН blocks
# egress to api.telegram.org from RU hosting). All Telegram
# traffic now goes through the single @AI_Look_Studio_bot
# on Railway (webhook) with per-language routing inside.
# The RU_TELEGRAM_BOT_TOKEN GitHub secret is intentionally
# NOT consumed here anymore — left in place for historical
# reference / future re-introduction via a proxy if needed.
with:
host: ${{ secrets.RU_SSH_HOST }}
username: ${{ secrets.RU_SSH_USER }}
key: ${{ secrets.RU_SSH_KEY }}
port: 22
command_timeout: 10m
envs: DEPLOY_GIT_SHA,INTERNAL_API_KEY,GLOBAL_API_BASE_URL,RU_PUBLIC_BASE_URL,CMS_REPLICATION_SECRET,ADMIN_EMAILS,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,YANDEX_CLIENT_ID,YANDEX_CLIENT_SECRET,VK_ID_APP_ID,VK_ID_APP_SECRET,VK_SERVICE_TOKEN
script: |
export DEPLOY_GIT_SHA="${DEPLOY_GIT_SHA:0:12}"
ENV_FILE="/opt/ratemeai/.env.ru"
sync_env() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
else
echo "${key}=${value}" >> "$ENV_FILE"
fi
}
sync_env DEPLOYMENT_MODE edge
sync_env MARKET_ID ru
sync_env SERVICE_ROLE api
sync_env COMPUTE_MODE remote
# 1.59.3 — pin canonical RUB tariff grid on RU edge so the
# CREDIT_PACKS env can never drift back to the legacy
# "1:59,5:199,15:499,30:899" set even if .env.ru was edited
# by hand. Source of truth = src/config.py default.
sync_env CREDIT_PACKS "5:227,10:427,20:727,50:1527"
sync_env REMOTE_AI_BACKEND_URL "$GLOBAL_API_BASE_URL"
# Variant B: API and SPA share the public RU origin. The
# SPA reaches the backend via nginx /api proxy on the same
# host, so VITE_API_BASE_URL stays empty.
sync_env API_BASE_URL "$RU_PUBLIC_BASE_URL"
sync_env WEB_BASE_URL "$RU_PUBLIC_BASE_URL"
# 1.61.0 — bot reads EDGE_API_URL to know which backend to
# hit for auth/tasks/payments. On the RU edge it's the same
# origin as API_BASE_URL (the public RU URL); without it the
# bot logs a "EDGE_API_URL is empty in production" warning
# and ЮKassa RUB top-ups silently use the wrong return host.
sync_env EDGE_API_URL "$RU_PUBLIC_BASE_URL"
# 1.62.0 — RU edge no longer runs a Telegram bot, so the
# bot-only env vars (BOT_WEB_LANDING_URL, PEER_BOT_USERNAME,
# TELEGRAM_BOT_USERNAME) are intentionally not synced from
# CI for the edge. Web side ignores them. We strip any
# leftovers from a previous deploy so the file stays clean.
sed -i '/^PEER_BOT_USERNAME=/d' "$ENV_FILE" 2>/dev/null || true
sed -i '/^TELEGRAM_BOT_TOKEN=/d' "$ENV_FILE" 2>/dev/null || true
sed -i '/^TELEGRAM_BOT_USERNAME=/d' "$ENV_FILE" 2>/dev/null || true
sed -i '/^BOT_WEB_LANDING_URL=/d' "$ENV_FILE" 2>/dev/null || true
sync_env VITE_API_BASE_URL ""
sync_env VITE_MARKET_ID ru
sync_env VITE_WEB_ORIGIN "$RU_PUBLIC_BASE_URL"
sync_env VITE_SUPPORT_EMAIL "support@ailookstudio.ru"
# Variant B: this server is a CMS follower that pulls from
# Railway and accepts signed pushes via /internal/cms/replicate.
sync_env CMS_ROLE follower
sync_env CMS_MASTER_URL "$GLOBAL_API_BASE_URL"
sync_env CMS_SAFETY_PULL_ENABLED true
if [ -n "$CMS_REPLICATION_SECRET" ]; then
sync_env CMS_REPLICATION_SECRET "$CMS_REPLICATION_SECRET"
fi
# YooKassa return URL: SPA payment-success page on the same RU origin.
sync_env YOOKASSA_RETURN_URL "${RU_PUBLIC_BASE_URL}/payment-success"
# style-schema-v2 rollout (v1.25+): pin v2 flags on the RU
# edge just like on Railway, so a stale .env.ru cannot
# silently disable the new prompt pipeline. All three
# default to true in src/config.py — these lines heal any
# accidental manual override on every deploy.
# v1.28.0: STYLE_SCHEMA_V3_ENABLED pinned alongside —
# default in src/config.py is false (additive ship), but
# production runs with v3 active. Re-asserted on every
# RU deploy to mirror the Railway primary side.
sync_env STYLE_SCHEMA_V2_ENABLED true
sync_env UNIFIED_PROMPT_V2_ENABLED true
sync_env VARIATION_ENGINE_V2_ENABLED true
sync_env STYLE_SCHEMA_V3_ENABLED true
# Dead flag removed in v1.25; strip it if still present.
sed -i '/^PROMPT_ENGINE_MAP_FIX=/d' "$ENV_FILE" 2>/dev/null || true
if [ -n "$INTERNAL_API_KEY" ]; then
sync_env INTERNAL_API_KEY "$INTERNAL_API_KEY"
echo "INTERNAL_API_KEY synced to .env.ru"
fi
# Variant B fix — sync OAuth provider credentials to .env.ru.
# Without these the RU SPA's "Sign in with Google" returns
# 503 from /api/v1/auth/google/init. Each pair is optional:
# missing GitHub secrets leave the existing .env.ru entry
# untouched (so a locally-set Yandex/VK app keeps working).
if [ -n "$GOOGLE_CLIENT_ID" ]; then
sync_env GOOGLE_CLIENT_ID "$GOOGLE_CLIENT_ID"
echo "GOOGLE_CLIENT_ID synced to .env.ru"
fi
if [ -n "$GOOGLE_CLIENT_SECRET" ]; then
sync_env GOOGLE_CLIENT_SECRET "$GOOGLE_CLIENT_SECRET"
echo "GOOGLE_CLIENT_SECRET synced to .env.ru"
fi
if [ -n "$YANDEX_CLIENT_ID" ]; then
sync_env YANDEX_CLIENT_ID "$YANDEX_CLIENT_ID"
echo "YANDEX_CLIENT_ID synced to .env.ru"
fi
if [ -n "$YANDEX_CLIENT_SECRET" ]; then
sync_env YANDEX_CLIENT_SECRET "$YANDEX_CLIENT_SECRET"
echo "YANDEX_CLIENT_SECRET synced to .env.ru"
fi
# VK ID OAuth — Pydantic reads VK_ID_APP_ID / VK_ID_APP_SECRET
# (src/config.py:413-415). Strip any legacy VK_CLIENT_* lines
# that may still linger in .env.ru from a previous CI revision
# so the file stays clean and consistent.
sed -i '/^VK_CLIENT_ID=/d' "$ENV_FILE" 2>/dev/null || true
sed -i '/^VK_CLIENT_SECRET=/d' "$ENV_FILE" 2>/dev/null || true
if [ -n "$VK_ID_APP_ID" ]; then
sync_env VK_ID_APP_ID "$VK_ID_APP_ID"
echo "VK_ID_APP_ID synced to .env.ru"
fi
if [ -n "$VK_ID_APP_SECRET" ]; then
sync_env VK_ID_APP_SECRET "$VK_ID_APP_SECRET"
echo "VK_ID_APP_SECRET synced to .env.ru"
fi
if [ -n "$VK_SERVICE_TOKEN" ]; then
sync_env VK_SERVICE_TOKEN "$VK_SERVICE_TOKEN"
echo "VK_SERVICE_TOKEN synced to .env.ru"
fi
# 1.62.0 — there is no bot on the RU VPS anymore. All
# Telegram traffic is on Railway via the single
# @AI_Look_Studio_bot (webhook). Strip any leftover
# TELEGRAM_BOT_TOKEN / BOT_WEBHOOK_URL from .env.ru so a
# stale value can never accidentally reanimate a polling
# loop on the VPS.
sed -i '/^TELEGRAM_BOT_TOKEN=/d' "$ENV_FILE" 2>/dev/null || true
sed -i '/^BOT_WEBHOOK_URL=/d' "$ENV_FILE" 2>/dev/null || true
# 1.55.4 — ADMIN_EMAILS provisioning is now CI-driven and
# runs OUTSIDE update.sh. Why: in 1.55.2/1.55.3 the helper
# lived inside update.sh, but bash holds the running
# script's inode open across the in-script ``git pull``,
# so any change to update.sh only took effect on the NEXT
# deploy (one-deploy lag). Doing it here, in the CI ssh
# action's own bash, sidesteps the inode quirk entirely:
# this script is never replaced mid-run. The change also
# makes the value visible in the CI log ("ADMIN_EMAILS
# synced (N entries)") and editable via a single GitHub
# secret without touching code.
if [ -n "$ADMIN_EMAILS" ]; then
sync_env ADMIN_EMAILS "$ADMIN_EMAILS"
ENTRIES=$(echo "$ADMIN_EMAILS" | tr ',' '\n' | grep -c .)
echo "ADMIN_EMAILS synced to .env.ru ($ENTRIES entries)"
else
echo "::warning::ADMIN_EMAILS empty — RU admin endpoints will return 403 for everyone"
fi
# IMPORTANT: pull in CI bash, NOT inside update.sh — see header
# comment in update.sh for the bash-inode rationale. Without
# this, any function newly added to update.sh stays unreachable
# for one full deploy ("one-deploy lag" bug from 1.55.2).
echo "--- git pull (CI bash, before update.sh) ---"
cd /opt/ratemeai && git pull origin main
bash /opt/ratemeai/deploy/ru/update.sh
- name: RU smoke tests
if: env.SKIP_RU != 'true'
env:
RU_PUBLIC_BASE_URL: ${{ secrets.RU_PUBLIC_BASE_URL }}
RU_SSH_HOST: ${{ secrets.RU_SSH_HOST }}
run: |
set -e
DOMAIN="$RU_PUBLIC_BASE_URL"
if [ -z "$DOMAIN" ]; then
echo "::error::RU_PUBLIC_BASE_URL is not set"
exit 1
fi
SHORT_SHA="${GITHUB_SHA:0:12}"
FAIL=0
# ``--resolve`` pin so we never hit a stale GitHub runner DNS
# cache (this bit us in 1.60 — the runner kept resolving
# ailookstudio.ru to an old Vercel edge IP for hours after the
# cut-over). RU_SSH_HOST is the VPS public IP we deploy to,
# which by definition is the right A-record for the domain.
HOST_NO_SCHEME=$(echo "$DOMAIN" | sed -E 's|^https?://||; s|/.*$||')
if [ -n "$RU_SSH_HOST" ] && [ -n "$HOST_NO_SCHEME" ]; then
RESOLVE_FLAG="--resolve ${HOST_NO_SCHEME}:443:${RU_SSH_HOST} --resolve ${HOST_NO_SCHEME}:80:${RU_SSH_HOST}"
echo "Smoke pins: ${RESOLVE_FLAG}"
else
RESOLVE_FLAG=""
echo "::warning::No --resolve pin (RU_SSH_HOST or DOMAIN missing); relying on runner DNS"
fi
echo "=== Smoke test 1: /health with git SHA (retries) ==="
HEALTH="FAIL"
for attempt in $(seq 1 24); do
if [ "$attempt" -gt 1 ]; then
echo " health attempt $attempt/24 (waiting for API behind nginx)..."
sleep 10
else
sleep 20
fi
HEALTH=$(curl -sf $RESOLVE_FLAG "$DOMAIN/health" || echo "FAIL")
if echo "$HEALTH" | grep -q '"ok"'; then
break
fi
done
echo " $HEALTH"
if ! echo "$HEALTH" | grep -q '"ok"'; then
echo "::error::Health check failed after retries (nginx 502 / API still down?)"
FAIL=1
fi
if ! echo "$HEALTH" | grep -q "$SHORT_SHA"; then
echo "::warning::Health response missing expected SHA $SHORT_SHA"
fi
echo "=== Smoke test 2: /version.json (frontend build) ==="
VER=$(curl -sf $RESOLVE_FLAG "$DOMAIN/version.json" || echo "FAIL")
echo " $VER"
if [ "$VER" = "FAIL" ]; then
echo "::warning::version.json not available"
fi
echo "=== Smoke test 3: catalog API (no auth) ==="
# Do not use curl -f here: with ``set -e`` a 4xx/5xx or connection
# error exits the whole step before later diagnostics run.
HTTP_CODE=$(curl -sS $RESOLVE_FLAG -o /dev/null -w "%{http_code}" "$DOMAIN/api/v1/catalog/styles?mode=dating" || echo "000")
echo " HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::Catalog API returned $HTTP_CODE"
FAIL=1
fi
echo "=== Smoke test 4: auth + DB-dependent endpoint ==="
AUTH_RESP=$(curl -sf $RESOLVE_FLAG -X POST "$DOMAIN/api/v1/auth/web" \
-H "Content-Type: application/json" \
-d '{"device_id":"ci-smoke-test"}' || echo "FAIL")
echo " auth: ${AUTH_RESP:0:80}..."
if echo "$AUTH_RESP" | grep -q "session_token"; then
TOKEN=$(echo "$AUTH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['session_token'])")
TASKS_CODE=$(curl -sS $RESOLVE_FLAG -o /dev/null -w "%{http_code}" \
"$DOMAIN/api/v1/tasks?limit=1" \
-H "Authorization: Bearer $TOKEN" || echo "000")
echo " tasks: HTTP $TASKS_CODE"
if [ "$TASKS_CODE" != "200" ]; then
echo "::error::Tasks endpoint returned $TASKS_CODE (expected 200 — DB migration may have failed)"
FAIL=1
fi
else
echo "::error::Auth failed: $AUTH_RESP"
FAIL=1
fi
echo "=== Smoke test 5: edge→primary internal API connectivity ==="
READINESS=$(curl -sf $RESOLVE_FLAG "$DOMAIN/readiness" || echo "FAIL")
echo " $READINESS"
if echo "$READINESS" | grep -qE '"primary_reachable"\s*:\s*true'; then
echo " Edge→primary connectivity OK"
else
echo "::error::Edge→primary internal API connectivity failed (INTERNAL_API_KEY mismatch or primary unreachable)"
FAIL=1
fi
if [ "$FAIL" -ne 0 ]; then
echo "::error::One or more smoke tests failed"
exit 1
fi
echo "All smoke tests passed"
- name: RU deploy summary
if: always() && env.SKIP_RU != 'true'
run: |
SHORT_SHA="${GITHUB_SHA:0:12}"
echo "## RU Edge Deploy" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Commit | \`$SHORT_SHA\` |" >> $GITHUB_STEP_SUMMARY
echo "| Server | \`${{ secrets.RU_PUBLIC_BASE_URL }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Health | [/health](${{ secrets.RU_PUBLIC_BASE_URL }}/health) |" >> $GITHUB_STEP_SUMMARY
echo "| Version | [/version.json](${{ secrets.RU_PUBLIC_BASE_URL }}/version.json) |" >> $GITHUB_STEP_SUMMARY