Настраиваем CI/CD в GitHub для Python-проекта с нуля

от автора

1. Введение

CI/CD — это автоматизация процессов, которые разработчики обычно делают руками после написания кода.

Если без лишней теории, то:

  • CI (Continuous Integration / Непрерывная интеграция) означает, что каждый раз, когда вы отправляете код в репозиторий, запускается сервер. Он скачивает ваши изменения, устанавливает зависимости и прогоняет тесты с линтерами. Цель — убедиться, что новый код ничего не сломал.

  • CD (Continuous Deployment / Непрерывное развертывание) вступает в дело, когда CI отработал без ошибок. Код автоматически собирается и отправляется туда, где он должен работать — на сервер, в Docker Hub или публикуется как пакет в PyPI.

Зачем CI/CD нужен в Python-проектах

Python — интерпретируемый язык с динамической типизацией. Здесь нет строгого этапа компиляции, который поймал бы опечатку в переменной до запуска программы. Ошибка или съехавший отступ могут проявиться только в рантайме.

Внедрение CI/CD в Python-проекте решает три конкретные задачи:

  1. Автоматизация рутины. Вам больше не нужно перед каждым коммитом локально запускать pytest, black, isort или flake8. Вы просто пишете код и делаете push. Остальное делает сервер.

  2. Защита стандартов кода (PEP8). В главную ветку (main/master) физически не попадет код, который не проходит проверки на стиль и качество. Это экономит часы на код-ревью — люди обсуждают архитектуру, а не отступы и длину строк.

  3. Устранение проблемы «А у меня на компьютере работает». Код тестируется в изолированном, чистом окружении. Если вы установили библиотеку локально, но забыли добавить ее в requirements.txt или pyproject.toml, пайплайн сразу упадет с ошибкой ModuleNotFoundError, и вы исправите это до того, как код попадет на продакшен.

Почему GitHub Actions

На рынке много CI/CD систем: GitLab CI, Jenkins, CircleCI. Но если ваш проект уже живет на GitHub, использование GitHub Actions — самый прагматичный выбор.

  • Работает «из коробки». Вам не нужно арендовать сервер, устанавливать раннеры и настраивать вебхуки (как в случае с Jenkins). Достаточно положить YAML-файл в директорию .github/workflows/ внутри вашего репозитория, и всё заработает само.

  • Щедрые лимиты. GitHub дает 2000 минут серверного времени в месяц для приватных репозиториев. Для публичных open-source проектов время вообще не ограничено. Для подавляющего большинства небольших и средних проектов это полностью бесплатно.

  • Экосистема и комьюнити. В GitHub Marketplace есть тысячи готовых экшенов (шагов). Вам не нужно писать bash-скрипты для установки Python, кэширования зависимостей или деплоя по SSH — для всего этого уже есть официальные и проверенные сообществом готовые блоки в 2-3 строчки кода.

2. Наш подопытный

Чтобы не разбирать CI/CD на абстрактных примерах, давайте автоматизируем реальный, хоть и крошечный проект.

Допустим, мы пишем микросервис на FastAPI, который рассчитывает индекс массы тела (BMI). Он принимает вес и рост, а возвращает рассчитанный индекс.

Что мы автоматизируем

Вот наш основной код приложения. Он занимает всего десяток строк и лежит в файле src/main.py:

from fastapi import FastAPI, HTTPExceptionapp = FastAPI()@app.get("/bmi")def calculate_bmi(weight: float, height: float):    if height <= 0 or weight <= 0:        raise HTTPException(status_code=400, detail="Рост и вес должны быть больше нуля")        bmi = weight / (height ** 2)    return {"weight": weight, "height": height, "bmi": round(bmi, 2)}

Чтобы проверить, что наша формула работает правильно и приложение не падает, мы написали автотест в файле tests/test_main.py:

from fastapi.testclient import TestClientfrom src.main import appclient = TestClient(app)def test_calculate_bmi_success():    # Проверяем расчет для человека весом 70 кг и ростом 1.75 м    response = client.get("/bmi?weight=70&height=1.75")    assert response.status_code == 200    assert response.json()["bmi"] == 22.86

Структура файлов

Наш проект уже причесан и готов к автоматизации. Файлы аккуратно разложены по папкам, а зависимости зафиксированы. Выглядит это так:

