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 кода
Проверка знанийKnowledge check
ОтветAnswer

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Junior настраивает CI и видит, что каждый push на feature ветку тратит 60 секунд на установку зависимостей (uv sync). Как ускорить?

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

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

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

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