
❯ Вступление
В этой статье будет разбираться написание GitHub Actions пайплайна на основе моего стенда cicd-practice на GitHub (подробно все можно посмотреть там). Стенд состоит из FastAPI (веб API на Python) и PostgreSQL. Все сервисы разворачиваются в Docker с помощью Docker Compose (микросервисы) на VDS от Timeweb Cloud. Подробнее об остальных инструментах будет ниже, в отдельном блоке.
Для части приложения я также написал около 90 Pytest тестов, которые будут воспроизводиться в пайплайне, но об этом позже.
Backend-часть также будет разбираться, но менее подробно, для общего понимания работы. Уточню, что я не являюсь backend-разработчиком, поэтому часть приложения нужна только для симуляции рабочей архитектуры, поверх которой будет строиться CI/CD.
Немного терминов
Пайплайн — задачи организованные в виде непрерывного конвейера для автоматизации работы и передачи результатов от одного этапа к другому
CI (Continuous Integration) — это практика автоматической сборки и тестирования кода каждый раз, когда разработчик добавляет новые изменения в общий репозиторий.
CD (Continuous Delivery / Deployment) — автоматическая подготовка проверенного кода к релизу или его автоматический запуск на сервере для пользователей
❯ Подробнее про инструменты
Ниже перечислены некоторые основные используемые инструменты:
Python 3.13 — основной язык проекта.
FastAPI — создание REST API.
Uvicorn — запуск FastAPI-приложения.
PostgreSQL 18 — основная и тестовая базы данных.
SQLAlchemy — работа с базой данных через ORM.
Alembic — миграции схемы базы данных.
Pydantic — валидация данных и конфигурации.
JWT / OAuth2 — аутентификация и авторизация пользователей.
Docker — упаковка приложения в контейнеры.
Docker Compose — запуск backend, PostgreSQL, pgAdmin и тестов.
GitHub Actions — автоматизация CI/CD.
Ruff — проверка качества Python-кода.
pytest — запуск unit- и интеграционных тестов.
Bandit — статический анализ безопасности Python-кода.
pip-audit — проверка зависимостей на уязвимости.
GitHub Container Registry — хранение Docker-образов.
SSH Action — деплой приложения на удалённый сервер.
pgAdmin — веб-интерфейс для управления PostgreSQL.
❯ Docker Compose (про каждый микросервис)
Ниже представлен основной docker-compose.yml файл, в котором находятся все развертываемые сервисы.
Основными двумя контейнерами являются backend и main_db, pgadmin4 используется как интерфейс для удобного взаимодействия с базой.
test_db и tests контейнеры нужны только для интеграционных и юнит тестов.
Интеграционные тесты — это проверка совместной работы нескольких модулей.
Юнит тесты — отдельные проверки определенных частей кода, функций.
services: test_db: # Тестовая база данных image: postgres:18 environment: - POSTGRES_DB=${TEST_DB_NAME} - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_USER=${POSTGRES_USER} networks: - backend healthcheck: # Проверка готовности СУБД перед запуском тестов test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${TEST_DB_NAME}"] interval: 5s timeout: 5s retries: 5 ports: - 5433:5432 # Порт 5433, чтобы не конфликтовать с main_db command: > postgres -c listen_addresses='*' backend: # Основное FastAPI приложение image: ${BACKEND_IMAGE:-ghcr.io/canntstand/cicd-practice-backend:latest} # Образ из GHCR после CI/CD networks: - backend volumes: - ./backend:/backend # Синхронизация кода с хостом для hot-reload environment: - DB_URL=${DB_URL} - SECRET_KEY=${SECRET_KEY} - REFRESH_SECRET_KEY=${REFRESH_SECRET_KEY} - ALGORITHM=${ALGORITHM} - TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES} - REFRESH_TOKEN_EXPIRE_MINUTES=${REFRESH_TOKEN_EXPIRE_MINUTES} - REFRESH_ALGORITHM=${REFRESH_ALGORITHM} command: > # Накат миграций Alembic и запуск Uvicorn sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /backend/app" depends_on: main_db: condition: service_healthy # Ожидание готовности основной БД ports: - "8000:8000" pgadmin4: # Веб-интерфейс для работы с базой image: dpage/pgadmin4:latest ports: - 5050:80 environment: - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL} - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD} depends_on: - main_db volumes: - pgadmin4_data:/var/lib/pgadmin # Хранение настроек pgAdmin networks: - backend tests: # Контейнер для автоматического запуска тестов build: context: . dockerfile: Dockerfile.tests # Изолированный Dockerfile для тестов networks: - backend environment: - DB_URL=${TEST_DB_URL} - SECRET_KEY=${SECRET_KEY} - REFRESH_SECRET_KEY=${REFRESH_SECRET_KEY} - ALGORITHM=${ALGORITHM} - TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES} - REFRESH_TOKEN_EXPIRE_MINUTES=${REFRESH_TOKEN_EXPIRE_MINUTES} - REFRESH_ALGORITHM=${REFRESH_ALGORITHM} command: > # Накат миграций на тестовую БД и запуск Pytest sh -c "set -e; cd backend && alembic upgrade head; cd .. && pytest" tty: true stdin_open: true volumes: - ./tests:/test/tests - ./backend:/test/backend depends_on: test_db: condition: service_healthy # Запуск тестов только после готовности test_db main_db: # Основная база данных (PostgreSQL) image: postgres:18 environment: - POSTGRES_DB=${DB_NAME} - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_USER=${POSTGRES_USER} ports: - 5432:5432 networks: - backend volumes: - pg_main_data:/var/lib/postgresql/ # Хранение постоянных данных БД healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${DB_NAME}"] interval: 5s timeout: 5s retries: 5 command: > postgres -c listen_addresses='*' volumes: # Именованные тома для сохранения данных pg_main_data: pgadmin4_data: networks: # Изолированная сеть для взаимодействия контейнеров backend: driver: bridge
❯ Архитектура Backend
Ниже представлены 3 схемы, которые отображают общую работу приложения.



