fix(prompts): v1.71.2 framing-aware assembly + trigger_pool expansion #454
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: 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 |