deploy-caddy: сторож отставания прода, общий лок и квотирование путей (#3448)

Оживший быстрый путь снимает гард свежести :latest. Пока caddy_only был мёртв,
любой push шёл полным деплоем, и гард #2950 прикрывал прод по умолчанию. Теперь
при caddy_only=true джоба `deploy` пропускается целиком — вместе с гардом.

Сценарий отказа. Push A правит бэкенд, билды ~6 мин, `deploy` в очереди. Через
2 мин push B правит только caddy/. На Forgejo 10.0.3 ещё не стартовавшая
`deploy` предыдущего прогона отменяется ДАЖЕ при cancel-in-progress: false
(наблюдение 21.08.2026 10:35:13, шапка scripts/check-latest-image-revision.sh;
workflow-level concurrency там не исполняется, см. deploy.yml). Дифф A..B — один
caddy-файл, быстрый путь включается, `compose pull` + `up -d` не делает никто:
прод крутит старый образ, голова main зелёная, сигнала нет.

Сторож в ssh-скрипте deploy-caddy: если прод отстаёт от origin/main не только
по Caddyfile/caddy/**, быстрый путь запрещён, шаг падает и называет файлы.
Стоит ДО `git reset --hard` намеренно — при отказе прод-HEAD остаётся честным
для следующего прогона. У Trade-In для того же заведён отдельный маркер
(/opt/gendesign/.tradein-deployed-sha, deploy-tradein.yml), у ПТИЦЫ маркера нет,
и `git reset` делает прод-HEAD его эквивалентом.

ЧЕГО СТОРОЖ НЕ ЛОВИТ: `deploy`, упавшую ПОСЛЕ `git reset --hard` (например на
миграции). Тогда прод-HEAD уже равен новому коммиту, а контейнеры старые —
это остаётся за настоящим маркером «что задеплоено».

Тот же лок, что и у полного деплоя. deploy-caddy делает `git reset --hard` в
/opt/gendesign, то есть правит прод-дерево — ровно то, что job `deploy`
сериализует через flock /var/lock/gendesign-docker-deploy.lock. Пока путь был
мёртв, сталкиваться было нечему; теперь это первая джоба, трогающая прод-дерево
в обход сериализации.

Квотирование путей. `git diff --name-only` и `git ls-files` при core.quotePath
(умолчание true) отдают не-ASCII пути закавыченными с \NNN-экранированием —
`^backend/` такую строку не матчит, и файл backend/<кириллица>.py дал бы
backend=false. Старый paths-filter брал `--name-status -z`, где квотирования
нет: это единственное место, где переход на свой diff менял поведение. В дереве
такие пути уже живут (docs/Бизнес-план…). Добавлен `-c core.quotePath=false` в
обе команды и в сторож выше.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-12 15:56:24 +05:00
parent 091137a0c5
commit 9a8aaa2d2d

View file

@ -181,12 +181,12 @@ jobs:
fi
if [ -n "$BASE" ]; then
FILES=$(git diff --no-renames --name-only "$BASE" HEAD)
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
printf '%s\n' "$FILES" | sed 's/^/ /'
else
FILES=$(git ls-files)
FILES=$(git -c core.quotePath=false ls-files)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
fi
@ -1343,8 +1343,62 @@ jobs:
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
exec 9>/var/lock/gendesign-docker-deploy.lock
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
git fetch origin main
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
# конфиг прокси ────────────────────────────────────────────────────
#
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
#
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
# очереди; через 2 мин push B правит только caddy/. Forgejo на
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
# делает никто: прод крутит старый образ при зелёной голове main.
#
# Единственный источник правды о том, что реально на проде, — HEAD
# прод-дерева (у Trade-In для этого заведён отдельный маркер
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
# честным для следующего прогона.
#
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
# путём. Это остаётся за настоящим маркером «что задеплоено».
PROD_HEAD=$(git rev-parse HEAD)
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
echo "Запусти полный деплой через workflow_dispatch."
exit 1
fi
git reset --hard origin/main
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
# контейнер читает тот же файл, что только что обновил git.