Production DE workflow: uv + ruff + mypy + pytest + caching
В предыдущем уроке мы разобрали anatomy GitHub Actions. Теперь — конкретный workflow, который реально используется в DE-командах в 2026 году. Стек: uv для package management, ruff для linting, mypy для типов, pytest для тестов. Это стандарт de-facto на май 2026.
В этом уроке построим production-ready CI шаг за шагом, разберём:
- Почему
uvвместоpip(10x быстрее). - Как настроить caching, чтобы не пересобирать venv каждый раз.
- Как передавать secrets безопасно.
- Conditional jobs: запускать dbt-tests только если изменились
models/. [skip ci]для документации.- Какие edge cases ловят junior DE.
К концу урока у тебя будет copy-paste-ready workflow для нового DE проекта.
Стек: что и почему
uv: pip replacement (Astral, 2024+)
uv — package manager, написанный на Rust, заменяет pip, pip-tools, virtualenv. На январь 2026 — стандарт для нового Python кода. Преимущества:
- 10-100x быстрее pip (Rust + parallel downloads).
- Один tool вместо трёх (pip + venv + pip-compile).
- Lockfile (
uv.lock) для reproducible builds. - Совместим с
pyproject.toml(PEP 621).
Установка:
# macOS / Linux
$ curl -LsSf https://astral.sh/uv/install.sh | sh
# Через brew
$ brew install uv
Базовый workflow:
# В новом проекте
$ uv init my-de-project
$ cd my-de-project
$ uv add pandas snowflake-connector-python apache-airflow
$ uv add --dev pytest mypy ruff
# Sync (создаёт .venv + установка из uv.lock)
$ uv sync
# Запуск с auto-activate venv
$ uv run pytest
$ uv run ruff check .
ruff: lint + format
Lint + formatter в одном Rust tool. Заменяет black + isort + flake8 + pylint + autoflake. 100x быстрее, чем все они вместе.
pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py313"
extend-exclude = ["migrations", "dags/legacy"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"SIM", # simplify
"ARG", # unused arguments
]
ignore = ["E501"] # line-too-long (мы используем 100)
[tool.ruff.format]
quote-style = "double"
Команды:
$ uv run ruff check . # lint, exit 1 если есть warnings
$ uv run ruff check --fix . # auto-fix то, что можно
$ uv run ruff format . # format files (как black)
$ uv run ruff format --check . # check, что код already formatted
В CI используем check, не --fix — fixes делает локально dev.
mypy: static type check
[tool.mypy]
python_version = "3.13"
strict = true
ignore_missing_imports = true
disallow_untyped_defs = true
warn_unused_ignores = true
warn_return_any = true
strict = true включает большой набор checks. Для нового проекта — да. Для legacy — может быть слишком, начни с base и постепенно добавляй.
$ uv run mypy src/
pytest: testing
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
addopts = "-v --strict-markers --strict-config --cov=src --cov-report=term --cov-report=xml"
markers = [
"slow: tests that take >5s",
"integration: requires external services",
]
$ uv run pytest # все тесты
$ uv run pytest -k "test_etl" # только matched names
$ uv run pytest -m "not slow" # exclude slow tests
Workflow v1: базовый CI
Начнём с простого, без оптимизаций:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.5.0"
- name: Set up Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync
- name: Ruff check
run: uv run ruff check .
- name: Ruff format check
run: uv run ruff format --check .
- name: Mypy
run: uv run mypy src/
- name: Pytest
run: uv run pytest --cov=src --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
Делает: lint -> format check -> type check -> tests -> upload coverage. Время на свежем runner — 2-3 минуты. Без оптимизаций при каждом push — мы каждый раз тратим время на uv sync (скачать пакеты). Дальше — кеш.
Оптимизация 1: caching через actions/cache
Главная статья расхода CI time — установка зависимостей. На большом DE-проекте uv sync качает 200+ пакетов, 500+ MB, тратит 30-90 секунд.
Решение — закешировать venv по хешу uv.lock:
- name: Cache venv
uses: actions/cache@v4
id: cache-venv
with:
path: .venv
key: venv-${{ runner.os }}-py3.13-${{ hashFiles('uv.lock') }}
restore-keys: |
venv-${{ runner.os }}-py3.13-
- name: Install dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
run: uv sync
Что происходит:
- Cache key =
venv-Linux-py3.13-{hash-of-uv.lock}. - Первый раз — cache miss ->
uv sync-> save cache. - Следующие runs (если uv.lock не менялся) -> cache hit -> skip uv sync. Время: 5 сек вместо 60.
- Если uv.lock изменился (новый dep added) -> cache miss -> пересобираем.
restore-keys — если точного key нет, ищем по префиксу venv-Linux-py3.13-. Это даёт partial restore: vendor мог измениться слегка, мы возьмём близкую версию.
GitHub Actions cache имеет лимит 10GB на репо. Старые записи auto-evicted. Если у тебя проекты с очень тяжёлыми deps (PyTorch + CUDA, ~5GB venv) — следи за этим. uv-cache по умолчанию gzip-сжат, обычно весит 100-500 MB.
Уровень глубже: uv-cache + venv
uv использует глобальный cache (~/.cache/uv/) для скачанных wheel-ов и локальный venv в .venv/. Можно кешировать оба:
- name: Cache uv (global)
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-cache-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- name: Cache venv (project)
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-py3.13-${{ hashFiles('uv.lock') }}
Это даёт двухуровневый cache: даже если venv-key промахнулся, uv может переиспользовать globally cached wheel-ы.
astral-sh/setup-uv@v3 action уже умеет это — добавь enable-cache: true:
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
И всё. Хорошая практика — использовать готовый support.
Оптимизация 2: разделение на параллельные jobs
Текущий workflow всё делает в одном job — последовательно. Можно ускорить, разделив на параллельные:
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync
lint:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync
- run: uv run ruff check .
type-check:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync
- run: uv run mypy src/
test:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync
- run: uv run pytest --cov=src
Параллельность даёт: lint, type-check, test работают одновременно. Раньше — 2+3+5 = 10 мин последовательно. Теперь — max(2, 3, 5) = 5 мин.
Но! install job — pointless если каждый последующий всё равно делает uv sync. Реальная польза от separate install — это когда install готовит shared artifact (например, скомпилированный wheel), который reuse-ится. Для venv-кеша достаточно того, что cache shared между jobs (ключи одинаковые — попадут в hit).
В реальности часто не делают install отдельным job — он лишний. Структура: 3-4 параллельных job, каждый делает свой uv sync (быстро из cache) + свою команду. Это проще.
Оптимизация 3: conditional jobs (paths)
Если PR меняет только README.md — зачем гонять pytest 5 минут? paths-ignore или paths фильтры спасают:
on:
pull_request:
paths:
- 'src/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/**'
paths-ignore:
- 'docs/**'
- '**.md'
Или на уровне job:
jobs:
test:
if: |
contains(github.event.pull_request.changed_files, 'src/') ||
contains(github.event.pull_request.changed_files, 'tests/')
runs-on: ubuntu-latest
...
Второй способ сложнее, требует paths-filter action. Проще на уровне on: фильтра.
Smart approach: changed paths по job
Для крупного monorepo (Airflow DAGs + dbt + Spark jobs в одном репо) — отдельные workflow per path:
.github/workflows/
ci-airflow.yml # триггерится на dags/
ci-dbt.yml # триггерится на models/, macros/
ci-spark.yml # триггерится на spark_jobs/
ci-shared.yml # на shared/, всегда
Каждый workflow со своим on: pull_request: paths:. PR, который меняет только dags/ — запустит ci-airflow + ci-shared, не запустит ci-dbt и ci-spark. Save costs, faster feedback.
Secrets: безопасное использование
Secrets хранятся в Settings -> Secrets and variables -> Actions:
| Уровень | Кто видит |
|---|---|
| Repository secrets | все workflows в репо |
| Environment secrets | workflows с environment: prod |
| Organization secrets | все репо в org |
Использование:
- name: Run dbt build in CI schema
env:
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: uv run dbt build --target ci
Best practices
1. Никогда не echo secret в логи:
# ПЛОХО: secret попадёт в публичные логи
- run: echo "Snowflake password: ${{ secrets.SNOWFLAKE_PASSWORD }}"
GitHub автоматически mask-ит известные secret-ы в логах (заменяет на ***), но это не 100% защита.
2. Используй secrets: ключ, не env, для passing в reusable workflows:
jobs:
call-reusable:
uses: ./.github/workflows/deploy.yml
secrets:
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
3. Environment secrets для prod:
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production # ← требует approval в UI
steps:
- env:
PROD_KEY: ${{ secrets.PROD_KEY }} # доступен только в env=production
run: deploy.sh
В Settings -> Environments -> production -> Required reviewers настраивается. Перед deploy в prod — manual approve.
4. OIDC вместо long-lived keys (best practice 2026):
Вместо AWS_ACCESS_KEY_ID в Secrets — настройка OIDC trust:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # ← нужно для OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: us-east-1
- run: aws s3 cp dags/ s3://airflow-dags-prod/ --recursive
GitHub Actions предоставит OIDC token, AWS обменяет на temporary credentials (1 час validity). Никаких long-lived secrets вообще. Это золотой стандарт для security.
Skip CI: [skip ci] и [skip actions]
Иногда хочется не гонять CI:
docs: fix typo— никакого кода.- Auto-merge от Dependabot после ручного review.
- Hotfix README.
В commit message:
$ git commit -m "docs: fix typo in README [skip ci]"
$ git push
GitHub Actions увидит [skip ci] (или [ci skip], [skip actions], [actions skip]) — не запустит workflows.
Опасности:
- Branch protection с required checks всё равно блокирует merge, если check не прошёл / не запустился.
- Не используй в production коде — иначе можно случайно merge сломанное.
Рекомендация: использовать только для docs/typo, и только на main-direct-push, не на PR (где branch protection всё равно сработает).
Полный production workflow
Соберём всё вместе:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/**'
push:
branches: [main]
permissions:
contents: read
env:
PYTHON_VERSION: "3.13"
UV_VERSION: "0.5.0"
jobs:
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
cache-dependency-glob: "uv.lock"
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
type-check:
name: Type check (mypy)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
cache-dependency-glob: "uv.lock"
- run: uv sync --frozen
- run: uv run mypy src/
test:
name: Test (Python ${{ matrix.python }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python: ['3.12', '3.13']
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test_password
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
cache-dependency-glob: "uv.lock"
- run: uv python install ${{ matrix.python }}
- run: uv sync --frozen
- name: Run tests
run: uv run pytest --cov=src --cov-report=xml -v
env:
POSTGRES_URL: postgres://postgres:test_password@localhost:5432/postgres
- name: Upload coverage
if: matrix.python == '3.13'
uses: codecov/codecov-action@v4
with:
file: coverage.xml
secret-scan:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
all-checks-passed:
name: All checks passed
runs-on: ubuntu-latest
needs: [lint, type-check, test, secret-scan]
if: always()
steps:
- name: Verify all dependencies
run: |
if [[ "${{ needs.lint.result }}" != "success" || \
"${{ needs.type-check.result }}" != "success" || \
"${{ needs.test.result }}" != "success" || \
"${{ needs.secret-scan.result }}" != "success" ]]; then
echo "One or more checks failed"
exit 1
fi
echo "All checks passed!"
Объясним новые элементы:
services:— Docker контейнеры, доступные job-у. Postgres подlocalhost:5432— для integration tests.--frozenвuv sync— fail еслиuv.lockустарел противpyproject.toml. В CI всегда--frozen.if: always()вall-checks-passed— выполнится даже если другие jobs упали (чтобы дать explicit failure message в один required check).
DE-specific: dbt CI workflow
Отдельный workflow для dbt-моделей. Запускается только когда меняются models/ или macros/:
# .github/workflows/dbt-ci.yml
name: dbt CI
on:
pull_request:
paths:
- 'models/**'
- 'macros/**'
- 'tests/**'
- 'dbt_project.yml'
- 'profiles.yml.template'
permissions:
contents: read
id-token: write # OIDC для AWS
jobs:
dbt-ci:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/dbt-ci-role
aws-region: us-east-1
- uses: astral-sh/setup-uv@v3
- name: Install dbt
run: uv pip install --system dbt-snowflake==1.9.0
- name: Download prod manifest
run: aws s3 cp s3://dbt-state/manifest.json ./prod-state/manifest.json
- name: Create CI schema
run: |
uv run dbt run-operation create_ci_schema \
--args '{"pr_number": ${{ github.event.pull_request.number }}}'
env:
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
- name: dbt compile
run: dbt compile
env:
DBT_PROFILES_DIR: ./profiles
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
DBT_SCHEMA: ci_pr_${{ github.event.pull_request.number }}
- name: dbt build (state-aware)
run: |
dbt build \
--select state:modified+ \
--state ./prod-state \
--defer \
--favor-state
env:
DBT_PROFILES_DIR: ./profiles
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
DBT_SCHEMA: ci_pr_${{ github.event.pull_request.number }}
- name: Cleanup CI schema
if: always()
run: |
uv run dbt run-operation drop_ci_schema \
--args '{"pr_number": ${{ github.event.pull_request.number }}}'
env:
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
--defer + --favor-state: для не-изменённых upstream моделей dbt будет использовать prod версии, а не запускать локально. Это резко экономит warehouse credits. Изменённые модели запускаются в CI schema.
DE-specific: Airflow CI
# .github/workflows/airflow-ci.yml
name: Airflow CI
on:
pull_request:
paths:
- 'dags/**'
- 'plugins/**'
- 'requirements.txt'
jobs:
dag-validation:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: airflow
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- name: Install Airflow
run: uv pip install --system 'apache-airflow[postgres]==2.10.0'
- name: Init Airflow DB
run: airflow db init
env:
AIRFLOW__CORE__SQL_ALCHEMY_CONN: postgresql://postgres:airflow@localhost/postgres
AIRFLOW__CORE__LOAD_EXAMPLES: 'False'
- name: Validate DAG imports
run: |
export AIRFLOW__CORE__DAGS_FOLDER="$(pwd)/dags"
airflow dags list 2>&1 | tee /tmp/dags.txt
if grep -i "Import error" /tmp/dags.txt; then
echo "DAG import errors found!"
exit 1
fi
- name: Test DAGs (smoke)
run: |
for dag_file in dags/*.py; do
echo "Testing $dag_file"
python -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', '$dag_file')
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
dags = [v for v in m.__dict__.values() if hasattr(v, 'dag_id')]
print(f'Found {len(dags)} DAG(s)')
"
done
Попробуй сам
Создай минимальный DE-проект с CI:
$ mkdir my-de-ci-demo && cd my-de-ci-demo
$ git init
$ uv init --python 3.13
$ uv add --dev pytest ruff mypy
# Создаём src и tests
$ mkdir src tests
$ cat > src/utils.py << 'EOF'
def add(a: int, b: int) -> int:
return a + b
EOF
$ cat > tests/test_utils.py << 'EOF'
from src.utils import add
def test_add():
assert add(2, 2) == 4
EOF
# CI workflow
$ mkdir -p .github/workflows
$ cat > .github/workflows/ci.yml << 'EOF'
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync
- run: uv run ruff check .
- run: uv run mypy src/
- run: uv run pytest
EOF
$ git add .
$ git commit -m "Initial DE project with CI"
$ git remote add origin <your-repo>
$ git push -u origin main
После push открой Actions tab — увидишь bег. Поэкспериментируй:
- Сломай тест -> push -> CI red.
- Добавь mypy error (
def f(): passбез типов в strict mode) -> push -> CI red. - Удали ошибки -> push -> CI green.
Killer takeaway
Production DE-workflow на 2026 — uv + ruff + mypy + pytest в GitHub Actions. Главные оптимизации: (1) actions/cache для venv по hash uv.lock (5 sec вместо 60); (2) параллельные jobs (lint, type-check, test independent); (3) path filters (не гонять CI на docs); (4) --frozen в uv sync — fail если lockfile stale; (5) OIDC для AWS вместо long-lived keys. Для dbt CI обязательно --select state:modified+ --defer --favor-state — резкая экономия warehouse credits. Для Airflow — smoke test на DAG imports. [skip ci] использовать только для docs/typo, никогда для кода. Скопируй финальный workflow из этого урока — он стартовая база для нового проекта.