CI demo: metrics + Gitea Actions

This commit is contained in:
2026-11-12 13:55:02 +08:00
commit 6637fb8ff4
17 changed files with 564 additions and 0 deletions

43
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,43 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: ruff check .
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q --cov=app --cov-branch --cov-fail-under=60
metrics:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: radon cc app -s -a
- run: xenon --max-absolute C app/

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.pytest_cache/
.coverage
coverage.xml
.venv/

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# ci-demo (Gitea) — учебный стенд CI к лекции 9
Мини-проект к лекции «Метрики сложности и CI» (неделя 9, `../09l_л_Метрики_сложности.md`).
Домен — «калькулятор скидок», чтобы студенты узнавали код из практики.
[![CI](https://gitea.example.com/OWNER/ci-demo/actions/workflows/ci.yml/badge.svg?branch=main)](https://gitea.example.com/OWNER/ci-demo/actions)
> Замените `gitea.example.com` и `OWNER` на ваш сервер и логин.
## Что внутри
```
app/
discounts.py # стратегии скидок: regular / vip / employee
service.py # OrderService — контекст, делегирует скидке
legacy_report.py # функция ранга C — для метрического гейта
tests/
test_discounts.py # 5 тестов, включая проверку версии Python для матрицы
test_legacy_report.py
.gitea/workflows/
ci.yml # lint → test (coverage) → metrics (radon + xenon)
workflows_заготовки/
ci-metrics-strict.yml # тот же пайплайн, но xenon --max-absolute B (красный)
ci-matrix.yml # матрица 3.11/3.12/3.13, fail-fast: false
ci-artifacts.yml # артефакт coverage.xml
bad.yml # «плохой» workflow с || true
```
## Локальный запуск
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
pytest -q --cov=app --cov-branch --cov-fail-under=60
ruff check .
radon cc app -s -a
xenon --max-absolute C app/ # зелёный: legacy_report.py ранга C
xenon --max-absolute B app/ # красный: гейт не пропускает C
```
## Требования к серверу Gitea
Gitea 1.19+ с включёнными Actions (`app.ini`: `[actions] ENABLED = true`); act_runner онлайн с меткой `ubuntu-latest`.
## Как развернуть стенд
1. Создайте пустой репозиторий `ci-demo` на сервере Gitea.
2. Содержимое этой папки — корень репозитория:
```powershell
git init
git add .
git commit -m "Стартовый каркас демо"
git branch -M main
git remote add origin https://gitea.example.com/OWNER/ci-demo.git
git push -u origin main
```
3. Замените `gitea.example.com` и `OWNER` в бейдже README.
4. Дождитесь первого прогона CI (act_runner прогреет кеш pip).
5. Настройте защиту ветки `main`: обязательные проверки `lint`, `test`,
`metrics` (Status Check Contexts), запрет force-push.
## Отличия Gitea-варианта
- workflow лежит в `.gitea/workflows/ci.yml`;
- прогоны выполняет act_runner;
- кеш и артефакты зависят от версии Gitea: заготовки это учитывают.
## Метрический гейт
`app/legacy_report.py` намеренно содержит функцию ранга C (V(G) = 17).
Основной workflow использует `xenon --max-absolute C` и остаётся зелёным.
Строгая заготовка `ci-metrics-strict.yml` поднимает планку до B — сборка
красная. Это и есть демонстрация «метрика работает, когда её нельзя
игнорировать»: порог в CI делает рост сложности блокирующим.
## Важно
Стенд учебный: секреты, деплой и реальные данные здесь не используются.
«Плохой» workflow из `workflows_заготовки/bad.yml` запускать только в этом
демо-репозитории.

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Пакет приложения ci-demo (Gitea edition)."""

35
app/discounts.py Normal file
View File

@@ -0,0 +1,35 @@
"""Скидки демо-проекта ci-demo (СТРПО, неделя 9, Gitea edition)."""
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, total: float) -> float:
"""Вернуть сумму с учётом скидки."""
class NoDiscount(Discount):
def apply(self, total: float) -> float:
return total
class VIPDiscount(Discount):
def apply(self, total: float) -> float:
return total * 0.9
class EmployeeDiscount(Discount):
def apply(self, total: float) -> float:
return total * 0.7
DISCOUNTS: dict[str, type[Discount]] = {
"regular": NoDiscount,
"vip": VIPDiscount,
"employee": EmployeeDiscount,
}
def discount_for(customer_type: str) -> Discount:
"""Выбрать стратегию скидки по типу клиента."""
return DISCOUNTS.get(customer_type, NoDiscount)()

41
app/legacy_report.py Normal file
View File

@@ -0,0 +1,41 @@
"""Учебный модуль с высокой сложностью для метрического гейта CI.
V(G) функции classify_order — ранг C (11–20). Поэтому:
xenon --max-absolute C app/ # зелёный
xenon --max-absolute B app/ # красный — гейт не пропускает
Мораль: порог в CI делает рост сложности видимым и блокирующим.
"""
def classify_order(order, cfg):
"""Классифицировать заказ по десятку правил — намеренно сложно."""
if order is None:
return "missing"
if "total" not in order:
return "no_total"
total = order["total"]
if total < 0:
return "negative"
if total == 0:
return "empty"
if total > 100000:
return "huge"
if cfg.get("maintenance"):
return "maintenance"
if cfg.get("blocked"):
return "blocked"
if cfg.get("regions") and order.get("region") not in cfg["regions"]:
return "region"
if order.get("coupon") and not cfg.get("coupons", False):
return "coupon"
if total > cfg.get("limit", 10000):
return "limit"
if order.get("fraud") and order.get("manual_review"):
return "fraud_review"
elif order.get("fraud"):
return "fraud"
if order.get("vip") and total > 5000:
return "vip_review"
return "ok"

21
app/service.py Normal file
View File

@@ -0,0 +1,21 @@
"""Сервис заказов демо-проекта ci-demo (СТРПО, неделя 9, Gitea edition)."""
from dataclasses import dataclass
from .discounts import Discount
@dataclass
class Order:
items: dict[str, float]
@property
def total(self) -> float:
return sum(self.items.values())
class OrderService:
def __init__(self, discount: Discount) -> None:
self.discount = discount
def total(self, order: Order) -> float:
return round(self.discount.apply(order.total), 2)

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
pytest>=8.0,<9
pytest-cov>=5.0,<7
ruff>=0.6,<1
radon>=6.0,<7
xenon>=0.9,<1

36
tests/test_discounts.py Normal file
View File

@@ -0,0 +1,36 @@
import sys
import pytest
from app.discounts import NoDiscount, discount_for
from app.service import Order, OrderService
def make_order() -> Order:
return Order({"чай": 300, "кофе": 700})
def test_vip_discount() -> None:
service = OrderService(discount_for("vip"))
assert service.total(make_order()) == pytest.approx(900.0)
def test_employee_discount() -> None:
service = OrderService(discount_for("employee"))
assert service.total(make_order()) == pytest.approx(700.0)
def test_regular_has_no_discount() -> None:
service = OrderService(discount_for("regular"))
assert service.total(make_order()) == pytest.approx(1000.0)
def test_unknown_type_falls_back_to_regular() -> None:
assert isinstance(discount_for("unknown"), NoDiscount)
def test_python_version_supported() -> None:
# Для акта с матрицей: на Python 3.11 этот тест падает
assert sys.version_info >= (3, 12), (
f"нужен Python 3.12+, получено {sys.version.split()[0]}"
)

View File

@@ -0,0 +1,25 @@
import pytest
from app.legacy_report import classify_order
CASES = [
(None, {}, "missing"),
({}, {}, "no_total"),
({"total": -1}, {}, "negative"),
({"total": 0}, {}, "empty"),
({"total": 200000}, {}, "huge"),
({"total": 100}, {"maintenance": True}, "maintenance"),
({"total": 100}, {"blocked": True}, "blocked"),
({"total": 100, "region": "eu"}, {"regions": ["us"]}, "region"),
({"total": 100, "coupon": "X"}, {"coupons": False}, "coupon"),
({"total": 20000}, {"limit": 10000}, "limit"),
({"total": 100, "fraud": True, "manual_review": True}, {}, "fraud_review"),
({"total": 100, "fraud": True}, {}, "fraud"),
({"total": 6000, "vip": True}, {}, "vip_review"),
({"total": 100}, {}, "ok"),
]
@pytest.mark.parametrize("order,cfg,expected", CASES)
def test_classify_order(order, cfg, expected):
assert classify_order(order, cfg) == expected

View File

@@ -0,0 +1,15 @@
# ВНИМАНИЕ: демонстрационно плохой workflow — только для учебного репозитория!
# Используется в бонусном акте: показать «декоративный» CI (|| true).
name: mega
on: pull_request
jobs:
everything:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: ruff check . || true
- run: pytest -q || true
- run: echo "deploy --token=ghp_xxxxxxxxxxxxxxxxxxxx"

View File

@@ -0,0 +1,26 @@
# Заготовка для акта «Артефакты» (Gitea Actions): скопировать поверх .gitea/workflows/ci.yml
# upload-artifact@v3 — максимально совместим с Gitea Actions;
# на новых версиях Gitea можно заменить на @v4.
name: CI (artifacts)
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q --cov=app --cov-branch --cov-fail-under=60 --cov-report=xml
- uses: actions/upload-artifact@v3
if: always()
with:
name: coverage-report
path: coverage.xml

View File

@@ -0,0 +1,23 @@
# Заготовка для акта «Матрица» (Gitea Actions): скопировать поверх .gitea/workflows/ci.yml
name: CI (matrix)
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q

View File

@@ -0,0 +1,43 @@
name: CI (strict metrics)
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: ruff check .
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q --cov=app --cov-branch --cov-fail-under=60
metrics:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: radon cc app -s -a
- run: xenon --max-absolute B app/

View File

@@ -0,0 +1,158 @@
# Сценарий демонстрации CI на лекции (Gitea Actions)
**К дисциплине**: «Современные технологии разработки программного обеспечения» (СТРПО), неделя 9
**Основа**: лекция `../09l_л_Метрики_сложности.md`, блок CI (слайды 18–24)
**Стенд**: эта папка `Демо_CI_Gitea`; GitHub-версия — `../Демо_CI` (мини-проект «калькулятор скидок»)
**Длительность**: 10–12 минут; бонусные акты — ещё 4 минуты
**Формат**: живой показ на проекторе; реплики лектора — в блоках «Что говорить»
## Карта актов ↔ слайды лекции
| Акт | Что показываем | Слайд |
|---|---|---|
| 0 | Подготовка стенда (до лекции) | — |
| 1 | Зелёный локальный прогон, устройство пайплайна | «GitHub Actions: минимальный workflow» |
| 2 | Красный PR, лог падения, merge заблокирован | «Падай рано: правило 10 минут» |
| 3 | Лечение теста, зелёный прогон, merge, бейдж | «GitHub Actions: минимальный workflow» |
| 4 | Метрический гейт: `radon` + `xenon` | «Метрики в CI» |
| Бонус | Кеш, матрица, артефакты, «плохой» CI | «Стадии пайплайна» |
## Отличия Gitea-варианта
- workflow лежит в `.gitea/workflows/ci.yml`;
- прогоны выполняет act_runner;
- защита ветки — Status Check Contexts в Settings → Branches.
## Акт 0. Подготовка (до лекции, 15–20 минут)
1. Создайте пустой репозиторий `ci-demo` на сервере Gitea.
2. Запушьте содержимое этой папки как корень репозитория:
```powershell
git init
git add .
git commit -m "Стартовый каркас демо"
git branch -M main
git remote add origin https://gitea.example.com/OWNER/ci-demo.git
git push -u origin main
```
3. Замените `gitea.example.com` и `OWNER` в бейдже `README.md`.
4. Прогрейте кеш: дождитесь зелёного первого прогона act_runner.
5. Защита ветки: обязательные Status Check Contexts `lint`, `test`, `metrics`.
**Чек-лист готовности:** первый прогон зелёный; проверки `lint`, `test`, `metrics`
видны в branch protection; кеш прогрет.
## Акт 1. Зелёный старт (1–2 мин)
Покажите структуру: `app/`, `tests/`, `.gitea/workflows/ci.yml`. Запустите локально:
```powershell
pytest -q
```
**Ожидаемо:** все тесты зелёные (сейчас 19). Откройте Actions — последний прогон на `main` зелёный.
**Что говорить.** «Локально зелёный — и в CI зелёный. CI не делает ничего волшебного:
он повторяет те же шаги — checkout, Python, зависимости, pytest. Workflow — ваш
локальный ритуал, записанный в YAML».
Покажите `ci.yml`: `on:` — триггер, `jobs.lint` — стиль, `jobs.test` — тесты
с порогом покрытия, `jobs.metrics` — `radon` и `xenon`, `needs` — порядок и гейт.
## Акт 2. Красный PR (3 мин)
```powershell
git switch -c feature/vip-095
```
В `app/discounts.py` измените скидку VIP: `return total * 0.9` → `return total * 0.95`.
Тесты не трогайте.
```powershell
git add -A
git commit -m "VIP: скидка 0.95 (требование изменено)"
git push -u origin feature/vip-095
```
Откройте PR: `lint` — зелёный, `test` — красный, `metrics` — не запускается.
В логе видно `assert ... pytest.approx(900.0)` и фактическое `950.0`.
Кнопка merge заблокирована.
**Что говорить.** «Тест красный — merge нельзя. Это подарок: ошибка поймана за минуту.
Красная main — stop-the-line. Упал только `test`; `lint` прошёл и не ждал тестов:
быстрые проверки — первыми».
## Акт 3. Лечим тест, мёржим (2 мин)
**Спросите:** что правильнее — вернуть код или обновить тест? Ответ: требование
изменилось, значит меняется тест.
В `tests/test_discounts.py`: `pytest.approx(900.0)` → `pytest.approx(950.0)`.
```powershell
git commit -am "Тест VIP приведён к новому требованию 0.95"
git push
```
Дождитесь зелёного прогона и нажмите Merge. Покажите бейдж в README.
**Что говорить.** «Зелёная галочка на PR — Definition of Done в действии: код,
ревью, зелёный CI, merge. Без гейта DoD — просто список пожеланий».
## Акт 4. Метрический гейт (2–3 мин)
**Действия.**
```powershell
radon cc app -s -a
xenon --max-absolute C app/
```
**Ожидаемо:** `legacy_report.py:12 classify_order — C (17)`, гейт C зелёный.
Теперь скопируйте строгую заготовку:
```powershell
Copy-Item workflows_заготовки/ci-metrics-strict.yml .gitea/workflows/ci.yml
git switch -c demo/strict-metrics
git add .
git commit -m "Демо: строгий метрический гейт"
git push -u origin demo/strict-metrics
```
В PR `metrics` красный: `xenon --max-absolute B` не пропускает ранг C.
PR закройте, верните основной workflow.
**Что говорить.** «Метрика работает, когда её нельзя игнорировать. Порог C
пропускает функцию сложности 17; порог B — нет. Рост сложности становится
видимым и блокирующим, а не темой для спора на ревью».
## Бонусные акты (по времени)
- **Кеш**: сравните Duration первого и последнего прогонов; найдите `Cache hit`.
- **Матрица**: `Copy-Item workflows_заготовки/ci-matrix.yml ...` → 3.11 красный,
3.12/3.13 зелёные; PR не мёржить.
- **Артефакты**: `ci-artifacts.yml` → скачайте `coverage-report`.
- **Плохой CI**: `bad.yml` с `|| true` — тест падает, а проверка зелёная.
## Если что-то пошло не так
- Первый прогон медленный — кеша ещё нет; прогрейте заранее.
- Branch protection не видит проверки — обновите страницу после первого прогона.
- Раннеры тормозят — покажите заранее сделанные прогоны по закладкам.
## Уборка
```powershell
git switch main
git checkout main -- .gitea/workflows/ci.yml
git push origin --delete feature/vip-095 demo/strict-metrics
```
## Шпаргалка ответов
- **Почему отдельные job'ы?** Параллельность + `needs` задаёт порядок и гейт.
- **Что такое runner?** Чистая машина под прогон; состояние не сохраняется.
- **Можно ли доверять зелёному CI?** Только если проверки настоящие: без `|| true`.
- **Чем CI отличается от CD?** CI — интеграция и проверки; CD — доставка, отдельная тема.

Binary file not shown.