Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
873111aade |
@@ -5,7 +5,6 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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
-2
@@ -24,6 +24,5 @@ build/
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# local run without Docker: SQLite DB, uploads clone, demo repo
|
# local SQLite DB (bot without Docker)
|
||||||
*.db
|
*.db
|
||||||
data/
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ 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, руками, откат |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install lint fmt typecheck test cov up down logs ps run demo-repo
|
.PHONY: install lint fmt typecheck test cov up down logs ps
|
||||||
|
|
||||||
install:
|
install:
|
||||||
uv sync
|
uv sync
|
||||||
@@ -30,18 +30,3 @@ logs:
|
|||||||
|
|
||||||
ps:
|
ps:
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# Run the bot on this machine without Docker: SQLite (./tgit.db) and the settings from .env
|
|
||||||
run:
|
|
||||||
uv run python -m app
|
|
||||||
|
|
||||||
# A local uploads repo seeded from the template — try the bot without SourceCraft:
|
|
||||||
# make demo-repo → put the printed GIT_UPLOADS_URL into .env → make run
|
|
||||||
DEMO_REPO := $(CURDIR)/data/demo-uploads.git
|
|
||||||
demo-repo:
|
|
||||||
@test ! -e $(DEMO_REPO) || { echo "$(DEMO_REPO) already exists"; exit 1; }
|
|
||||||
@rm -rf data/demo-seed && mkdir -p data && cp -r infra/uploads-template data/demo-seed
|
|
||||||
@cd data/demo-seed && git init -q -b main && git add -A \
|
|
||||||
&& git -c user.name=tgit-bot -c user.email=tgit-bot@users.noreply.tgit commit -qm "init: uploads template"
|
|
||||||
@git clone -q --bare data/demo-seed $(DEMO_REPO) && rm -rf data/demo-seed
|
|
||||||
@echo "GIT_UPLOADS_URL=$(DEMO_REPO)"
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ 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"):
|
||||||
@@ -180,10 +179,8 @@ 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 += [1, 1]
|
sizes.append(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)
|
||||||
@@ -263,14 +260,6 @@ 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)
|
||||||
@@ -477,28 +466,17 @@ 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
|
||||||
confirm, markup = confirm_screen(draft)
|
builder = InlineKeyboardBuilder()
|
||||||
await msg.edit_text(confirm, reply_markup=markup)
|
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
||||||
case Act.COMMIT:
|
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT))
|
||||||
draft = await drafts.get(uid)
|
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message)
|
||||||
if draft is None or not draft.changes:
|
await msg.edit_text(confirm, reply_markup=builder.as_markup())
|
||||||
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)
|
||||||
@@ -571,12 +549,7 @@ 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)
|
screen, kb = await draft_screen(state, 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)
|
|
||||||
await message.answer(screen, reply_markup=kb)
|
await message.answer(screen, reply_markup=kb)
|
||||||
|
|
||||||
@router.message(Flow.content, F.text | F.document)
|
@router.message(Flow.content, F.text | F.document)
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ 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):
|
||||||
|
|||||||
+2
-10
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -55,15 +54,8 @@ 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)),
|
||||||
]
|
]
|
||||||
proxy = settings.telegram.proxy_url
|
bot = Bot(settings.telegram.bot_token.get_secret_value())
|
||||||
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
|
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
|
||||||
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:
|
||||||
|
|||||||
+1
-2
@@ -47,8 +47,7 @@ class CommitInfo:
|
|||||||
class UploadsRepo:
|
class UploadsRepo:
|
||||||
def __init__(self, settings: GitSettings) -> None:
|
def __init__(self, settings: GitSettings) -> None:
|
||||||
self._s = settings
|
self._s = settings
|
||||||
# absolute: git clone runs from the parent dir, a relative path would land one level deeper
|
self._dir = settings.clone_dir
|
||||||
self._dir = settings.clone_dir.resolve()
|
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._fetched_at: float | None = None
|
self._fetched_at: float | None = None
|
||||||
self._key_path = self._materialize_key()
|
self._key_path = self._materialize_key()
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ 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,8 +113,6 @@ 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: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
|
mem_limit: 256m
|
||||||
labels:
|
labels:
|
||||||
tgit.managed: "true"
|
tgit.managed: "true"
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@@ -129,11 +129,3 @@
|
|||||||
**Решение:** первый куб задачи `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`).
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Деплой бота
|
|
||||||
|
|
||||||
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` пропускается.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/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,7 +14,6 @@ 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,30 +63,3 @@ 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") == "быстро"
|
|
||||||
|
|||||||
@@ -83,14 +83,3 @@ async def test_unreachable_remote(tmp_path: Path) -> None:
|
|||||||
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
||||||
with pytest.raises(GitError, match="clone"):
|
with pytest.raises(GitError, match="clone"):
|
||||||
await broken.refresh()
|
await broken.refresh()
|
||||||
|
|
||||||
|
|
||||||
async def test_relative_clone_dir(remote: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
from app.config import GitSettings
|
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
relative = UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=Path("data/uploads")))
|
|
||||||
await relative.refresh()
|
|
||||||
|
|
||||||
assert (tmp_path / "data" / "uploads" / ".git").is_dir()
|
|
||||||
assert "README.md" in [e.name for e in await relative.list_dir()]
|
|
||||||
|
|||||||
@@ -74,19 +74,6 @@ 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"
|
||||||
@@ -655,15 +642,6 @@ 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"
|
||||||
@@ -785,7 +763,6 @@ 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" },
|
||||||
@@ -812,7 +789,6 @@ 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