Merge pull request 'feat(infra): CD бота на сервер Vobot через Gitea Actions' (#43) from ci/12-cd into dev
CD / deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }} (push) Successful in 54s

Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
2026-10-17 10:49:44 +03:00
12 changed files with 208 additions and 3 deletions
+1
View File
@@ -5,6 +5,7 @@
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
ADMIN_IDS= # Telegram ID первых администраторов через запятую
LOG_LEVEL=INFO
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
# --- База данных ---
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
+45
View File
@@ -0,0 +1,45 @@
name: CD
# Как в Vobot: тесты здесь не гоняем — они прошли в CI на PR.
# dev → /srv/tgit/dev, main → /srv/tgit/prod на хосте Vobot (infra/deploy/deploy.sh).
# Окружение без /srv/tgit/<env>/.env на сервере пропускается (prod — когда будет свой токен бота).
on:
push:
branches: [dev, main]
workflow_dispatch: {}
# Деплои одной ветки — строго по очереди, без отмены.
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
name: deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
runs-on: ubuntu-latest
env:
DEPLOY_ENV: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
steps:
- uses: actions/checkout@v4
# Серия мержей подряд: устаревший прогон сам сворачивается, задеплоит актуальный (опыт Vobot)
- id: guard
name: "Актуален ли ещё этот пуш?"
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "go=true" >> "$GITHUB_OUTPUT"; exit 0; fi
REMOTE_SHA=$(git ls-remote origin "refs/heads/${{ github.ref_name }}" | cut -f1)
if [ "$REMOTE_SHA" = "${{ github.sha }}" ]; then echo "go=true" >> "$GITHUB_OUTPUT"
else echo "⏭ ${{ github.sha }} уже не HEAD ${{ github.ref_name }} — пропускаю"; echo "go=false" >> "$GITHUB_OUTPUT"; fi
- name: Deploy
if: steps.guard.outputs.go == 'true'
env:
# Через env:, а не инлайном в run: — многострочный ключ иначе может попасть в лог (опыт Vobot)
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
export DEPLOY_SSH_OPTS="-F /dev/null -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
infra/deploy/deploy.sh "$DEPLOY_ENV" HEAD
+1
View File
@@ -49,6 +49,7 @@ Telegram-бот «git в телеграме» для команды (учебн
| [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue |
| [`docs/dev/GITEA_API.md`](docs/dev/GITEA_API.md) | Gitea API: issue, PR, зависимости, зеркало |
| [`docs/dev/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты |
| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат |
---
+2
View File
@@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
bot_token: SecretStr
# http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron)
proxy_url: SecretStr | None = None
class AccessSettings(BaseSettings):
+10 -2
View File
@@ -5,6 +5,7 @@ import contextlib
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from app import __version__
from app.access.service import AccessService
@@ -54,8 +55,15 @@ async def run(settings: Settings) -> None:
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
asyncio.create_task(cleanup_loop(sessions)),
]
bot = Bot(settings.telegram.bot_token.get_secret_value())
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
proxy = settings.telegram.proxy_url
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session)
log.info(
"tgit %s starting, %d bootstrap admin(s), telegram %s",
__version__,
len(settings.access.admin_ids),
"via proxy" if proxy else "direct",
)
try:
await dp.start_polling(bot, handle_signals=True)
finally:
+1 -1
View File
@@ -12,7 +12,7 @@ services:
postgres:
condition: service_healthy
restart: unless-stopped
mem_limit: 256m
mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
labels:
tgit.managed: "true"
logging:
+8
View File
@@ -129,3 +129,11 @@
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют.
## ADR-015: CI/CD бота — на Gitea Actions (как в Vobot)
**Дата:** 2026-10-16 · **Статус:** принято (меняет ADR-010 в части CI/CD бота)
**Контекст:** SourceCraft отложен: ключ бота там не принимается, триггеры зависят от основной ветки, раннеры с очередью до минут. Гейт и деплой нужны сейчас.
**Решение:** CI (`.gitea/workflows/ci.yml`: ruff, mypy, миграции на Postgres, pytest) и CD (`.gitea/workflows/deploy.yml` → `infra/deploy/deploy.sh`) — на Gitea Actions, как у Vobot и meditron, на общих раннерах Gitea. Деплой — на хост Vobot, `dev`/`prod` по веткам, через пользователя `tgit-deploy`. Репозиторий загрузок на сервере — временно `HSE/tgit-uploads` в Gitea.
**Альтернативы:** ждать SourceCraft — блокирует проверку и демо.
**Последствия:** `.sourcecraft/ci.yaml` бота остаётся для будущего возврата; при возврате к SourceCraft меняются `GIT_UPLOADS_URL` и ключ, код не трогаем. На RF-сервере Telegram — только через прокси (`TELEGRAM_PROXY_URL`).
+45
View File
@@ -0,0 +1,45 @@
# Деплой бота
CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` → окружение **dev**, push в `main` → **prod**.
Под капотом — `infra/deploy/deploy.sh`, тот же скрипт работает руками и для любого Docker-хоста.
## Сервер (хост Vobot, 72.56.247.166)
| Что | Где |
|---|---|
| Пользователь | `tgit-deploy` (группа `docker`), ключ CI в `~/.ssh/authorized_keys` |
| Окружение | `/srv/tgit/<env>/` — `.env` (только здесь, 600), `app/` (текущий релиз), `app.old/` (предыдущий), `REVISION` |
| Контейнеры | Compose-проект `tgit-<env>`: `tgit-<env>-bot-1`, `tgit-<env>-postgres-1`; volumes `pgdata`, `uploads` |
| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) |
| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью |
Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`).
## Руками
```bash
DEPLOY_SSH_OPTS="-F /dev/null -i ~/.ssh/tgit_ci_deploy -o IdentitiesOnly=yes" infra/deploy/deploy.sh dev HEAD
```
Другой сервер: `DEPLOY_HOST=user@host`, на нём — Docker, пользователь в группе `docker`, `/srv/tgit/<env>/.env`.
## Проверить
```bash
ssh tgit-deploy@72.56.247.166 'cat /srv/tgit/dev/REVISION; docker ps --filter name=tgit-dev; docker logs --tail 20 tgit-dev-bot-1'
```
## Откат
Ошибка health при деплое печатает команду; руками:
```bash
ssh tgit-deploy@72.56.247.166
cd /srv/tgit/dev && rm -rf app && mv app.old app && cd app && docker compose -p tgit-dev up -d --build
```
## Завести prod
Нужен **отдельный токен бота** (один токен — один процесс polling): создать бота у @BotFather →
`/srv/tgit/prod/.env` по образцу dev (`TELEGRAM_BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, `GIT_UPLOADS_URL`,
`GIT_SSH_KEY_B64`, `TELEGRAM_PROXY_URL`, `LLM_PROVIDER`). Пока файла нет, деплой `main` пропускается.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Deploy a git ref of the bot to a server. Used by hand and by CD (.gitea/workflows/deploy.yml).
# infra/deploy/deploy.sh <dev|prod> [git-ref] default ref: HEAD
# Env: DEPLOY_HOST (default tgit-deploy@72.56.247.166 — the vobot host; any Docker host works)
# DEPLOY_SSH_OPTS (extra ssh/scp options, e.g. -i key)
# Server layout: /srv/tgit/<env>/{.env, app/, app.old/, REVISION}; secrets live only in /srv/tgit/<env>/.env.
# The host drops some ssh connections — every remote step is retried, and each one is safe to repeat.
set -euo pipefail
ENV=${1:?usage: deploy.sh <dev|prod> [ref]}
REF=${2:-HEAD}
case "$ENV" in dev | prod) ;; *) echo "env must be dev or prod" >&2; exit 2 ;; esac
HOST=${DEPLOY_HOST:-tgit-deploy@72.56.247.166}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/tgit/$ENV
SHA=$(git rev-parse --short "$REF")
TGZ=$(mktemp --suffix=.tgz)
trap 'rm -f "$TGZ"' EXIT
git archive --format=tar.gz -o "$TGZ" "$REF"
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 5)); done; }
# shellcheck disable=SC2086
if ! retry ssh $OPTS "$HOST" "test -f $DIR/.env"; then
echo "::notice::$HOST:$DIR/.env not found — environment '$ENV' is not configured, skipping deploy"
exit 0
fi
echo "deploy $REF ($SHA) -> $HOST:$DIR"
# shellcheck disable=SC2086
retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release-$SHA.tgz"
# shellcheck disable=SC2086
retry scp -q $OPTS infra/deploy/remote.sh "$HOST:$DIR/remote.sh"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash $DIR/remote.sh $ENV $SHA"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Runs on the server: unpack release-<sha>.tgz, swap app/ atomically, compose up, wait for healthy.
# bash /srv/tgit/<env>/remote.sh <env> <sha> (uploaded by deploy.sh; safe to re-run)
set -euo pipefail
ENV=$1
SHA=$2
DIR=/srv/tgit/$ENV
PROJECT=tgit-$ENV
cd "$DIR"
if [ -f "release-$SHA.tgz" ]; then
rm -rf app.new && mkdir app.new && tar -xzf "release-$SHA.tgz" -C app.new && rm -f "release-$SHA.tgz"
cp .env app.new/.env
rm -rf app.old
[ -d app ] && mv app app.old
mv app.new app
fi
cd app
docker compose -p "$PROJECT" up -d --build --remove-orphans </dev/null
status=""
for _ in $(seq 1 40); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$PROJECT-bot-1" 2>/dev/null || true)
[ "$status" = healthy ] && break
sleep 3
done
if [ "$status" != healthy ]; then
echo "bot is '$status' after 2 min — last logs:" >&2
docker logs --tail 50 "$PROJECT-bot-1" >&2 || true
echo "rollback: cd $DIR && rm -rf app && mv app.old app && cd app && docker compose -p $PROJECT up -d --build" >&2
exit 1
fi
echo "$SHA" > ../REVISION
docker image prune -f >/dev/null
echo "bot healthy @ $SHA ($ENV)"
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"pyyaml>=6.0",
"regex>=2024.7",
"segno>=1.6",
"aiohttp-socks>=0.9",
]
[dependency-groups]
Generated
+24
View File
@@ -74,6 +74,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
name = "aiohttp-socks"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -642,6 +655,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-socks"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -763,6 +785,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "httpx" },
@@ -789,6 +812,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.13" },
{ name = "aiohttp-socks", specifier = ">=0.9" },
{ name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "httpx", specifier = ">=0.27" },