bmi_project/├── requirements.txt       # Здесь записаны fastapi, uvicorn, pytest, httpx и ruff├── src/│   ├── __init__.py│   └── main.py            # Наш API└── tests/    ├── __init__.py    └── test_main.py       # Наши тесты

Ручная рутина (от которой мы избавимся)

Представьте, что вы добавили новую фичу в этот код. Чтобы убедиться, что всё работает идеально перед отправкой кода в GitHub (командой git push), вы как ответственный разработчик открываете терминал и вводите три команды:

  1. Обновляете зависимости (вдруг коллега добавил новую библиотеку):

pip install -r requirements.txt
  1. Запускаете линтер (в нашем случае ruff), чтобы проверить код на чистоту и соответствие PEP8:

    ruff check .
  1. Прогоняете тесты, чтобы убедиться, что логика не сломалась:

    pytest

И так — каждый раз. А если вы забудете это сделать? Или поленитесь? Плохой код улетит в главную ветку и может сломать приложение на продакшене.

Наша цель в следующих шагах — написать конфигурацию, которая заставит сервер GitHub автоматически выполнять эти три команды при каждом вашем коммите. Вы будете только писать код, а проверять его будет робот.

Ой, вижу на скриншоте, что разметка Markdown сломалась — я забыл закрыть блок кода ««`, и весь остальной текст улетел внутрь. Спасибо, что поправили! Исправляюсь и перехожу сразу к делу.

3. Пишем первый пайплайн (Базовый CI)

Чтобы GitHub понял, что мы от него хотим, нужно создать конфигурационный файл. GitHub Actions ищет инструкции строго в одном месте: в скрытой папке .github/workflows/ в корне вашего репозитория.

Создадим там файл и назовем его ci.yml (имя может быть любым, главное — расширение .yml).

Вот как выглядит базовый скелет нашего пайплайна:

name: API CI/CD# 1. Когда запускать (Triggers)on:  push:    branches: [ "main" ]  pull_request:    branches: [ "main" ]# 2. Что именно делать (Jobs)jobs:  build-and-test:    runs-on: ubuntu-latest # Выделяем виртуальный сервер на Linux    # 3. Пошаговая инструкция (Steps)    steps:      - name: Скачиваем код репозитория        uses: actions/checkout@v4      - name: Устанавливаем Python 3.11        uses: actions/setup-python@v5        with:          python-version: "3.11"      - name: Устанавливаем зависимости        run: |          python -m pip install --upgrade pip          pip install -r requirements.txt

Разбираем код по косточкам

  • name: Имя пайплайна. Оно будет красиво отображаться в интерфейсе GitHub во вкладке «Actions».

  • on: Это триггер. Мы говорим: «GitHub, запускай этот скрипт каждый раз, когда кто-то пушит код напрямую в ветку main или открывает в нее Pull Request».

  • jobs: Задачи. Пока у нас одна задача, мы назвали её build-and-test.

  • runs-on: ubuntu-latest: GitHub выделяет нам абсолютно чистую виртуальную машину на последней версии Ubuntu.

  • steps: Самое интересное. Это последовательность шагов, которые сервер выполнит друг за другом:

  1. actions/checkout@v4 — готовый экшен (action). По умолчанию выделенный сервер абсолютно пустой. Этот шаг копирует ваш код из репозитория на виртуальную машину.

  2. actions/setup-python@v5 — еще один экшен. Он устанавливает нужную версию Python (в нашем случае 3.11).

  3. run — а это уже обычные консольные команды, которые мы запускаем внутри сервера. Ровно так же, как вы делали это локально: обновляем pip и устанавливаем fastapi, pytest и ruff из файла зависимостей.

4. Запускаем проверки: Тесты и Линтеры

Теперь, когда сервер готов, на нем лежит наш код и установлены все библиотеки, самое время добавить проверки. Продолжаем редактировать файл ci.yml. Добавим в конец блока steps те самые команды, которые раньше вводили руками:

      - name: Проверка кода линтером (Ruff)        run: ruff check .      - name: Запуск автотестов (Pytest)        run: pytest

Всё, базовый CI готов. Как только вы сделаете git push, GitHub поднимет сервер и пойдет по этим шагам.

Разбор полетов: что, если мы ошиблись?

Допустим, вы случайно сломали формулу расчета BMI в файле main.py (например, забыли возвести рост в квадрат) и запушили этот код. Что произойдет?

  1. GitHub скачает код и установит зависимости (шаги пройдут успешно).

  2. Линтер ruff проверит синтаксис (тоже успешно, синтаксических ошибок в неверной формуле нет).

  3. Очередь дойдет до pytest. Наш тест ожидает индекс 22.86, а получит 40.0. Тест упадет.

Что сделает GitHub: Он немедленно прервет выполнение пайплайна. Около вашего коммита появится красный крестик ❌. Если это был Pull Request, система покажет, что проверки провалены, и вы сразу увидите, что код не готов к слиянию.

Если же всё написано правильно, пайплайн загорится зеленой галочкой ✅, и вы будете на 100% уверены, что код работает.

5. Оптимизация: Ускоряем CI и расширяем покрытие

Наш базовый пайплайн отлично справляется со своей задачей, но у него есть два узких места: он проверяет код только на одной версии Python и каждый раз скачивает все библиотеки заново. В реальных проектах это съедает кучу времени. Давайте это исправим.

Матрица тестирования (Matrix Strategy)

Представьте, что ваше приложение будет запускаться на разных серверах, или вы пишете open-source пакет для других разработчиков. Проверять код только на Python 3.11 недостаточно — вдруг в 3.10 нет нужной функции, и всё сломается?

Вместо того чтобы писать отдельные пайплайны или дублировать шаги для каждой версии, в GitHub Actions есть элегантное решение — матрица.

Мы добавляем блок strategy с указанием нужных версий, а в шаге установки Python заменяем жестко прописанную версию на переменную ${{ matrix.python-version }}.

jobs:  build-and-test:    runs-on: ubuntu-latest    strategy:      matrix:        python-version: ["3.10", "3.11", "3.12"] # Указываем нужные версии

Что произойдет: GitHub автоматически поднимет три параллельных сервера и запустит ваши тесты одновременно на Python 3.10, 3.11 и 3.12. Время выполнения пайплайна останется прежним (задачи идут параллельно), а уверенность в коде вырастет втрое.

Кэширование зависимостей (Ускорение в разы)

Каждый раз при запуске шага установки зависимостей сервер честно идет в интернет, скачивает архивы FastAPI, Pytest и распаковывает их. В нашем микро-проекте это занимает секунд 10-15. Но в проектах с Pandas, SQLAlchemy или машинным обучением установка может длиться несколько минут.

Чтобы не скачивать одно и то же каждый раз, нужно включить кэш. Писать сложные скрипты не придется — разработчики официального экшена setup-python уже встроили этот функционал.

Добавляем всего одну строчку cache: 'pip' в наш шаг настройки Python:

    steps:      - name: Скачиваем код        uses: actions/checkout@v4      - name: Устанавливаем Python ${{ matrix.python-version }}        uses: actions/setup-python@v5        with:          python-version: ${{ matrix.python-version }}          cache: 'pip' # <-- Включаем магию кэширования

Как это работает под капотом: При первом запуске GitHub Actions скачает все пакеты и сохранит их в скрытый архив у себя на серверах. В качестве уникального «ключа» от этого архива он возьмет хеш вашего файла requirements.txt.

При следующем коммите сервер проверит: изменился ли requirements.txt? Если нет (вы просто правили код в main.py), он мгновенно достанет готовые библиотеки из кэша. Шаг установки зависимостей сократится до 1-2 секунд.

Собираем обновленный Job

Вот как теперь выглядит наша задача build-and-test с учетом всех оптимизаций:

jobs:  build-and-test:    runs-on: ubuntu-latest    strategy:      matrix:        python-version: ["3.10", "3.11", "3.12"]    steps:      - name: Скачиваем код репозитория        uses: actions/checkout@v4      - name: Устанавливаем Python ${{ matrix.python-version }}        uses: actions/setup-python@v5        with:          python-version: ${{ matrix.python-version }}          cache: 'pip'      - name: Устанавливаем зависимости        run: |          python -m pip install --upgrade pip          pip install -r requirements.txt      - name: Проверка кода линтером (Ruff)        run: ruff check .      - name: Запуск автотестов (Pytest)        run: pytest

6. Настраиваем CD (Непрерывное развертывание)

Тесты горят зеленым, линтеры довольны, код в ветке main гарантированно рабочий. Что дальше?

Дальше код должен попасть к пользователям. Исторически разработчики собирали релизы на своих ноутбуках и копировали файлы на сервер через FTP или SSH. Это долго, чревато ошибками и совершенно не масштабируется. Мы поручим эту работу GitHub.

В этом разделе мы настроим CD (Continuous Deployment): сделаем так, чтобы при создании новой версии (тега) GitHub сам упаковывал наше FastAPI-приложение в Docker-образ и отправлял его в хранилище (Docker Hub), откуда сервер сможет его скачать.

Базовое требование: Dockerfile

Чтобы собрать образ, в корне проекта (рядом с requirements.txt) должен лежать файл Dockerfile. Если вы никогда с ним не работали, для нашего проекта он будет выглядеть максимально просто (всего 5 строк):

FROM python:3.11-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY ./src ./srcCMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Безопасность: Прячем ключи в GitHub Secrets

Для публикации образа в Docker Hub пайплайну нужен ваш логин и пароль. Никогда не пишите пароли, токены или SSH-ключи в файлах .yml или в коде. Репозиторий могут сделать публичным, или доступ к нему получит новый сотрудник — и ваши данные утекут.

Для этого в GitHub есть Secrets — встроенный защищенный сейф для переменных окружения.

  1. Зайдите на страницу вашего репозитория в GitHub.

  2. Перейдите во вкладку Settings -> слева в меню Secrets and variables -> Actions.

  3. Нажмите зеленую кнопку New repository secret.

  4. Создайте два секрета:

  • Name: DOCKER_USERNAME | Secret: ваш логин на Docker Hub.

  • Name: DOCKER_TOKEN | Secret: ваш Access Token (лучше выпустить токен в настройках Docker Hub, а не использовать основной пароль от аккаунта).

Теперь мы можем безопасно обращаться к этим данным в коде пайплайна через синтаксис ${{ secrets.DOCKER_USERNAME }}. В логах GitHub эти значения будут автоматически скрыты звездочками ***.

Практика деплоя: Пишем CD-задачу

Мы не хотим выкатывать каждый мелкий коммит из ветки main. Правильная практика — делать деплой только тогда, когда мы выпускаем релиз (создаем Git-тег, например, v1.0.0).

Для начала обновим блок on в самом начале нашего файла ci.yml, чтобы он реагировал на теги:

on:  push:    branches: [ "main" ]    tags: [ "v*.*.*" ] # Запускать и при создании тегов вида v1.0.0  pull_request:    branches: [ "main" ]

Теперь добавим новую задачу (Job) в самый конец файла. Назовем её build-and-push.

Важнейший момент: CD не должен начинаться, если CI упал. Мы свяжем эти две задачи параметром needs.

  # ... здесь заканчивается наша предыдущая задача build-and-test ...  build-and-push:    # Запускаем эту задачу ТОЛЬКО если build-and-test завершилась успешно    needs: build-and-test     # Запускаем ТОЛЬКО если пуш содержит тег (а не просто коммит в main)    if: startsWith(github.ref, 'refs/tags/')    runs-on: ubuntu-latest    steps:      - name: Скачиваем код        uses: actions/checkout@v4      # Авторизуемся в Docker Hub с помощью наших спрятанных секретов      - name: Логин в Docker Hub        uses: docker/login-action@v3        with:          username: ${{ secrets.DOCKER_USERNAME }}          password: ${{ secrets.DOCKER_TOKEN }}      # Вытаскиваем версию из Git-тега (например, 1.0.0), чтобы назвать так образ      - name: Получаем версию релиза (тег)        id: get_version        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV      # Собираем и пушим Docker-образ      - name: Сборка и отправка Docker-образа        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ secrets.DOCKER_USERNAME }}/bmi-api:latest            ${{ secrets.DOCKER_USERNAME }}/bmi-api:${{ env.VERSION }}

Как это выглядит на практике

Теперь ваш рабочий процесс выглядит как на конвейере:

  1. Вы работаете локально, пишите код.

  2. Делаете git push. GitHub запускает только первую часть (build-and-test). Линтеры проверили код, тесты прогнались. CD-задача игнорируется, так как это не релиз.

  3. Вы понимаете, что накопили достаточно фич для новой версии. В терминале вы создаете тег: git tag v1.0.0 и отправляете его на сервер: git push origin v1.0.0.

  4. GitHub снова запускает тесты. Как только они загораются зеленым, автоматически стартует задача build-and-push.

  5. Сервер авторизуется в Docker Hub, собирает образ bmi-api и вешает на него сразу два ярлыка: latest (последняя версия) и 1.0.0 (жестко зафиксированная версия).

Всё. Ваш свежий, проверенный и упакованный код лежит в Docker Hub. Любой сервер в мире теперь может скачать его командой docker pull и запустить.

7. Проверка, все ли настроили?

Этап 1. Проверяем СI (Тесты и Линтеры)

Для начала просто отправим наш новый файл конфигурации и Dockerfile на GitHub.

Откройте терминал и введите:

git add .git commit -m "Добавил полный CI/CD пайплайн и Dockerfile"git push

Что должно произойти:

  1. Зайдите на GitHub во вкладку Actions.

  2. Вы увидите, что пайплайн запустился. Но запустится только задача build-and-test (сразу 3 параллельных сервера для разных версий Python).

  3. Задача build-and-push (работа с Docker) будет пропущена. Сервер проигнорирует её, потому что мы сделали просто коммит, а не релиз. Это значит, что наше условие if: startsWith(github.ref, 'refs/tags/') работает правильно!

Этап 2. Проверяем CD (Сборка Docker-образа)

Теперь сымитируем выпуск новой версии (релиза), чтобы заставить GitHub собрать Docker-образ.

В терминале создаем Git-тег и отправляем его на сервер:

# Создаем ярлык версии (тег)git tag v1.0.0# Отправляем именно этот тег на GitHubgit push origin v1.0.0

Что должно произойти сейчас:

  1. Снова идите во вкладку Actions в GitHub.

  2. Вы увидите новый запуск пайплайна (он будет называться по имени коммита, но рядом будет значок бирки v1.0.0).

  3. Сначала снова пробегут тесты (build-and-test).

  4. Как только тесты успешно завершатся, вы увидите, что запустилась вторая задачаbuild-and-push.

  5. Кликните на нее, чтобы открыть логи. Вы увидите, как сервер логинится в Docker Hub, шаг за шагом выполняет инструкции из Dockerfile и отправляет слои образа в интернет.

Этап 3. Финальная проверка (Где мой образ?)

Когда задача загорится зеленой галочкой ✅, нужно убедиться, что образ действительно долетел до хранилища.

Способ 1: Через браузер Зайдите на hub.docker.com, авторизуйтесь и перейдите в свой профиль. Там должен появиться новый репозиторий bmi-api. Если зайти в него и открыть вкладку Tags, вы увидите два тега: latest и 1.0.0.

Способ 2: Запуск на вашем компьютере Откройте терминал и просто попробуйте скачать и запустить этот свежий образ (замените ВАШ_ЛОГИН на логин от Docker Hub):

docker run -p 8000:8000 ВАШ_ЛОГИН/bmi-api:1.0.0

Если Docker скачал образ, и у вас запустился сервер Uvicorn — поздравляю! Вы настроили полноценный, рабочий CI/CD пайплайн.

8. Заключение

Чтобы вся эта автоматизация имела реальный смысл, остается сделать одну вещь в настройках GitHub: зайти в Settings -> Branches -> Add branch protection rule, указать ветку master и включите галочку Require status checks to pass before merging для нашей задачи build-and-test.

Всё. Теперь залить сломанный код в главную ветку физически невозможно — GitHub заблокирует кнопку слияния (Merge), пока пайплайн не загорится зеленой галочкой.

Что мы получили в итоге: За пару десятков строк конфигурации мы полностью делегировали серверу всю рутину. Он сам следит за чистотой кода через ruff, параллельно гоняет тесты на трех версиях Python, кэширует зависимости для скорости и автоматически собирает Docker-образы при выпуске релизов. Вы просто пишете код, а робот делает всё остальное.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.

Посмотреть исходники, структуру папок и забрать себе готовый рабочий файл ci.yml можно в репозитории: github.com/Zaplavs/bmi_project.

ссылка на оригинал статьи https://habr.com/ru/articles/1037002/