Теперь приведу пример кода аутентификации для наглядности.
У меня логика эндпоинтов разделена на 2 части:
В routers: функции, которые принимают веб-запросы, вызывают функции из services и отдают результат.
В services: функции, которые отвечают за работу непосредственно с базой данных.
routers/auth.py:
# Импорт основных компонентов FastAPI from fastapi import APIRouter, Cookie, Depends, HTTPException, Response from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session # Внутренние модули проекта from ..database.database import get_db from ..services import auth from ..utils.dependencies import get_current_user, refresh_access_token from ..validation import schemas # Создание роутера с базовым префиксом /api и тегами для Документации (Swagger) router = APIRouter(prefix="/api", tags=["Auth", "API"]) @router.post("/register", status_code=201) def register(body: schemas.User, db: Session = Depends(get_db)): """Регистрация нового пользователя.""" # Вызов сервиса регистрации для записи пользователя в БД is_success = auth.register(body, db) if is_success: return {"message": "Successfully registered"} raise HTTPException(500, detail="Registration failed") @router.post("/login", status_code=200, response_model=schemas.TokenResp) def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): """Аутентификация пользователя по логину/паролю и выдача JWT-токенов.""" return auth.login(form, db) @router.delete("/logout", status_code=204) def logout( refresh_token: str = Cookie(None), db: Session = Depends(get_db), user_id: int = Depends(get_current_user), # Проверка авторизации через access_token ): if not refresh_token: raise HTTPException(400, detail="Refresh token missing") # Удаление токена из базы и очистка client-side Cookie if auth.logout(refresh_token, db): response = Response(status_code=204) response.delete_cookie("access_token") response.delete_cookie("refresh_token") return response raise HTTPException(500, detail="something went wrong in logout function") @router.post("/refresh", status_code=200, response_model=schemas.TokenResp) def refresh_token_pair( refresh_token: str = Cookie(None), db: Session = Depends(get_db) ): """Обновление пары токенов (Access + Refresh) с использованием Refresh-токена из Cookie.""" if not refresh_token: raise HTTPException(400, detail="Refresh token missing") return refresh_access_token(refresh_token, db)
services/auth.py:
import datetime # FastAPI и инструменты безопасности from fastapi import HTTPException, Response from fastapi.security import OAuth2PasswordRequestForm # Работа с JWT и ORM SQLAlchemy from jose import jwt from sqlalchemy import delete from sqlalchemy.orm import Session # Внутренние модули приложения from ..config import settings as ss from ..database import models from ..utils.dependencies import create_token_pair from ..utils.exc import db_exc_check from ..utils.hash import hash_pwd, verify_pwd from ..validation import schemas from . import users @db_exc_check def register(body: schemas.User, db: Session) -> bool: """Хеширует пароль, создает нового пользователя и сохраняет его в БД.""" body.password = hash_pwd(body.password) user = users.create_user(body, db) db.commit() return bool(user) @db_exc_check def login( form: OAuth2PasswordRequestForm, db: Session, response: Response = Response() ) -> schemas.TokenResp: """ Аутентифицирует пользователя, сбрасывает старые refresh-токены, генерирует новую пару токенов и устанавливает их в HttpOnly Cookie. """ # Поиск пользователя и проверка валидности пароля user = users.get_user_by_form(form, db) if not user or not verify_pwd(form.password, user.password): raise HTTPException(400, detail="Invalid credentials") # Аннулирование предыдущих refresh-токенов пользователя (сессии) db.execute(delete(models.RefreshToken).where(models.RefreshToken.owner == user.id)) # Генерация новой пары токенов и запись refresh-токена в БД token_pair = create_token_pair(user.id) _save_refresh_token(db, user.id, token_pair.refresh_token) # Установка безопасных HttpOnly Cookie с токенами в ответ response.set_cookie( key="access_token", value=token_pair.access_token, httponly=True, secure=True, max_age=15 * 60, # 15 минут ) response.set_cookie( key="refresh_token", value=token_pair.refresh_token, httponly=True, secure=True, max_age=7 * 24 * 60 * 60, # 7 дней ) db.commit() return token_pair def _save_refresh_token(db: Session, user_id: int, refresh_token: str): """Декодирует exp из JWT и сохраняет refresh-токен в базу данных.""" expires = datetime.datetime.fromtimestamp( jwt.decode( refresh_token, ss.REFRESH_SECRET_KEY, algorithms=[ss.REFRESH_ALGORITHM], )["exp"], tz=datetime.timezone.utc, ) db.add(models.RefreshToken(token=refresh_token, owner=user_id, expires_at=expires)) db.commit() @db_exc_check def logout(refresh_token: str, db: Session) -> bool: """Удаляет указанный refresh-токен из базы данных для завершения сессии.""" db.execute( delete(models.RefreshToken).where(models.RefreshToken.token == refresh_token) ) db.commit() return True
❯ Кратко про Pytest
Pytest в этом проекте используется для автоматической проверки работы приложения и базы данных. Он запускает тесты, проверяет API, бизнес-логику и корректность схемы данных, а затем сообщает, что прошло, а что сломалось.
В проекте есть набор тестов в папке tests, и CI-пайплайн запускает их автоматически после сборки и перед деплоем, чтобы ловить ошибки.
Файлы с тестами начинаются с test_, поэтому Pytest находит их автоматически.
Пример функций тестов
class TestLogin: # Тесты для эндпоинта авторизации /api/login # Успешная авторизация с правильным логином и паролем (код 200) def test_post_login_returns_200(self, register: None, client: TestClient): resp = client.post( "/api/login", data={"username": "example", "password": "example123"}, ) assert resp.status_code == 200 # Попытка входа с неверным логином или неверным паролем (код 400) @pytest.mark.parametrize( "username,password", [("false", "example123"), ("example", "false")] ) def test_post_login_with_invalid_credentials_returns_400( self, register: None, username: str, password: str, client: TestClient ): resp = client.post( "/api/login", data={"username": username, "password": password}, ) assert resp.status_code == 400 # Передача незаполненных полей — ошибка валидации FastAPI (код 422) @pytest.mark.parametrize( "data", [ ({"username": None, "password": "example123"}), ({"username": "example", "password": None}), ], ) def test_post_login_with_missing_fields_returns_422( self, register: None, data: dict, client: TestClient ): resp = client.post( "/api/login", data=data, ) assert resp.status_code == 422
❯ Кратко про Alembic
Для этой статьи данный инструмент не так сильно важен, поэтому кратко напишу зачем он вообще нужен, и можно идти дальше.
Alembic в проекте используется для управления структурой базы данных PostgreSQL.
Вместо ручного изменения таблиц всё фиксируется в отдельной миграции: например, добавление столбца, индекса, ограничения или новой таблицы.
Миграции хранятся в backend/alembic/versions и выполняются последовательно через функции upgrade() и downgrade(), что позволяет как обновлять базу, так и откатывать изменения.
Alembic получает модели SQLAlchemy и подключается к базе через DB_URL, благодаря этому таблицы базы остаются синхронизированными с кодом приложения.
❯ Архитектура GitHub Actions пайплайна
Мой CI/CD состоит из:
Линтинг кода и конфигов
Интеграционные и юнит тесты
Проверки безопасности
Сборка docker образов и отправка в ghcr.io
Подключение к серверу, скачивание образов и репозитория, развертывание базы данных и приложения.


