Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da01066d5d | ||
|
|
29f9cbe538 | ||
|
|
164f055c5e | ||
|
|
526eb56c82 | ||
|
|
df5224e813 | ||
|
|
12b2ca22a5 | ||
|
|
d1addd2c25 | ||
|
|
9e1d6322b6 | ||
|
|
595368b0a2 |
@@ -5,6 +5,7 @@
|
|||||||
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
|
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
|
||||||
ADMIN_IDS= # Telegram ID первых администраторов через запятую
|
ADMIN_IDS= # Telegram ID первых администраторов через запятую
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
|
||||||
|
|
||||||
# --- База данных ---
|
# --- База данных ---
|
||||||
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
|
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Гейт качества на каждый PR и push в dev/main — как в Vobot (.gitea/workflows/backend.yml).
|
||||||
|
# CD (.gitea/workflows/deploy.yml) тесты не гоняет: они прошли здесь, на PR.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev, main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
# Имя джоба — контекст статуса в PR; если включать branch protection, требовать именно его.
|
||||||
|
name: ruff · mypy · pytest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: tgit_test
|
||||||
|
POSTGRES_USER: tgit
|
||||||
|
POSTGRES_PASSWORD: tgit
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
# без маппинга портов: act_runner запускает job в контейнере, сервис доступен по имени `postgres`
|
||||||
|
# (опыт Vobot: маппинг 5432 на хост конфликтует с postgres самого Gitea)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
# pip, а не astral-sh/setup-uv: на self-hosted Gitea экшен ходит в GitHub API и падает (опыт Vobot)
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Ruff
|
||||||
|
run: |
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Mypy
|
||||||
|
run: uv run mypy
|
||||||
|
|
||||||
|
- name: Migrations on PostgreSQL — up, down, up
|
||||||
|
# pytest гоняет схему на SQLite; здесь — настоящий Postgres: JSONB, BIGINT, CHECK, откат
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://tgit:tgit@postgres:5432/tgit_test
|
||||||
|
run: |
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run alembic downgrade base
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
- name: Pytest (+ порог покрытия из pyproject)
|
||||||
|
run: |
|
||||||
|
git config --global user.name ci
|
||||||
|
git config --global user.email ci@ci
|
||||||
|
uv run pytest -q --cov --cov-report=term
|
||||||
@@ -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
|
||||||
@@ -49,6 +49,7 @@ Telegram-бот «git в телеграме» для команды (учебн
|
|||||||
| [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue |
|
| [`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/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/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты |
|
||||||
|
| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class Act(StrEnum):
|
|||||||
PUSH = "ps"
|
PUSH = "ps"
|
||||||
PUSH_GO = "pg"
|
PUSH_GO = "pg"
|
||||||
PUSH_FORCE = "pf"
|
PUSH_FORCE = "pf"
|
||||||
|
COMMIT = "cm"
|
||||||
|
|
||||||
|
|
||||||
class CommitCb(CallbackData, prefix="c"):
|
class CommitCb(CallbackData, prefix="c"):
|
||||||
@@ -179,8 +180,10 @@ def render_dir(
|
|||||||
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
|
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
|
||||||
sizes.append(2)
|
sizes.append(2)
|
||||||
if draft_count:
|
if draft_count:
|
||||||
|
# the main action once something is staged — right in the folder, not two screens deep
|
||||||
|
kb.button(text=t.commit_button.format(count=draft_count), callback_data=CommitCb(a=Act.COMMIT))
|
||||||
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
|
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
|
||||||
sizes.append(1)
|
sizes += [1, 1]
|
||||||
if path:
|
if path:
|
||||||
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
|
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
|
||||||
sizes.append(1)
|
sizes.append(1)
|
||||||
@@ -260,6 +263,14 @@ def build_router(
|
|||||||
kb.adjust(1)
|
kb.adjust(1)
|
||||||
return text, kb.as_markup()
|
return text, kb.as_markup()
|
||||||
|
|
||||||
|
def confirm_screen(draft: DraftView) -> tuple[str, InlineKeyboardMarkup]:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
||||||
|
builder.button(text=t.edit_message, callback_data=CommitCb(a=Act.MESSAGE))
|
||||||
|
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.TO_DIR))
|
||||||
|
builder.adjust(1)
|
||||||
|
return t.push_confirm.format(summary=summary(draft), message=draft.message), builder.as_markup()
|
||||||
|
|
||||||
def summary(draft: DraftView) -> str:
|
def summary(draft: DraftView) -> str:
|
||||||
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
|
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
|
||||||
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
|
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
|
||||||
@@ -466,17 +477,28 @@ def build_router(
|
|||||||
return
|
return
|
||||||
case Act.MESSAGE:
|
case Act.MESSAGE:
|
||||||
await state.set_state(Flow.message)
|
await state.set_state(Flow.message)
|
||||||
|
await state.update_data(commit_then_push=False)
|
||||||
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
||||||
case Act.PUSH:
|
case Act.PUSH:
|
||||||
draft = await drafts.get(uid)
|
draft = await drafts.get(uid)
|
||||||
if draft is None or not draft.changes or not draft.message:
|
if draft is None or not draft.changes or not draft.message:
|
||||||
await query.answer()
|
await query.answer()
|
||||||
return
|
return
|
||||||
builder = InlineKeyboardBuilder()
|
confirm, markup = confirm_screen(draft)
|
||||||
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
await msg.edit_text(confirm, reply_markup=markup)
|
||||||
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT))
|
case Act.COMMIT:
|
||||||
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message)
|
draft = await drafts.get(uid)
|
||||||
await msg.edit_text(confirm, reply_markup=builder.as_markup())
|
if draft is None or not draft.changes:
|
||||||
|
await query.answer()
|
||||||
|
return
|
||||||
|
if draft.message:
|
||||||
|
confirm, markup = confirm_screen(draft)
|
||||||
|
await msg.edit_text(confirm, reply_markup=markup)
|
||||||
|
else:
|
||||||
|
# ask for the message, then go straight to the push confirmation
|
||||||
|
await state.set_state(Flow.message)
|
||||||
|
await state.update_data(commit_then_push=True)
|
||||||
|
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
||||||
case Act.PUSH_GO | Act.PUSH_FORCE:
|
case Act.PUSH_GO | Act.PUSH_FORCE:
|
||||||
await query.answer()
|
await query.answer()
|
||||||
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
|
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
|
||||||
@@ -549,6 +571,11 @@ def build_router(
|
|||||||
return
|
return
|
||||||
await drafts.set_message(member.tg_id, text)
|
await drafts.set_message(member.tg_id, text)
|
||||||
await state.set_state(None)
|
await state.set_state(None)
|
||||||
|
draft = await drafts.get(member.tg_id)
|
||||||
|
if (await state.get_data()).get("commit_then_push") and draft is not None and draft.changes:
|
||||||
|
await state.update_data(commit_then_push=False)
|
||||||
|
screen, kb = confirm_screen(draft)
|
||||||
|
else:
|
||||||
screen, kb = await draft_screen(state, member.tg_id)
|
screen, kb = await draft_screen(state, member.tg_id)
|
||||||
await message.answer(screen, reply_markup=kb)
|
await message.answer(screen, reply_markup=kb)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
|
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
|
||||||
|
|
||||||
bot_token: SecretStr
|
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):
|
class AccessSettings(BaseSettings):
|
||||||
|
|||||||
+10
-2
@@ -5,6 +5,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
from app.access.service import AccessService
|
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(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
|
||||||
asyncio.create_task(cleanup_loop(sessions)),
|
asyncio.create_task(cleanup_loop(sessions)),
|
||||||
]
|
]
|
||||||
bot = Bot(settings.telegram.bot_token.get_secret_value())
|
proxy = settings.telegram.proxy_url
|
||||||
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
|
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:
|
try:
|
||||||
await dp.start_polling(bot, handle_signals=True)
|
await dp.start_polling(bot, handle_signals=True)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ class CommitTexts(_Strict):
|
|||||||
ask_message: str
|
ask_message: str
|
||||||
bad_message: str
|
bad_message: str
|
||||||
push_button: str
|
push_button: str
|
||||||
|
commit_button: str
|
||||||
|
edit_message: str
|
||||||
push_confirm: str
|
push_confirm: str
|
||||||
push_go: str
|
push_go: str
|
||||||
pushing: str
|
pushing: str
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ commit:
|
|||||||
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
|
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
|
||||||
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
|
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
|
||||||
push_button: "🚀 Push"
|
push_button: "🚀 Push"
|
||||||
|
commit_button: "🚀 Закоммитить · {count}"
|
||||||
|
edit_message: "✏️ Изменить сообщение"
|
||||||
push_confirm: |-
|
push_confirm: |-
|
||||||
Запушить в main?
|
Запушить в main?
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 256m
|
mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
|
||||||
labels:
|
labels:
|
||||||
tgit.managed: "true"
|
tgit.managed: "true"
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@@ -129,3 +129,11 @@
|
|||||||
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
|
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
|
||||||
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
|
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
|
||||||
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют.
|
**Последствия:** +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`).
|
||||||
|
|||||||
@@ -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` пропускается.
|
||||||
Executable
+34
@@ -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"
|
||||||
Executable
+36
@@ -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)"
|
||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"regex>=2024.7",
|
"regex>=2024.7",
|
||||||
"segno>=1.6",
|
"segno>=1.6",
|
||||||
|
"aiohttp-socks>=0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -63,3 +63,30 @@ async def test_message_then_push_lands_in_remote(tg: TgClient, texts: Texts, rem
|
|||||||
tg.session.requests.clear()
|
tg.session.requests.clear()
|
||||||
await tg.send(ADMIN, texts.menu.commit)
|
await tg.send(ADMIN, texts.menu.commit)
|
||||||
assert not any(label.startswith("🧾") for label in labels(tg.session.sent(SendMessage)[-1].reply_markup))
|
assert not any(label.startswith("🧾") for label in labels(tg.session.sent(SendMessage)[-1].reply_markup))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_commit_button_in_folder_asks_message_then_confirms(
|
||||||
|
tg: TgClient, texts: Texts, remote: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
t = texts.commit
|
||||||
|
await tg.send(ADMIN, texts.menu.commit)
|
||||||
|
await tg.press(ADMIN, cb(Act.NEW_FILE))
|
||||||
|
await tg.send(ADMIN, "quick.txt")
|
||||||
|
await tg.send(ADMIN, "быстро")
|
||||||
|
|
||||||
|
folder = tg.session.sent(SendMessage)[-1]
|
||||||
|
assert t.commit_button.format(count=1) in labels(folder.reply_markup)
|
||||||
|
|
||||||
|
await tg.press(ADMIN, cb(Act.COMMIT))
|
||||||
|
assert tg.session.sent(EditMessageText)[-1].text == t.ask_message
|
||||||
|
await tg.send(ADMIN, "feat: быстрый коммит")
|
||||||
|
|
||||||
|
confirm = tg.session.sent(SendMessage)[-1]
|
||||||
|
assert confirm.text.startswith("Запушить в main?") and "feat: быстрый коммит" in confirm.text
|
||||||
|
assert labels(confirm.reply_markup) == [t.push_go, t.edit_message, texts.menu.back]
|
||||||
|
|
||||||
|
await tg.press(ADMIN, cb(Act.PUSH_GO))
|
||||||
|
assert tg.session.sent(EditMessageText)[-1].text.startswith("✅ Запушено: ")
|
||||||
|
clone = tmp_path / "verify2"
|
||||||
|
git(tmp_path, "clone", "-q", str(remote), str(clone))
|
||||||
|
assert (clone / "quick.txt").read_text(encoding="utf-8") == "быстро"
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "aiosignal"
|
name = "aiosignal"
|
||||||
version = "1.4.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -763,6 +785,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiogram" },
|
{ name = "aiogram" },
|
||||||
|
{ name = "aiohttp-socks" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@@ -789,6 +812,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiogram", specifier = ">=3.13" },
|
{ name = "aiogram", specifier = ">=3.13" },
|
||||||
|
{ name = "aiohttp-socks", specifier = ">=0.9" },
|
||||||
{ name = "alembic", specifier = ">=1.13" },
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
{ name = "asyncpg", specifier = ">=0.29" },
|
{ name = "asyncpg", specifier = ">=0.29" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
|||||||
Reference in New Issue
Block a user