Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 20.03 · 30 мин
Средний
uvruffmypypytestcachingsecretsconditional-jobsskip-ci

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 шаг за шагом, разберём:

  1. Почему uv вместо pip (10x быстрее).
  2. Как настроить caching, чтобы не пересобирать venv каждый раз.
  3. Как передавать secrets безопасно.
  4. Conditional jobs: запускать dbt-tests только если изменились models/.
  5. [skip ci] для документации.
  6. Какие 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

Что происходит:

  1. Cache key = venv-Linux-py3.13-{hash-of-uv.lock}.
  2. Первый раз — cache miss -> uv sync -> save cache.
  3. Следующие runs (если uv.lock не менялся) -> cache hit -> skip uv sync. Время: 5 сек вместо 60.
  4. Если uv.lock изменился (новый dep added) -> cache miss -> пересобираем.

restore-keys — если точного key нет, ищем по префиксу venv-Linux-py3.13-. Это даёт partial restore: vendor мог измениться слегка, мы возьмём близкую версию.

TIP

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).

NOTE

В реальности часто не делают 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 secretsworkflows с 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 из этого урока — он стартовая база для нового проекта.

pytest: написание и запуск тестов для Python кода

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4