feat(pipeline): уведомления CI/CD — PipelineWatcher, CI загрузок на Gitea Actions
CI / ruff · mypy · pytest (pull_request) Failing after 3m40s

- app/pipeline: протокол CIProvider, клиент Gitea Actions (прогон по head_sha, упавший
  шаг, лог джоба, ретраи), PipelineWatcher — опрос только при незавершённых push,
  сообщение о push обновляется CI ⏳ → ✅/❌ → CD 🚀/⛔, автору — отдельные уведомления
- деплой сервисов — только после зелёного CI; старый push не откатывает сервис,
  который развернёт более новый; после рестарта бот продолжает с того же места
- CI_PROVIDER=none — прежнее поведение (деплой сразу после push)
- шаблон tgit-uploads: .gitea/workflows/check.yml; .gitea/ защищён от правок из бота
- миграция 0002: старые push помечены skipped, чтобы не слать им «таймаут CI»
- ADR-016, ARCHITECTURE §2.5/§5.2, README, DEPLOY, .env.example

Refs #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 00:24:08 +03:00
co-authored by Claude Opus 5.5
parent eda5694270
commit 95a72437ec
29 changed files with 1293 additions and 117 deletions
+22 -9
View File
@@ -4,7 +4,7 @@ import asyncio
import base64
import json
import subprocess
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
@@ -24,6 +24,7 @@ from app.config import RunnerSettings
from app.db.models import Alert, AlertKind
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.pipeline.watcher import PipelineWatcher
from app.repo.git import UploadsRepo
from app.runner.client import RunnerClient, RunnerResult
from app.runner.deployer import Deployer, Outcome, changed_services
@@ -168,14 +169,23 @@ async def test_one_broken_service_does_not_stop_others(repo: UploadsRepo) -> Non
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo, fake_host: Path) -> TgClient:
async def tg(
texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo, fake_host: Path
) -> AsyncIterator[TgClient]:
loops: list[asyncio.Task[None]] = []
def dispatcher(bot: Bot) -> Dispatcher:
alerts = AlertService(
sessions, TelegramNotifier(bot, access, sessions, texts), load_alerts_config(ROOT / "config")
)
return build_dispatcher(texts, sessions, access, repo, alerts=alerts, runner=client())
# no CI configured: the watcher deploys right after push
watcher = PipelineWatcher(sessions, bot, texts, None, Deployer(repo, client(), alerts), poll_interval=0.05)
loops.append(asyncio.create_task(watcher.run()))
return build_dispatcher(texts, sessions, access, repo, alerts=alerts, runner=client(), pipeline=watcher)
return TgClient(dispatcher)
yield TgClient(dispatcher)
for loop in loops:
loop.cancel()
async def test_push_deploys_touched_service(tg: TgClient, texts: Texts, fake_host: Path, remote: Path) -> None:
@@ -189,12 +199,15 @@ async def test_push_deploys_touched_service(tg: TgClient, texts: Texts, fake_hos
await tg.send(ADMIN, "add util")
await tg.press(ADMIN, CommitCb(a=Act.PUSH).pack())
await tg.press(ADMIN, CommitCb(a=Act.PUSH_GO).pack())
await until(lambda: any("работает" in (m.text or "") for m in tg.session.sent(SendMessage)))
await until(lambda: any("развёрнуты" in (m.text or "") for m in tg.session.sent(SendMessage)))
pushed = [e for e in tg.session.sent(EditMessageText) if "Запушено" in (e.text or "")]
assert pushed and "🚀 Разворачиваю: hello…" in pushed[-1].text
[done] = [m for m in tg.session.sent(SendMessage) if "работает" in (m.text or "")]
assert done.text.startswith("🚀 <b>hello</b> работает · ")
pushed = [e.text for e in tg.session.sent(EditMessageText) if "Запушено" in (e.text or "")]
assert "CI" not in pushed[0] and pushed[0].endswith(texts.pipeline.cd_running.format(services="hello"))
await until(
lambda: tg.session.sent(EditMessageText)[-1].text.endswith(texts.pipeline.cd_success.format(services="hello"))
)
[done] = [m for m in tg.session.sent(SendMessage) if "развёрнуты" in (m.text or "")]
assert done.text.startswith("🚀 CD: сервисы hello развёрнуты, ревизия ")
assert "tgit-svc-hello" in docker_state(fake_host)["containers"] # type: ignore[operator]