feat(infra): шаблон репозитория загрузок tgit-uploads — CI/CD, служебные скрипты, демо-сервис
- .sourcecraft/ci.yaml: push в main → check (compile · ruff мягкий · bandit · pytest по сервисам) → deploy - .tgit/changed_services.py: изменённые services/<name>/ (без родителя — все сервисы) - .tgit/run_tests.py: pytest каждого сервиса со своими requirements.txt - deploy пока заглушка со списком сервисов — tgit-runner в #13 - services/hello: демо-сервис с тестом; tgit.yaml — формат настроек сервиса Refs #7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user