# Сторож расхождения «код на проде ↔ main» (#3029). # # ЗАЧЕМ ОТДЕЛЬНЫЙ ПРОГОН, А НЕ ШАГ В deploy.yml. Шаг внутри деплоя проверяет # только тот деплой, который запустился. 27.08.2026 прод сутки жил на старом # коммите ровно потому, что деплой НЕ доезжал: замена IP сервера (#3110) # осиротила секрет DEPLOY_HOST, deploy.yml падал на i/o timeout, а CI оставался # зелёным и PR продолжали мержиться. Проверять надо не «прошёл ли прогон», а # «совпадает ли то, что лежит на проде, с тем, что в main» — это независимый # вопрос, и задавать его надо по часам, а не по событию деплоя. # # Read-only: один SSH и `git rev-parse`. Ничего не деплоит и не меняет. name: deploy-drift on: workflow_dispatch: {} schedule: # Ежечасно в :23 — вне ровного часа, чтобы не толкаться со сторожами # бэкапов (те ходят в :00). - cron: '23 * * * *' # Правка самого сторожа проверяется сразу, а не через час. push: branches: [main] paths: - 'scripts/check-deploy-drift.sh' - '.forgejo/workflows/deploy-drift.yml' concurrency: group: deploy-drift cancel-in-progress: false jobs: drift: runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout repo uses: actions/checkout@v4 - name: Что ждём увидеть на проде id: expected run: | echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "epoch=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" echo "На main: $(git rev-parse --short HEAD) $(git log -1 --format=%s | cut -c1-60)" - name: Что лежит на проде id: deployed env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} # known_hosts, а не SHA256-отпечаток: здесь обычный openssh-клиент. # Секрет привязан к АДРЕСУ — после смены IP его надо переснять # (`ssh-keyscan -p <порт> <хост>`), иначе шаг молча уедет в «неизвестно». DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} run: | SSH_KEY_FILE=$(mktemp) echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE" chmod 600 "$SSH_KEY_FILE" 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") else SSH_HOST_OPTS=(-o StrictHostKeyChecking=no) echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан (#3029)." fi # `safe.directory` — каталог принадлежит деплой-пользователю, а git с # 2.35 отказывается работать в чужом репозитории. Без этого шаг вернул # бы пусто, и сторож сказал бы «неизвестно» вместо правды. RAW=$(ssh -i "$SSH_KEY_FILE" \ "${SSH_HOST_OPTS[@]}" \ -o ConnectTimeout=10 \ -o BatchMode=yes \ -p "${DEPLOY_PORT:-22}" \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ "git -c safe.directory=/opt/gendesign -C /opt/gendesign rev-parse HEAD 2>/dev/null || true" \ 2>/dev/null | tr -d '[:space:]' || true) rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" echo "sha=${RAW}" >> "$GITHUB_OUTPUT" if [ -n "$RAW" ]; then echo "На проде: ${RAW:0:12}" else echo "На проде: прочитать не удалось" fi - name: Вердикт env: DEPLOYED_SHA: ${{ steps.deployed.outputs.sha }} EXPECTED_SHA: ${{ steps.expected.outputs.sha }} EXPECTED_COMMIT_EPOCH: ${{ steps.expected.outputs.epoch }} # Деплой продукта идёт около десяти минут; тридцать — запас, чтобы # ежечасный сторож не кричал на деплой, который просто ещё в полёте. GRACE_MINUTES: '30' run: | chmod +x scripts/check-deploy-drift.sh set +e ./scripts/check-deploy-drift.sh rc=$? set -e case "$rc" in 0) exit 0 ;; 2) echo "::warning title=Состояние прода неизвестно::Сторож не смог прочитать SHA с прод-хоста (#3029)." exit 1 ;; *) echo "::error title=Прод отстал от main::Код из main на проде не работает (#3029)." exit 1 ;; esac