❯ Настройка Continuous Integration (CI)
Главная задача этапа CI — убедиться, что каждый pull запрос или коммит в основную ветку содержит рабочий код, проходящий линтеры и интеграционные тесты.
Главный файл пайплайна хранится в .github/workflows/ci-cd.yml.
Линтинг конфигов и кода
name: CI/CD Pipeline # Название пайплайна on: # Когда запускать push: branches: ["main"] # При пуше в ветку main pull_request: branches: ["main"] # При создании/обновлении PR в main env: # Переменные REGISTRY: ghcr.io # Реестр для Docker-образов IMAGE_NAME: ${{ github.repository }} # Название репозитория jobs: lint: # Задача проверки кода name: Code & Config Linting runs-on: ubuntu-latest # Запускаем на Ubuntu steps: - uses: actions/checkout@v4 # Скачиваем код репозитория - name: Yamllint # Проверка файла docker-compose uses: karancode/yamllint-github-action@v2.1.1 with: yamllint_file_or_dir: "docker-compose.yml" yamllint_strict: false - name: Set up Python # Установка Python uses: actions/setup-python@v5 with: python-version: "3.13" - name: Run Ruff (Linter) # Проверка кода линтером run: | pip install ruff ruff check . # Проверяем все .py файлы
Проверки безопасности
security: # Задача проверки безопасности name: Security Checks runs-on: ubuntu-latest # Запускаем на Ubuntu permissions: # Права для GITHUB_TOKEN security-events: write # Разрешение на отправку отчетов по безопасности contents: read # Разрешение на чтение кода steps: - uses: actions/checkout@v4 # Скачиваем код репозитория - name: Set up Python # Установка Python uses: actions/setup-python@v5 with: python-version: "3.13" - name: Security audit for dependencies # Проверка зависимостей на известные уязвимости uses: pypa/gh-action-pip-audit@v1.1.0 with: inputs: backend/requirements.txt ignore-vulns: PYSEC-2026-1325 # Игнорируем уязвимость ecdsa (зависимость python-jose, нет апдейтов) - name: Perform Bandit Analysis # Сканирование кода Python на уязвимости (Bandit) uses: PyCQA/bandit-action@v1 with: path: "backend" # Проверяемая директория
Тестирование работы
tests: # Задача запуска тестов name: Integration & Unit Tests runs-on: ubuntu-latest # Запускаем на Ubuntu steps: - uses: actions/checkout@v4 # Скачиваем код репозитория - name: Create .env file # Создаем .env с переменными (из secrets/vars или со значениями по умолчанию) run: | # На практике значения по умолчанию лучше не добавлять, чтобы избежать неожиданностей в продакшене, даже если все тесты прошли успешно cat << EOF > .env TEST_DB_NAME=${{ vars.TEST_DB_NAME || 'test_db' }} DB_NAME=${{ vars.DB_NAME || 'main_db' }} DB_PASSWORD=${{ secrets.DB_PASSWORD || 'example123' }} POSTGRES_USER=${{ vars.POSTGRES_USER || 'example' }} TEST_DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@test_db:5432/${{ vars.TEST_DB_NAME || 'test_db' }} DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@main_db:5432/${{ vars.DB_NAME || 'main_db' }} SECRET_KEY=${{ secrets.SECRET_KEY || 'example_key' }} REFRESH_SECRET_KEY=${{ secrets.REFRESH_SECRET_KEY || 'example_refresh' }} ALGORITHM=${{ vars.ALGORITHM || 'HS256' }} REFRESH_ALGORITHM=${{ vars.REFRESH_ALGORITHM || 'HS256' }} TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_MINUTES=1440 PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_PASSWORD=example123 EOF - name: Start services # Поднимаем базы данных и ждем их полной готовности (--wait) run: docker compose up -d --wait main_db test_db - name: Run tests via Docker Compose # Запускаем тесты и удаляем тестовый контейнер после финиша (--rm) run: docker compose run --rm tests
❯ Настройка Continuous Deployment (CD)
После того как код прошел все проверки на этапе CI, его необходимо автоматически доставить на сервер, a все собранные образы нужно отправить в registry.
Cборка образов и отправка в ghcr.io
build-and-push: # Задача сборки и публикации Docker-образа name: Build & Push Docker Image needs: [lint, tests, security] # Ждем успешного выполнения прошлых трех задач if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Запуск только при прямом пуше в main (не для PR) runs-on: ubuntu-latest # Запускаем на чистой Ubuntu permissions: # Права для загрузки образов contents: read # Чтение кода packages: write # Запись в реестр пакетов (GHCR) steps: - uses: actions/checkout@v4 # Скачиваем код репозитория - name: Log in to GitHub Container Registry # Авторизация в GHCR с помощью системного токена uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker # Автосоздание тегов (latest и SHA коммита) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend tags: | type=raw,value=latest type=sha,prefix= - name: Set up Docker Buildx # Включение Buildx для поддержки кэширования uses: docker/setup-buildx-action@v3 - name: Build and push Docker image # Сборка Dockerfile из директории backend и отправка в GHCR uses: docker/build-push-action@v5 with: context: ./backend file: ./backend/Dockerfile push: true # Публикуем собранный образ tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha # Использование кэша GitHub Actions для ускорения сборки cache-to: type=gha,mode=max
Деплой на сервер
Теперь стоит уточнить, что CI/CD прежде всего нужен для автоматизации и безопасности при изменении кодовой базы. Это значит, что он не должен отвечать за первоначальную настройку сервера. Для этого лучше подойдет, к примеру, Ansible.
В данный момент для работы на сервере нужно:
Установить Git
Установить Docker Engine
Склонировать репозиторий по пути /opt/cicd-practice
deploy: # Задача деплоя на удаленный сервер name: Deploy to Server needs: [build-and-push] # Ждем успешного завершения сборки и пуша образа if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Запуск только при пуше в main runs-on: ubuntu-latest # Запускаем на Ubuntu steps: - name: Deploy via SSH # Подключение к серверу по SSH и запуск команд uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | set -e # Остановка выполнения при любой ошибке cd /opt/cicd-practice # Переход в директорию проекта на сервере git pull origin main # Обновление исходного кода из репозитория cat << EOF > .env # Создание .env файла с переменными окружения TEST_DB_NAME=${{ vars.TEST_DB_NAME || 'test_db' }} DB_NAME=${{ vars.DB_NAME || 'main_db' }} DB_PASSWORD=${{ secrets.DB_PASSWORD || 'example123' }} POSTGRES_USER=${{ vars.POSTGRES_USER || 'example' }} TEST_DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@test_db:5432/${{ vars.TEST_DB_NAME || 'test_db' }} DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@main_db:5432/${{ vars.DB_NAME || 'main_db' }} SECRET_KEY=${{ secrets.SECRET_KEY || 'example_key' }} REFRESH_SECRET_KEY=${{ secrets.REFRESH_SECRET_KEY || 'example_refresh' }} ALGORITHM=${{ vars.ALGORITHM || 'HS256' }} REFRESH_ALGORITHM=${{ vars.REFRESH_ALGORITHM || 'HS256' }} TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_MINUTES=1440 PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_PASSWORD=example123 BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:latest EOF echo "${{ secrets.DEPLOY_TOKEN || secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin # Авторизация сервера в GHCR docker compose pull backend # Скачивание обновленного Docker-образа docker compose up -d --remove-orphans backend pgadmin4 main_db # Перезапуск сервисов в фоновом режиме
❯ Подготовка переменных
Для CI/CD в .env значения хранить нельзя ни в коем случае, иначе они будут видны при выполнении пайплайна. Поэтому, в нашем случае, переменные нужно создать в самом GitHub.
Repository Secrets — это зашифрованные конфиденциальные данные (пароли, API-ключи, SSH-ключи), которые маскируются в логах и недоступны для просмотра в интерфейсе после создания.
Repository Variables — это открытые конфигурационные параметры (имена баз данных, порты, URL, флаги), которые хранятся в открытом виде и отображаются в логах выполнения workflows.
Repository Secrets
SERVER_HOST— IP-адрес или доменное имя вашего VPS/сервера.SERVER_USER— имя SSH-пользователя на сервере.SSH_PRIVATE_KEY— приватный SSH-ключ для подключения к серверу.DB_PASSWORD— пароль от СУБД PostgreSQL.SECRET_KEY— секретный ключ приложения для подписи Access JWT-токенов.REFRESH_SECRET_KEY— секретный ключ приложения для подписи Refresh-токенов.
Repository Variables
POSTGRES_USER— имя пользователя (логин) PostgreSQL.DB_NAME— название основной базы данных.TEST_DB_NAME— название базы данных для запуска тестов.ALGORITHM— алгоритм шифрования Access JWT-токена.REFRESH_ALGORITHM— алгоритм шифрования Refresh-токена.
❯ Заключение
Мы пошагово создали надежный CI/CD-пайплайн для проекта на FastAPI и PostgreSQL. Теперь при создании pull request код автоматически проверяется на ошибки стилистики и проходит интеграционные тесты с реальной базой данных. После одобрения изменений код бесшовно деплоится на сервер.
Полный исходный код проекта с готовыми конфигурационными файлами и выполненными пайплайнами доступен в репозитории canntstand/cicd-practice.
Если вам интересна тема CI/CD, автоматизации и разработки, делюсь своими практическими заметками и опытом в Telegram-канале My Tech Notes.
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