name: Deploy Obsidian # Деплой ТОЛЬКО obsidian-стека (CouchDB). # Триггерится при изменениях: # - docker-compose.obsidian.yml (compose сервиса CouchDB) # - scripts/setup-couchdb.sh (bootstrap) # - docs/obsidian-livesync.md (документация — для history-watcher'а) # - этот workflow # # Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub). # Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy). # # ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub # (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала # на github.com с настроенными секретами не оказалось: 0 запусков за всю # историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер # не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда — # единственная директория, которую реально исполняет этот инстанс. # См. issue #2416. # ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── # Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на # Beget — SSH становится междоузловым, через интернет. Поэтому у вызова # appleboy/ssh-action ниже появился вход `fingerprint`. # ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT = # ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' # (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml). # ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод # строки при копипасте включает проверку и роняет ssh-шаг с `host key # fingerprint mismatch`. # ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy # v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR. # Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у # TRADEIN_INTERNAL_AUTH_SECRET (#2989). # АДРЕСАТ (#3062). CouchDB/Obsidian ОСТАЁТСЯ на Beget вместе с Forgejo и # GlitchTip, а DEPLOY_HOST после 30.08 будет указывать на Selectel. Раньше этот # workflow ходил на DEPLOY_HOST безусловно — то есть в день переезда молча начал # бы разворачивать стек CouchDB не на той машине: git reset на /opt/gendesign # продуктового хоста, а волт на Beget тем временем перестал бы обновляться. # Отказа при этом не было бы — деплой зелёный, адресат другой. # # Теперь адресат берётся как INFRA_DEPLOY_HOST, а если он не задан — DEPLOY_HOST. # До переезда это одна и та же машина, поэтому поведение не меняется; после — # workflow сам остаётся на инфраструктурном хосте, без правки этого файла. # # Отпечаток идёт В ПАРЕ с адресатом и БЕЗ перекрёстного фолбэка: сверять ключ # Beget'а с отпечатком Selectel'а — гарантированный отказ. Задан INFRA_DEPLOY_HOST # → берётся INFRA_DEPLOY_SSH_FINGERPRINT; не задан → DEPLOY_SSH_FINGERPRINT. # Пусто в выбранной ветке → проверка подлинности пропускается, как и раньше. # ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] paths: - "docker-compose.obsidian.yml" - "scripts/setup-couchdb.sh" - "docs/obsidian-livesync.md" - ".forgejo/workflows/deploy-obsidian.yml" workflow_dispatch: concurrency: group: deploy-obsidian cancel-in-progress: false jobs: deploy-obsidian: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — # «инертно по умолчанию, включается одной настройкой». Заведут секрет — # предупреждение исчезнет само. - name: Адресат и подлинность хоста (#3062, #3029) id: target env: INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} run: | set -euo pipefail # Отпечаток — публичный хеш ключа хоста, не секрет: его можно # передать через output. Приватный ключ так передавать нельзя, # поэтому он остаётся прямой ссылкой на секрет в шаге ниже. if [ -n "${INFRA_HOST:-}" ]; then echo "Адресат: INFRA_DEPLOY_HOST — хосты разъехались, стек CouchDB едет на инфраструктурный хост." HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}" FINGERPRINT_SOURCE="INFRA_DEPLOY_SSH_FINGERPRINT" else echo "Адресат: DEPLOY_HOST — INFRA_DEPLOY_HOST не задан, хосты ещё одна машина." HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}" FINGERPRINT_SOURCE="DEPLOY_SSH_FINGERPRINT" fi # $GITHUB_OUTPUT — формат «ключ=значение» построчно, поэтому перевод # строки внутри значения означает инъекцию произвольного output'а. # Отпечаток однострочный по определению (SHA256:...), а вот копипаста # в поле секрета лишний \n добавляет легко — шапка этого файла об этом # прямо предупреждает. Не вычищаем молча: сверка побайтовая, тихий trim # изменил бы результат проверки. Падаем с внятным текстом. case "${HOST_FINGERPRINT}" in *[![:print:]]*) echo "ОШИБКА: ${FINGERPRINT_SOURCE} содержит перевод строки или непечатный символ." >&2 echo "ОШИБКА: значение должно быть одной строкой вида SHA256:xxxx — перезадай секрет без лишних символов." >&2 exit 1 ;; esac echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT" if [ -n "${HOST_FINGERPRINT:-}" ]; then echo "Подлинность хоста: сверяется по ${FINGERPRINT_SOURCE}." else echo "::warning title=SSH без проверки подлинности хоста::${FINGERPRINT_SOURCE} не задан — ключ хоста НЕ проверяется (#3029). По каналу едет ssh-ключ и разворачивается стек CouchDB/Obsidian. После разъезда хостов (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку этого файла." echo '###############################################################' echo "# ВНИМАНИЕ (#3029): ${FINGERPRINT_SOURCE} не задан." echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' echo '# Как снять отпечаток — см. шапку этого файла.' echo '###############################################################' fi - name: Deploy obsidian stack via SSH uses: appleboy/ssh-action@v1.0.3 with: # #3062: адресат — инфраструктурный хост, если хосты уже разъехались. # До этого INFRA_DEPLOY_HOST пуст и всё идёт на DEPLOY_HOST, как раньше. host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} # user/key/port с фолбэком: у двух хостов они совпадают, а отдельные # INFRA_*-секреты может и не завести — тогда работают общие. username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} # #3029: подлинность хоста. Отпечаток выбран шагом выше В ПАРЕ с # адресатом — перекрёстного фолбэка здесь быть не должно, иначе после # переезда ключ Beget'а сверялся бы с отпечатком Selectel'а. # Пусто → easyssh-proxy оставляет ssh.InsecureIgnoreHostKey(), как сегодня. fingerprint: ${{ steps.target.outputs.fingerprint }} script: | set -euo pipefail cd /opt/gendesign # Свежие конфиги из репо git fetch origin main git reset --hard origin/main # Создать shared network если её ещё нет (idempotent). docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared # Загрузить COUCHDB_USER/PASSWORD из .env.runtime до compose up # (compose также читает .env.runtime через env_file, но # `${COUCHDB_PASSWORD:?...}` валидация требует переменную в shell). if [ -f backend/.env.runtime ]; then set -a # shellcheck source=/dev/null source backend/.env.runtime set +a fi if [ -z "${COUCHDB_PASSWORD:-}" ]; then echo "ERROR: COUCHDB_PASSWORD не задан в backend/.env.runtime" exit 1 fi # Стек CouchDB поднимается с собственным project-name. docker compose -p gendesign-obsidian \ -f docker-compose.obsidian.yml pull docker compose -p gendesign-obsidian \ -f docker-compose.obsidian.yml up -d # Bootstrap CouchDB (CORS, db, лимиты) — idempotent if [ -f scripts/setup-couchdb.sh ]; then # Загружаем COUCHDB_PASSWORD из backend/.env.runtime если есть if [ -f backend/.env.runtime ]; then set -a; source backend/.env.runtime; set +a fi # Ждём CouchDB up, потом bootstrap for i in $(seq 1 30); do if docker compose -p gendesign-obsidian \ -f docker-compose.obsidian.yml \ exec -T couchdb curl -fsS http://localhost:5984/_up >/dev/null 2>&1; then break fi sleep 2 done COUCHDB_HOST=http://localhost:5984 \ COUCHDB_USER="${COUCHDB_USER:-obsidian}" \ COUCHDB_PASSWORD="${COUCHDB_PASSWORD:?must be set in backend/.env.runtime}" \ docker compose -p gendesign-obsidian \ -f docker-compose.obsidian.yml \ exec -T couchdb bash -c "$(cat scripts/setup-couchdb.sh)" \ || echo "(bootstrap warnings ignored — script is idempotent)" fi # Caddy в main-stack — НЕ перезапускаем тут (другой workflow), # но reload конфига полезен на случай если в Caddyfile добавили # новый obsidian-route только что (главное: image main caddy уже # запущен и подключён к gendesign_shared network). if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet \ | grep -q .; then docker compose -p gendesign -f docker-compose.prod.yml \ exec -T caddy caddy reload --config /etc/caddy/Caddyfile \ || echo "(caddy reload skipped — main stack not running)" fi sleep 3 curl -fsS https://obsidian.gendsgn.ru/_up | head -c 200 || true