Merge pull request 'feat(infra): репозиторий загрузок tgit-uploads и шаблон CI/CD' (#30) from infra/7-uploads-repo into dev
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
+3
-1
@@ -15,7 +15,9 @@ SOURCECRAFT_TOKEN= # (secret) персональный токен AP
|
||||
UPLOADS_REPO=rogachevegorushka/tgit-uploads
|
||||
|
||||
# --- Git: коммиты в репозиторий загрузок (#7, #9) ---
|
||||
GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only)
|
||||
GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only);
|
||||
# публичная часть — deploy key с правом записи в tgit-uploads
|
||||
UPLOADS_GIT_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git
|
||||
|
||||
# --- LLM (#17) ---
|
||||
LLM_PROVIDER=mock # mock | openai_compat
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
|---|---|---|
|
||||
| `deploy/` | `deploy.sh`, `docker-compose.server.yml` — деплой бота на сервер | #12 |
|
||||
| `runner/` | `tgit-runner` — песочница и запуск сервисов на хосте | #13 |
|
||||
| `uploads-template/` | шаблон репозитория загрузок: `.sourcecraft/ci.yaml`, `ruff.toml`, README | #7 |
|
||||
| [`uploads-template/`](uploads-template/) | шаблон [`tgit-uploads`](https://sourcecraft.dev/rogachevegorushka/tgit-uploads): CI/CD, служебные скрипты `.tgit/`, `ruff.toml`, демо-сервис `hello` — правки вносить здесь и переносить в репозиторий загрузок | #7 |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# CI/CD репозитория загрузок tgit. Шаблон живёт в HSE/tgit: infra/uploads-template/ — правки делать там.
|
||||
# Бот не даёт менять .sourcecraft/ и .tgit/ из Telegram (SPEC C-16).
|
||||
on:
|
||||
push:
|
||||
- workflows: [check-and-deploy]
|
||||
filter:
|
||||
branches: ["main"]
|
||||
|
||||
workflows:
|
||||
check-and-deploy:
|
||||
settings:
|
||||
max_cube_duration: 10m
|
||||
tasks:
|
||||
- name: check
|
||||
cubes:
|
||||
- name: compile
|
||||
image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim
|
||||
script:
|
||||
- python -m compileall -q -x '(^|/)\.' .
|
||||
- name: lint
|
||||
needs: ["-"]
|
||||
image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim
|
||||
script:
|
||||
- uvx ruff@0.13 check .
|
||||
- name: security
|
||||
needs: ["-"]
|
||||
image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim
|
||||
script:
|
||||
- uvx bandit@1.8 -q -r services -ll
|
||||
- name: tests
|
||||
needs: ["-"]
|
||||
image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim
|
||||
script:
|
||||
- python .tgit/run_tests.py
|
||||
- name: deploy
|
||||
needs: [check]
|
||||
cubes:
|
||||
- name: deploy
|
||||
# full (non-slim) image: has git for diffing the pushed commit
|
||||
image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm
|
||||
script:
|
||||
- python .tgit/changed_services.py > changed.txt
|
||||
- cat changed.txt
|
||||
# Запуск через tgit-runner по SSH — #13. До этого — только список сервисов к развёртыванию.
|
||||
- 'echo "deploy stub: $(wc -l < changed.txt) service(s) to deploy"'
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Print services (`services/<name>/`) changed by the pushed commit, one per line.
|
||||
|
||||
Falls back to every service when the parent commit is unavailable (first commit or shallow clone),
|
||||
so a deploy never silently skips a service.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
NAME = re.compile(r"^[a-z][a-z0-9-]{1,30}$")
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def all_services() -> list[str]:
|
||||
root = Path("services")
|
||||
return sorted(p.name for p in root.iterdir() if p.is_dir() and NAME.match(p.name)) if root.is_dir() else []
|
||||
|
||||
|
||||
def changed_paths() -> list[str] | None:
|
||||
try:
|
||||
git("rev-parse", "--verify", "HEAD~1")
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
git("fetch", "--deepen=1", "-q")
|
||||
git("rev-parse", "--verify", "HEAD~1")
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
return git("diff", "--name-only", "HEAD~1", "HEAD").splitlines()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
paths = changed_paths()
|
||||
if paths is None:
|
||||
print("no parent commit — deploying all services", file=sys.stderr)
|
||||
services = all_services()
|
||||
else:
|
||||
existing = set(all_services())
|
||||
services = sorted({p.split("/")[1] for p in paths if p.startswith("services/") and p.count("/") >= 2} & existing)
|
||||
print("\n".join(services))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Run pytest for every service that has tests, each in its own env with its own requirements.txt."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def has_tests(service: Path) -> bool:
|
||||
return any(service.rglob("test_*.py")) or any(service.rglob("*_test.py"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failed: list[str] = []
|
||||
services = sorted(p for p in Path("services").glob("*/") if p.is_dir()) if Path("services").is_dir() else []
|
||||
for service in services:
|
||||
if not has_tests(service):
|
||||
print(f"[{service.name}] no tests — skipped")
|
||||
continue
|
||||
cmd = ["uv", "run", "--no-project", "--with", "pytest"]
|
||||
req = service / "requirements.txt"
|
||||
if req.is_file():
|
||||
cmd += ["--with-requirements", str(req)]
|
||||
cmd += ["pytest", "-q", str(service)]
|
||||
print(f"[{service.name}] {' '.join(cmd)}", flush=True)
|
||||
if subprocess.run(cmd).returncode != 0:
|
||||
failed.append(service.name)
|
||||
if failed:
|
||||
print(f"tests failed: {', '.join(failed)}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
# tgit-uploads
|
||||
|
||||
Общий репозиторий команды [tgit](https://gitea.24studbank.ru/HSE/tgit): бот коммитит сюда изменения из Telegram
|
||||
(раздел «📝 Коммит»), каждый push в `main` запускает CI, а изменённые сервисы разворачиваются на хосте (CD).
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
services/<name>/ сервис = папка; имя — ^[a-z][a-z0-9-]{1,30}$
|
||||
main.py точка входа (python main.py)
|
||||
requirements.txt зависимости (необязательно)
|
||||
test_*.py тесты pytest (необязательно, запускаются в CI)
|
||||
tgit.yaml настройки запуска: health_url, network (необязательно)
|
||||
.sourcecraft/ci.yaml CI/CD — из Telegram не редактируется
|
||||
.tgit/ служебные скрипты CI — из Telegram не редактируются
|
||||
ruff.toml мягкий набор правил линтера
|
||||
```
|
||||
|
||||
## CI/CD (`check-and-deploy`, push в `main`)
|
||||
|
||||
| Шаг | Что делает | Падает, если |
|
||||
|---|---|---|
|
||||
| `compile` | `python -m compileall` | синтаксическая ошибка |
|
||||
| `lint` | `ruff check` (мягкие правила) | неопределённые имена, неиспользуемые импорты, опасные значения по умолчанию |
|
||||
| `security` | `bandit -ll` по `services/` | найдена уязвимость уровня medium+ |
|
||||
| `tests` | `pytest` для каждого сервиса с тестами, со своими `requirements.txt` | упал тест |
|
||||
| `deploy` | список изменённых сервисов → `tgit-runner` на хосте | сервис не поднялся (healthy) за 60 с |
|
||||
|
||||
## Развернуть заново
|
||||
|
||||
Шаблон — в репозитории бота: `infra/uploads-template/`. Новый пустой репозиторий → скопировать содержимое шаблона →
|
||||
push в `main` → в настройках добавить deploy key бота с правом записи.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Мягкий набор для кода участников: ловим то, что точно сломается при запуске,
|
||||
# а не стиль — иначе CI будет красным на каждом коммите из Telegram.
|
||||
target-version = "py312"
|
||||
extend-exclude = [".tgit"]
|
||||
|
||||
[lint]
|
||||
select = ["E9", "F63", "F7", "F82", "F401", "B006", "B008"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Demo service: logs a heartbeat line every 10 seconds."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
|
||||
def greeting(n: int) -> str:
|
||||
return f"hello #{n}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
n = 0
|
||||
while True:
|
||||
n += 1
|
||||
logging.info(greeting(n))
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
from main import greeting
|
||||
|
||||
|
||||
def test_greeting() -> None:
|
||||
assert greeting(3) == "hello #3"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Настройки сервиса для tgit-runner (#13). Все поля необязательны.
|
||||
# health_url: http://localhost:8080/health # HTTP-проба «Статуса»; без неё health = контейнер жив
|
||||
# network: false # доступ в интернет; включает только администратор
|
||||
Reference in New Issue
Block a user