All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 12s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 52s
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Obsidian / deploy-obsidian (push) Successful in 2m15s
Deploy / deploy (push) Successful in 1m53s
Deploy Trade-In / build-frontend (push) Successful in 2m44s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 47s
Deploy Trade-In / deploy (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
1453 lines
105 KiB
YAML
1453 lines
105 KiB
YAML
name: Deploy Trade-In
|
||
|
||
# Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/.
|
||
# Триггерится только на изменения внутри tradein-mvp/ (или этого workflow),
|
||
# не пересекается с основным deploy.yml.
|
||
|
||
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
|
||
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а Forgejo и раннеры
|
||
# оставляет на Beget — SSH перестаёт быть петлёй и идёт через интернет. В этом
|
||
# workflow ДВА разных SSH-канала, и закрываются они по-разному:
|
||
# 1) шаг "Deploy via SSH" (appleboy/ssh-action) → вход `fingerprint`,
|
||
# секрет DEPLOY_SSH_FINGERPRINT;
|
||
# 2) шаг "Resolve deployed base SHA" — обычный openssh-клиент, ему нужен
|
||
# known_hosts, а не SHA256-строка → секрет DEPLOY_KNOWN_HOSTS.
|
||
# Как снять значения:
|
||
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
|
||
# → DEPLOY_SSH_FINGERPRINT (с префиксом `SHA256:`; почему именно ecdsa —
|
||
# см. разбор в deploy.yml: дефолт x/crypto ставит ecdsa выше ed25519)
|
||
# ssh-keyscan -p <порт> <хост>
|
||
# → DEPLOY_KNOWN_HOSTS (все типы ключей сразу, без -t)
|
||
# ПОБАЙТОВО: fingerprint сравнивается как есть, без trim — лишний пробел или
|
||
# перевод строки при копипасте включает проверку и роняет ssh-шаг с `host key
|
||
# fingerprint mismatch`.
|
||
# ПОКА СЕКРЕТЫ НЕ ЗАДАНЫ — поведение прежнее в обоих каналах: пустой fingerprint
|
||
# у easyssh-proxy v1.5.0 это ssh.InsecureIgnoreHostKey(), а второй шаг остаётся
|
||
# на StrictHostKeyChecking=no, но печатает громкое предупреждение. Включается
|
||
# одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
|
||
# TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем — только добавляем.
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
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 }}
|
||
# #3029: строки known_hosts прод-хоста. DEPLOY_SSH_FINGERPRINT здесь НЕ
|
||
# подходит: ниже обычный openssh-клиент, а не Go-клиент ssh-action'а, и
|
||
# SHA256-отпечаток он на вход не принимает — ему нужен known_hosts.
|
||
# Получить: ssh-keyscan -p <порт> <хост> (без -t: пусть в секрете лежат
|
||
# все типы ключей сразу, тогда выбор алгоритма клиентом ничего не ломает).
|
||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||
run: |
|
||
# Write SSH key to a temp file
|
||
SSH_KEY_FILE=$(mktemp)
|
||
echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE"
|
||
chmod 600 "$SSH_KEY_FILE"
|
||
|
||
# #3029: подлинность хоста для ЭТОГО канала. Раньше здесь стояло
|
||
# безусловное -o StrictHostKeyChecking=no, то есть ключ хоста не
|
||
# проверялся никогда. После переезда (#3057) соединение идёт через
|
||
# интернет, поэтому: секрет задан → пишем known_hosts и требуем
|
||
# StrictHostKeyChecking=yes; не задан → оставляем ровно сегодняшнее
|
||
# поведение, но ГРОМКО об этом сообщаем. Инертно по умолчанию: пустой
|
||
# секрет = поведение до этого PR бит в бит.
|
||
KNOWN_HOSTS_FILE=$(mktemp)
|
||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$KNOWN_HOSTS_FILE"
|
||
chmod 600 "$KNOWN_HOSTS_FILE"
|
||
SSH_HOST_OPTS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE")
|
||
echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS."
|
||
else
|
||
SSH_HOST_OPTS=(-o StrictHostKeyChecking=no)
|
||
echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>."
|
||
echo "################################################################"
|
||
echo "# ВНИМАНИЕ (#3029): DEPLOY_KNOWN_HOSTS не задан. #"
|
||
echo "# Подлинность прод-хоста НЕ проверяется — канал уязвим к MITM. #"
|
||
echo "# Задать секрет: ssh-keyscan -p <порт> <хост> #"
|
||
echo "################################################################"
|
||
fi
|
||
|
||
# Try to read the marker file from the VPS. Suppress errors — if host is
|
||
# unreachable or file missing, RAW_SHA will be empty.
|
||
# #3029: сюда же попадает и расхождение ключа хоста. Шаг fail-safe по
|
||
# построению — пустой RAW_SHA уводит в build-all ниже, — поэтому цена
|
||
# ошибки в known_hosts здесь максимум лишняя полная пересборка, а не
|
||
# сорванный деплой. Это и делает включение проверки безопасным.
|
||
SSH_ERR_FILE=$(mktemp)
|
||
RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \
|
||
"${SSH_HOST_OPTS[@]}" \
|
||
-o ConnectTimeout=10 \
|
||
-p "${DEPLOY_PORT:-22}" \
|
||
"${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||
"cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \
|
||
2>"$SSH_ERR_FILE" || true)
|
||
RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]')
|
||
|
||
# #3029: раньше stderr уходил в /dev/null, и «Host key verification
|
||
# failed» был неотличим от недоступного хоста — неверный known_hosts
|
||
# молча читался как штатный фолбэк на полную пересборку. Теперь эта
|
||
# причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё
|
||
# равно пуст, ветка build-all включается ровно как прежде.
|
||
if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then
|
||
echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe: сейчас включится полная пересборка.'
|
||
echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность."
|
||
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
|
||
elif [ -s "$SSH_ERR_FILE" ]; then
|
||
echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):"
|
||
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
|
||
fi
|
||
|
||
rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" "$SSH_ERR_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
|
||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||
# предупреждение исчезнет само.
|
||
- name: Подлинность хоста — статус проверки (#3029)
|
||
env:
|
||
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
|
||
else
|
||
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.'
|
||
echo '###############################################################'
|
||
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
|
||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||
echo '###############################################################'
|
||
fi
|
||
|
||
- 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 }}
|
||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||
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 "✓ деплой прошёл успешно"
|