gendesign/.forgejo/workflows/deploy-tradein.yml
bot-backend 4cb8f32dcc
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 57s
CI Trade-In / frontend-checks (pull_request) Successful in 1m40s
CI / openapi-codegen-check (pull_request) Successful in 2m45s
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
CI / backend-tests (pull_request) Successful in 18m13s
Merge remote-tracking branch 'forgejo/main' into fix/2990-clean-start-initdb
2026-08-21 15:35:32 +03:00

1355 lines
95 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: Deploy Trade-In
# Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/.
# Триггерится только на изменения внутри tradein-mvp/ (или этого workflow),
# не пересекается с основным deploy.yml.
on:
push:
branches: [main]
paths:
- "tradein-mvp/**"
- ".forgejo/workflows/deploy-tradein.yml"
workflow_dispatch:
# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста.
# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-*
# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного
# сносит leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через
# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main
# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы).
# Разные группы + cancel-in-progress: false не спасают: false сериализует раны
# ВНУТРИ группы, а гонка была МЕЖДУ группами.
# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно:
# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная
# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок
# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию
# гарантирует планировщик Forgejo, залипать там нечему.
concurrency:
group: deploy-prod
cancel-in-progress: false
env:
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend
IMAGE_BROWSER: ghcr.io/lekss361/gendesign-tradein-browser
jobs:
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.set-all.outputs.backend || steps.filter.outputs.backend }}
frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }}
browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }}
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
app_version: ${{ steps.build-meta.outputs.app_version }}
build_sha: ${{ steps.build-meta.outputs.build_sha }}
build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
# проброшены как build-args в build-backend/build-frontend ниже (см.
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
# Считается ОДИН раз здесь, а не в каждой job отдельно.
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
id: build-meta
run: |
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
# we leave DEPLOYED_SHA empty — the next step will then build everything.
- name: Resolve deployed base SHA
id: resolve-base
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
# Write SSH key to a temp file
SSH_KEY_FILE=$(mktemp)
echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
# Try to read the marker file from the VPS. Suppress errors — if host is
# unreachable or file missing, RAW_SHA will be empty.
RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-p "${DEPLOY_PORT:-22}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \
2>/dev/null || true)
RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]')
rm -f "$SSH_KEY_FILE"
# Validate: non-empty, looks like a git SHA, and is an ancestor of HEAD.
DEPLOYED_SHA=""
if [ -n "$RAW_SHA" ] && echo "$RAW_SHA" | grep -qE '^[0-9a-f]{40}$'; then
if git merge-base --is-ancestor "$RAW_SHA" HEAD 2>/dev/null; then
DEPLOYED_SHA="$RAW_SHA"
echo "Resolved deployed base: $DEPLOYED_SHA"
else
echo "WARNING: stored SHA $RAW_SHA is not an ancestor of HEAD — falling back to build-all"
fi
else
echo "No valid deployed SHA found — falling back to build-all"
fi
echo "deployed_sha=$DEPLOYED_SHA" >> "$GITHUB_OUTPUT"
# FAIL-SAFE: if no valid base SHA, emit all=true and skip paths-filter.
# This covers: first run, post-force-push, VPS unreachable, corrupt marker.
# A spurious full build is always safer than a missed build.
- name: Build-all fallback (no base SHA)
id: set-all
if: steps.resolve-base.outputs.deployed_sha == ''
run: |
echo "No base SHA — enabling build-all"
echo "backend=true" >> "$GITHUB_OUTPUT"
echo "frontend=true" >> "$GITHUB_OUTPUT"
echo "browser=true" >> "$GITHUB_OUTPUT"
echo "infra=true" >> "$GITHUB_OUTPUT"
# Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges
# (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes.
- uses: dorny/paths-filter@v3
id: filter
if: steps.resolve-base.outputs.deployed_sha != ''
with:
base: ${{ steps.resolve-base.outputs.deployed_sha }}
filters: |
backend:
- 'tradein-mvp/backend/**'
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
- 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
# причина, что у backend выше.
- 'tradein-mvp/VERSION'
# /versions статически запекает CHANGELOG.md в билд (см.
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
# frontend/** иначе не долетала бы до образа.
- 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
- 'tradein-mvp/docker-compose.prod.yml'
- 'tradein-mvp/deploy/**'
- '.forgejo/workflows/deploy-tradein.yml'
# УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом
# «файлов, которые исполняет планировщик», и перечислял только то,
# что вспомнили. Дважды выстрелило одинаково:
# 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не
# до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**,
# house_dedup_merge.py) — залатали случай, не механизм;
# 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py
# в списке не значились → планировщик час крутил старый код,
# деплой при этом отчитался успехом.
# За июнь-август 48% (193 из 402) backend-мержей не попадали ни в
# один из путей списка, т.е. половина правок доезжала до scraper'а
# только со следующим «удачным» деплоем. Теперь пересоздание
# привязано не к списку файлов, а к факту пересборки образа —
# см. SCRAPER_RECREATE в job deploy.
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
test:
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
defaults:
run:
working-directory: ./tradein-mvp/backend
env:
# psycopg v3 requires a parseable URL at import time; never connected to.
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
steps:
- uses: actions/checkout@v4
with:
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
# depth=1 checkout тянет один sha и ветки main в клоне нет.
fetch-depth: 0
- name: Install uv
# Официальный standalone-инсталлер: системный `pip install uv` на
# ubuntu-runner падает с PEP 668 externally-managed-environment (#666 CI).
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync deps (incl. dev group — pytest)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml.
#
# Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с
# объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в
# /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на
# такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в
# pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё
# на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс
# дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, рядом с самим тестом.
#
# `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне —
# 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый
# пропуск роняет прогон через хук в tests/conftest.py.
run: uv run pytest -q -rs
build-backend:
runs-on: ubuntu-latest
needs: [changes, test]
if: |
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-backend
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# валит весь шаг ДО push нового образа — деплой тогда молча
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
# без cache-from — ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
# для editable install (#2137). Dockerfile — в backend/.
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
# ниже пропускается вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.frontend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
# поэтому копируем внутрь ДО build. /versions статически запекает его
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
# комментарий). Не влияет на кэш другого шага — читается только этим.
- name: Stage CHANGELOG.md into frontend build context
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# basePath=/trade-in baked-in во время build (Next.js)
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-browser:
runs-on: ubuntu-latest
needs: changes
# tradein-browser несёт camoufox + Firefox-build (#905). Триггерится на
# изменения browser/ или infra (compose ссылается на образ) или вручную.
if: |
needs.changes.outputs.browser == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-browser
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, test, build-backend, build-frontend, build-browser]
# NB: a failed `test` skips build-backend (result='skipped', not 'failure'),
# so we must block deploy on test failure explicitly (#666 quality gate).
if: |
always() &&
!cancelled() &&
needs.test.result != 'failure' &&
needs.build-backend.result != 'failure' &&
needs.build-frontend.result != 'failure' &&
needs.build-browser.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh.
# Пути = фильтры job'а changes (backend/frontend/browser + infra), которые
# приводят к сборке соответствующего образа.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
IMAGE_TAG: latest
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
GHCR_PAT: ${{ secrets.GHCR_PAT }}
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса,
# одна строка image, разный command). Значит вопрос «пересоздавать ли
# scraper» — это не «трогали ли его файлы», а «мог ли пересобраться
# образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend:
# backend || infra || workflow_dispatch. Ровно тогда в реестре мог
# появиться новый :latest, и оставить scraper на старом — значит
# оставить планировщик на старом коде (инцидент #2679).
#
# Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ
# пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс
# больше не нужен — с #1951 перед recreate'ом идёт graceful drain
# (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам
# `compose up -d` на неизменившемся образе — no-op.
SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
GITHUB_SHA: ${{ github.sha }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
# #2950: взаимное исключение докер-секции двух прод-деплоев.
# Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон —
# стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM.
# Каждый в конце делает `docker image prune -af`, и прун одного сносит
# leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через
# 0.4с после обрыва пула), прод остался на старом коде.
#
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
# concurrency не исполняется — проверено, обе цепочки стартовали на
# одном коммите одновременно. Она оставлена как декларация, которая
# заработает после обновления Forgejo; сегодня работает вот этот лок.
#
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
# докер-команды на хосте продолжат работу — и лок продолжит их
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
exec 9>/var/lock/gendesign-docker-deploy.lock
# Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе.
# Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить
# «второй деплой дождался первого» от «они просто разошлись по времени»
# нельзя — а именно это и есть критерий приёмки #2950.
if flock -n 9; then
echo "→ докер-лок свободен, взят сразу"
else
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
lock_wait_started=$(date +%s)
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
fi
cd /opt/gendesign
# repo уже clone'ен — origin = Forgejo. Подтягиваем последний main.
git fetch origin main
git reset --hard origin/main
cd tradein-mvp
# .env.runtime создаётся вручную при первом запуске (см. README-АДМИНУ.md).
# Здесь только подгружаем переменные для docker compose (POSTGRES_PASSWORD).
if [ ! -f .env.runtime ]; then
echo "ERROR: /opt/gendesign/tradein-mvp/.env.runtime отсутствует."
echo "Создай его вручную (см. tradein-mvp/README.md или DEPLOY.md)."
exit 1
fi
chmod 600 .env.runtime
set -a; source .env.runtime; set +a
# Re-assert +x на deploy-скриптах (#3005, по образцу deploy.yml ops/*.sh из #71).
# Cron зовёт backup-tradein-db.sh через `bash`, так что бит ему не нужен —
# но любой другой вызов сырым путём не должен зависеть от git-режима файла.
chmod +x deploy/*.sh 2>/dev/null || true
# External network для Caddy (он в основном gendesign-стеке)
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
# Re-login to GHCR (PAT может быть rotated)
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
# код на старой схеме». Откат = просто ничего не поднимали.
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
#
# `-h 127.0.0.1` ОБЯЗАТЕЛЕН (#2990) — по тому же образцу, что уже в
# ci-tradein.yml:157. На пустом томе образ postgres поднимает
# ВРЕМЕННЫЙ сервер с listen_addresses='' на время прогона
# docker-entrypoint-initdb.d (сюда смонтирован весь
# backend/data/sql/*.sql, см. docker-compose.prod.yml). Этот временный
# сервер отвечает "accepting connections" по unix-сокету уже через
# пару секунд — а pg_isready БЕЗ -h ходит именно по сокету через
# `docker compose exec`. Проба зеленела посреди initdb, до того как
# цепочка миграций реально доехала до конца, и код ниже (детект
# baseline vs пустая БД) видел частично накаченную схему. TCP-порт
# 5432 открывается только когда initdb.d полностью отработал и
# postgres перезапустился как настоящий сервер — проба по 127.0.0.1
# зеленеет ровно тогда, когда БД реально готова.
#
# 90 попыток × 2с = до 3 минут: на пустом томе postgres прогоняет
# ВСЮ цепочку миграций (270+ файлов) внутри initdb, это медленнее,
# чем ожидание живого сервера на непустом томе (обычный деплой).
echo "→ Ожидание готовности postgres..."
pg_ready=""
for i in $(seq 1 90); do
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
pg_isready -h 127.0.0.1 -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_ready="yes"; break
fi
sleep 2
done
if [ -z "$pg_ready" ]; then
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
exit 1
fi
echo "→ postgres ready."
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
# старте volume. Здесь — для повторных миграций после первого запуска.
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
# (миграции 001-076 живут в схеме). 7 из них (002/003/052/053/054/063/072)
# содержат INSERT-seed БЕЗ ON CONFLICT — повторный прогон под строгим
# ON_ERROR_STOP упал бы на PK violation. Поэтому: baseline (см. ниже).
migrations_table_existed=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public._schema_migrations') IS NOT NULL;" | tr -d '[:space:]')
# Sentinel (#2990): отсутствия _schema_migrations НЕДОСТАТОЧНО, чтобы
# заключить «схема уже накачена». Ровно два разных состояния дают одно
# и то же отсутствие таблицы:
# 1) наполненный прод до внедрения tracking → baseline корректен;
# 2) ПУСТАЯ БД на новом сервере → baseline пометил бы все миграции
# применёнными, ни одной не прогнав, и деплой уехал бы зелёным
# на пустой схеме. Отказ тихий и обнаружился бы уже под нагрузкой.
#
# Раньше различали одной живой таблицей listings — она создаётся
# миграцией 002, то есть почти в САМОМ НАЧАЛЕ цепочки. Этого мало: на
# пустом томе до фикса ожидания готовности (см. выше, -h 127.0.0.1)
# проба зеленела ПОСРЕДИ initdb, когда listings уже создан, а хвост
# цепочки — ещё нет; результат — тихий baseline недокачанной схемы.
# Фикс готовности эту гонку убирает (TCP открывается только после
# полного прохода initdb.d), но сентинел всё равно проверяем по ОБОИМ
# концам цепочки как defense-in-depth: если голова и хвост когда-нибудь
# разъедутся — это тот самый гоночный симптом, и его надо ловить явно,
# а не гадать.
#
# Хвост — houses_geog_gist_idx, индекс из миграции 270 (#2997, самая
# свежая на момент правки #2990). При добавлении новых миграций после
# 270 обнови этот сентинел на объект из новой последней миграции.
schema_head_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.listings') IS NOT NULL;" | tr -d '[:space:]')
schema_tail_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.houses_geog_gist_idx') IS NOT NULL;" | tr -d '[:space:]')
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"
if [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" != "t" ] && [ "$schema_tail_present" != "t" ]; then
# ПУСТАЯ БД: ни головы, ни хвоста цепочки — baseline пропускаем
# намеренно. Цикл ниже применит всю цепочку с нуля под
# ON_ERROR_STOP — это и есть штатный путь чистого старта на новом
# сервере (initdb.d уже должен был всё применить сам; этот цикл —
# подстраховка на случай, если монтирование почему-то не сработало).
echo "→ БД пуста (нет ни _schema_migrations, ни listings, ни хвоста цепочки) — baseline ПРОПУЩЕН,"
echo " вся цепочка миграций будет применена циклом ниже."
elif [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" = "t" ] && [ "$schema_tail_present" = "t" ]; then
# BASELINE: таблицы не было, но и голова, и хвост цепочки на месте →
# seed ВСЕ текущие миграции как applied БЕЗ их прогона. Это либо
# наполненный прод до внедрения tracking, либо чистый старт, где
# initdb.d уже честно доехал до конца сам (ожидание готовности это
# теперь гарантирует). В обоих случаях повторный прогон не нужен —
# помечаем текущим состоянием, чтобы под строгий gate попадали
# только НОВЫЕ миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно.
echo "→ _schema_migrations отсутствовала, но схема на месте целиком (голова + хвост) — baseline существующих миграций (без прогона)"
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
echo " baseline: $fname"
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c \
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
done
echo "Baseline complete — existing schema marked as applied."
elif [ "$migrations_table_existed" != "t" ]; then
# НЕОДНОЗНАЧНО: ровно один из концов цепочки на месте, второго нет,
# а _schema_migrations отсутствует. Это и есть симптом гонки
# готовности (см. комментарий выше) — молча баселайнить тут нельзя:
# либо схема реально недокачана (baseline пометил бы недостающий
# хвост как применённый без прогона), либо и то и другое пусто, но
# тогда tail-check не должен был сработать. Падаем громко, а не
# гадаем — новый app-код НЕ поднят, старые контейнеры не тронуты.
echo "ERROR: неоднозначное состояние схемы — _schema_migrations нет,"
echo " listings присутствует=${schema_head_present}, houses_geog_gist_idx присутствует=${schema_tail_present}."
echo " Похоже на недокачанную схему (гонка готовности postgres) —"
echo " baseline пропущен намеренно, чтобы не пометить недостающие"
echo " миграции применёнными без прогона. Прерываю деплой; нужен"
echo " ручной разбор состояния тома перед повторным запуском."
exit 1
fi
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
applied=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" | tr -d '[:space:]')
if [ "$applied" = "0" ]; then
echo "→ Applying migration: $fname"
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on \
< "$sql_file" \
|| { echo "FAILED on migration: $fname"; exit 1; }
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -c \
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
else
echo "✓ Already applied: $fname"
fi
done
echo "All migrations applied."
# (3b) Невалидные индексы после цикла (#2752). Оборванный
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
# на записи всё равно платится. Молчит это так (воспроизведено на
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
# «relation already exists, skipping», выходит с кодом 0 → миграция
# помечается применённой, а индекс остаётся невалидным навсегда.
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
# ловит и этот путь, и невалидные индексы любого другого
# происхождения (отменённый job, ручной CIC оператором).
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
if [ -n "$invalid_idx" ]; then
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
echo " пересоздать индекс и повторить деплой."
exit 1
fi
echo "✓ невалидных индексов нет."
# Bootstrap gendesign_reader password from env (post-migration, #976).
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
# .env.runtime already sourced above (set -a; source .env.runtime).
#
# ⚠️ psql variable substitution (:'pw') НЕ работает внутри -c
# (переменная доходит до сервера as literal → syntax error at ':').
# Решение: передаём SQL через stdin (< file), psql интерполирует
# :'pw' ВНЕ dollar-quoted блока. Детали: ops/db-bootstrap/set_gendesign_reader_password.sql.
if [ -n "${TRADEIN_READER_PASSWORD:-}" ]; then
echo "→ Applying gendesign_reader password from env"
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on \
-v "pw=${TRADEIN_READER_PASSWORD}" \
< ops/db-bootstrap/set_gendesign_reader_password.sql
else
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# (4) Теперь — новый app-код: схема уже актуальна.
#
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
#
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
# заранее в $SERVICES), а не двумя последовательными командами.
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
# инициировать recreate. Таймаут не блокирует деплой навсегда —
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
# финальной страховкой для того, что не успело дойти до checkpoint'а.
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
# recreate'ом (старый контейнер физически не может писать heartbeat
# после своей остановки) — не ждём 6h периодического reap_zombies().
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
# Порог — по метке времени конкретного recreate, а не по фиксированному
# интервалу: не зависит от heartbeat-каденса разных источников и не
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
# истинно и на infra-правках (compose / workflow / deploy/**), а те
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
# образе бежит scraper, что уже лежит в локальном демоне.
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
# 'cancelled', продолжая работать).
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
echo " drain пропускаем, in-flight прогоны не трогаем"
else
scraper_stale="yes"
fi
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
# прогона подчистит периодический 6h zombie-reaper.
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
if [ -n "$scraper_stale" ]; then
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
echo " ждём слива in-flight scrape_runs (до 5 мин)"
drained=""
for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
# прогон психgl молча читается как «слито», и graceful drain
# становится no-op именно в момент проблем с БД во время деплоя.
running_count=""
psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \
&& running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then
drained="yes"; break
fi
if [ -z "$running_count" ]; then
echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)"
else
echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)"
fi
sleep 10
done
if [ -n "$drained" ]; then
echo "→ Активных run'ов нет — recreate scraper безопасен."
else
echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится."
echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;"
echo " startup-reap ниже подчистит то, что не успеет."
fi
# Checkpoint по часам БД (не раннера) прямо перед recreate.
# NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и
# внутренний пробел между датой и временем ("2026-07-04 06:43" →
# "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча
# (см. WARNING-фолбэк ниже). sed убирает только leading/trailing.
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
# — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован
# самим Postgres (SELECT NOW()), не внешний ввод → безопасно.
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
UPDATE scrape_runs
SET status = 'cancelled',
finished_at = NOW(),
error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})'
WHERE status = 'running'
AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz);
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
fi
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
# Требовался рестарт для повторной попытки хука. Теперь backend
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
# Caddy reload — основной Caddyfile содержит inline tradein routes
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
# tradein-backend / tradein-frontend (они в gendesign_shared network).
cd /opt/gendesign
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
# доходил до записи success-маркера → мёртвый backend помечался
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
# ниже) → следующий прогон changes-job возьмёт корректную базу.
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
healthy=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Frontend health check — раньше проверялся ТОЛЬКО backend: сломанный
# фронт (500/белый экран после build, или контейнер упавший на старте)
# помечался успешным деплоем, отката не происходило (см. заголовок
# секции выше). Проверяем изнутри backend-контейнера — он в одной
# tradein-net сети с frontend, и curl там уже есть (в отличие от
# node:alpine рантайм-образа frontend, где нет ни curl, ни wget —
# добавлять их туда ради healthcheck не стали, backend достаточно).
# Путь ОБЯЗАН включать /trade-in: basePath запечён в prod-образ на
# build (NEXT_PUBLIC_BASE_PATH=/trade-in, см. build-frontend job) —
# голый "/" внутри Next вернёт 404, а не что-то живое. "/trade-in/"
# редиректит (307) на /trade-in/v2 — curl -f не считает 3xx ошибкой,
# так что это чистая liveness-проверка (процесс жив и роутит),
# без привязки к тому, что именно сейчас показывает витрина.
frontend_healthy=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://frontend:3000/trade-in/ >/dev/null 2>&1; then
frontend_healthy="yes"; break
fi
sleep 1
done
if [ -z "$frontend_healthy" ]; then
echo "ERROR: frontend не ответил на /trade-in/ за 30s — деплой FAILED"
exit 1
fi
echo "→ frontend healthy на /trade-in/."
# Browser health check — /health в browser/server.py всегда 200, пока
# жив сам aiohttp-процесс (см. health_handler: "compose НЕ имеет
# healthcheck на browser, только depends_on: service_started" — до
# этой правки browser вообще не проверялся никаким деплой-шагом).
# Это liveness процесса, НЕ readiness camoufox-инстансов конкретных
# источников (те поднимаются лениво на первый /fetch) — но упавший
# при старте контейнер (например, битый образ) здесь ловится сразу,
# а не молча остаётся мёртвым до первого реального /fetch scraper'ом.
browser_healthy=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://browser:3000/health >/dev/null 2>&1; then
browser_healthy="yes"; break
fi
sleep 1
done
if [ -z "$browser_healthy" ]; then
echo "ERROR: browser не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ browser healthy на /health."
# tgbot/scraper — те же backend-образ и Dockerfile, но bare python-
# процессы БЕЗ ASGI/HTTP-сервера (см. комментарии в tgbot_main.py /
# scheduler_main.py: "здесь нет ASGI-приложения"), поэтому HTTP-
# healthcheck для них невозможен в принципе. Liveness проверяем по
# состоянию контейнера через docker inspect: упавший на старте
# процесс (например, ImportError в новом коде) restart-policy
# unless-stopped уводит в бесконечный crash-loop — раньше это НИКАК
# не блокировало деплой (маркер писался, даже если tgbot/scraper
# были мертвы). Двойная проверка (running → пауза → снова running)
# снижает шанс поймать контейнер ровно в момент between-restarts
# промежуточного "running" внутри crash-loop.
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
# scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому
# проверяем только то, что реально входит в текущий $SERVICES.
for svc in tgbot scraper; do
case " $SERVICES " in
*" $svc "*) ;;
*) continue ;;
esac
container_ok=""
state="unknown"
for i in $(seq 1 15); do
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
if [ "$state" = "running" ]; then
container_ok="yes"; break
fi
sleep 1
done
if [ -n "$container_ok" ]; then
sleep 3
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
if [ "$state" != "running" ]; then
container_ok=""
fi
fi
if [ -z "$container_ok" ]; then
echo "ERROR: tradein-$svc не в стабильном состоянии running (state='$state') — деплой FAILED"
exit 1
fi
echo "→ tradein-$svc running."
done
# Сверка образов backend-семейства (#2679) — последняя проверка перед
# маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ
# gendesign-tradein-backend; backend пересоздаётся на каждом деплое
# (безусловно в $SERVICES) и потому всегда несёт свежий :latest —
# он и есть эталон. Если у scraper или tgbot image ID другой, значит
# контейнер остался на старом коде, а деплой без этой проверки
# отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до
# tradein-backend, ff98603ba3cc; tradein-scraper остался на
# da26154c64a6 часовой давности — а планировщик, единственный
# исполнитель домовой оценки, живёт именно там).
# Падаем, а не warning'уем: расхождение = правка не работает, и
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
# по-разному, поэтому сообщения различаются явно.
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
image_mismatch=""
if [ -z "$backend_image" ]; then
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
image_mismatch="yes"
fi
for svc in scraper tgbot; do
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
if [ -z "$svc_image" ]; then
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
echo " образ, а неполный стек: сервис не работает вообще."
image_mismatch="yes"
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
image_mismatch="yes"
fi
done
if [ -n "$image_mismatch" ]; then
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
echo " docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps backend scraper tgbot"
exit 1
fi
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do
docker images "$repo" --format '{{.Repository}}:{{.Tag}}' \
| grep -v ':latest$' | tail -n +3 \
| xargs -r docker rmi 2>/dev/null || true
done
docker image prune -af || true
# Mark this SHA as successfully deployed.
# Written LAST — only after all of the above completed without error.
# The changes job reads this file on the next run to compute cumulative diff.
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда `test` или один из build-* падает (например,
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
# кроме отмены прогона) и сама падает, если deploy не завершился success —
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
#
# ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая
# видит периметр целиком (короткие адреса, 301 с длинных, публичный API,
# закрытость B2B-путей на публичном домене). До этого PR он запускался только
# по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной
# прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это
# единственный настоящий гейт — и он был асинхронным.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и
# «периметр цел» — два разных факта, и красный смоук не должен читаться как
# неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что
# именно получилось.
#
# ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт
# МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый.
# `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены
# осознанно вместо зависимости, которая может молча не сработать.
perimeter-smoke:
runs-on: ubuntu-latest
needs: deploy
# Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а
# красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда.
if: always() && needs.deploy.result == 'success'
timeout-minutes: 6
steps:
- uses: actions/checkout@v4
- name: Дождаться, пока периметр отвечает после пересоздания контейнеров
# `up -d --force-recreate` возвращает управление раньше, чем бэкенд
# начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку.
# Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) —
# одного мало, Caddy отвечает раньше апстрима.
run: |
set -uo pipefail
for i in $(seq 1 30); do
page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true)
api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true)
if [ "$page" = "200" ] && [ "$api" = "401" ]; then
echo "периметр отвечает (попытка $i): лэндинг $page, API $api"
exit 0
fi
echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'"
sleep 5
done
# НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания.
# Иначе «не успел подняться» и «периметр сломан» слились бы в один
# красный шаг без разбора.
echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом"
- name: Смоук периметра
run: |
chmod +x scripts/smoke-mera-perimeter.sh
./scripts/smoke-mera-perimeter.sh
deploy-status:
runs-on: ubuntu-latest
needs: [test, build-backend, build-frontend, build-browser, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "test: ${{ needs.test.result }}"
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "build-browser: ${{ needs.build-browser.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"