# CI/CD для Битрикс-проектов на GitHub Actions

URL: https://dmeshcheryakov.ru/blog/bitrix-cicd-github-actions/
Раздел: 1С-Битрикс
Теги: 1С-Битрикс, CI/CD, GitHub Actions, DevOps
Опубликовано: 2026-08-17
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

> Настраиваем автоматический деплой Битрикс-сайта: линтинг, тесты, деплой на dev/prod через SSH. Пошаговый гайд с рабочими workflow.

Битрикс и CI/CD в одном предложении до сих пор вызывают скепсис: «там же половина настроек в базе», «там админка правит файлы», «у нас деплой через FileZilla и так работает». Работает — ровно до вечера пятницы, когда кто-то заливает половину файлов, а вторую половину не успевает.

Полноценный CI/CD для Битрикса действительно строится сложнее, чем для Laravel: часть состояния живёт в базе, часть файлов правится на боевом сервере, а ядро занимает гигабайты. Но базовый уровень — линтер на pull request, автоматический деплой из main и возможность откатиться — настраивается за вечер и закрывает большинство реальных проблем.

Ниже рабочие workflow с оговорками про грабли, на которые я наступал.

## Зачем CI/CD для Битрикс

Типичный процесс деплоя Битрикс-сайта:
1. Подключиться по SSH/FTP
2. Залить файлы
3. Надеяться, что ничего не сломалось
4. Откатывать вручную, если сломалось

**С CI/CD:**
1. `git push` в main
2. Автоматически: линтинг → тесты → деплой
3. Откат одной кнопкой

**Что настроим:** Автоматический деплой при push в main, проверка кода линтером, деплой на dev-сервер из feature-веток, Telegram-уведомления о статусе.

## Структура проекта

```bash
project/
├── .github/
│   └── workflows/
│       ├── deploy.yml      # Деплой на prod
│       ├── deploy-dev.yml  # Деплой на dev
│       └── lint.yml        # Проверка кода
├── local/
│   ├── php_interface/
│   └── components/
├── composer.json
├── phpcs.xml              # Правила линтера
└── .env.example
```

## Шаг 1: Линтинг PHP

Создаём workflow для проверки кода:

```yaml
# .github/workflows/lint.yml
name: Lint PHP
on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main, develop]
jobs:
  phpcs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer, cs2pr
      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-
      - name: Install dependencies
        run: composer install --no-progress --prefer-dist
      - name: Run PHP_CodeSniffer
        run: vendor/bin/phpcs --report=checkstyle | cs2pr
```

### Конфигурация PHP_CodeSniffer

```xml
<!-- phpcs.xml -->
<?xml version="1.0"?>
<ruleset name="Bitrix Project">
    <description>Coding standard for Bitrix project</description>
    <!-- Проверяем только наш код -->
    <file>./local</file>
    <!-- Исключаем сторонние библиотеки -->
    <exclude-pattern>*/vendor/*</exclude-pattern>
    <exclude-pattern>*/node_modules/*</exclude-pattern>
    <exclude-pattern>*/bitrix/*</exclude-pattern>
    <!-- Базовый стандарт PSR-12 -->
    <rule ref="PSR12">
        <!-- Битрикс использует CamelCase для некоторых методов -->
        <exclude name="PSR1.Methods.CamelCapsMethodName"/>
    </rule>
    <!-- Дополнительные проверки -->
    <rule ref="Generic.Files.LineLength">
        <properties>
            <property name="lineLimit" value="120"/>
            <property name="absoluteLineLimit" value="150"/>
        </properties>
    </rule>
</ruleset>
```

### composer.json

```json
{
    "require-dev": {
        "squizlabs/php_codesniffer": "^3.7"
    },
    "scripts": {
        "lint": "phpcs",
        "lint:fix": "phpcbf"
    }
}
```

## Шаг 2: Деплой на production

```yaml
# .github/workflows/deploy.yml
name: Deploy to Production
on:
  push:
    branches: [main]
# Запрещаем параллельные деплои
concurrency:
  group: production
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
      - name: Add known hosts
        # ssh-keyscan принимает тот ключ, который сервер отдаст в момент запуска,
        # то есть проверки подлинности хоста фактически нет. Надёжнее хранить
        # известный отпечаток в secrets.SSH_KNOWN_HOSTS и писать его в файл
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
      - name: Deploy via rsync
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            --exclude='bitrix/php_interface/dbconn.php' \
            --exclude='bitrix/.settings.php' \
            --exclude='upload' \
            --exclude='bitrix/backup' \
            --exclude='bitrix/cache' \
            --exclude='bitrix/managed_cache' \
            --exclude='bitrix/stack_cache' \
            ./ $SSH_USER@$SSH_HOST:$DEPLOY_PATH/
      - name: Clear Bitrix cache
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
        run: |
          ssh $SSH_USER@$SSH_HOST << 'EOF'
            cd $DEPLOY_PATH
            rm -rf bitrix/cache/*
            rm -rf bitrix/managed_cache/*
            rm -rf bitrix/stack_cache/*
            echo "Cache cleared"
          EOF
      - name: Notify Telegram
        if: always()
        # В проде тег или SHA вместо @master: сторонний action выполняется
        # в раннере с доступом к секретам, и @master — это «что автор выложит,
        # то и запустится в вашем пайплайне»
        uses: appleboy/telegram-action@master
        with:
          to: ${{ secrets.TELEGRAM_CHAT_ID }}
          token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          format: html
          message: |
            ${{ job.status == 'success' && '✅' || '❌' }} <b>Deploy to Production</b>
            Status: ${{ job.status }}
            Commit: <code>${{ github.sha }}</code>
            Author: ${{ github.actor }}
            <a href="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}">View logs</a>
```

**Внимание на heredoc с закавыченным разделителем.** В шаге очистки кэша используется `<< 'EOF'`, а кавычки вокруг `EOF` запрещают подстановку переменных на стороне GitHub Actions. На сервер уезжает буквальная строка `cd $DEPLOY_PATH`, где `$DEPLOY_PATH` — переменная удалённого шелла, а её там нет. `cd` без аргумента отправляет в домашнюю директорию, и дальнейший `rm -rf bitrix/cache/*` в лучшем случае не делает ничего, а в худшем — работает не там, где вы думаете.

Передавайте путь явно:

```yaml
run: |
  ssh $SSH_USER@$SSH_HOST "cd '$DEPLOY_PATH' && rm -rf bitrix/cache/* bitrix/managed_cache/* bitrix/stack_cache/*"
```

**Никогда не деплойте `dbconn.php`, `.settings.php` и `.env` из репозитория.** Эти файлы настраиваются на сервере и исключаются из rsync. Список исключений здесь — не формальность, а защита от самой дорогой ошибки в этом workflow: одна строка `--delete` без нужного `--exclude` унесёт `upload/` со всеми загруженными файлами.

**Про `--delete` и атомарность.** `rsync` льёт файлы прямо в живой сайт: несколько секунд проект работает в состоянии «часть файлов новая, часть старая», и в этот момент возможны фатальные ошибки у пользователей. Для небольшого проекта это приемлемо, для нагруженного — нет. Промышленный вариант: заливать в новую директорию `releases/<sha>` и переключать симлинк одной атомарной операцией, тогда же становится тривиальным откат — переключить симлинк обратно.

Минимальное улучшение без переделки схемы — флаг `--delay-updates`: rsync сначала передаст всё во временные файлы и затем переименует их одним пакетом, сократив окно несогласованности.

## Шаг 3: Деплой на dev-сервер

Для feature-веток используем отдельный workflow:

```yaml
# .github/workflows/deploy-dev.yml
name: Deploy to Development
on:
  push:
    branches:
      - develop
      - 'feature/**'
concurrency:
  group: development
  cancel-in-progress: true  # Отменяем предыдущий деплой
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: development
    steps:
      - uses: actions/checkout@v4
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
      - name: Add known hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.DEV_SSH_HOST }} >> ~/.ssh/known_hosts
      - name: Deploy
        env:
          SSH_HOST: ${{ secrets.DEV_SSH_HOST }}
          SSH_USER: ${{ secrets.DEV_SSH_USER }}
          DEPLOY_PATH: ${{ secrets.DEV_DEPLOY_PATH }}
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            --exclude='bitrix/php_interface/dbconn.php' \
            --exclude='bitrix/.settings.php' \
            --exclude='upload' \
            ./ $SSH_USER@$SSH_HOST:$DEPLOY_PATH/
      - name: Run migrations
        env:
          SSH_HOST: ${{ secrets.DEV_SSH_HOST }}
          SSH_USER: ${{ secrets.DEV_SSH_USER }}
          DEPLOY_PATH: ${{ secrets.DEV_DEPLOY_PATH }}
        run: |
          ssh $SSH_USER@$SSH_HOST << 'EOF'
            cd $DEPLOY_PATH
            php local/tools/migrate.php
          EOF
```

## Шаг 4: Настройка секретов

В Settings → Secrets and variables → Actions добавьте:

| Secret | Описание |
|--------|----------|
| `SSH_PRIVATE_KEY` | Приватный ключ для подключения |
| `SSH_HOST` | IP или домен сервера |
| `SSH_USER` | Пользователь SSH |
| `DEPLOY_PATH` | Путь до корня сайта |
| `TELEGRAM_BOT_TOKEN` | Токен бота для уведомлений |
| `TELEGRAM_CHAT_ID` | ID чата/группы |

### Генерация SSH-ключа

```bash
# Генерируем ключ без пароля
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions -N ""
# Копируем публичный ключ на сервер
ssh-copy-id -i ~/.ssh/github_actions.pub user@server
# Приватный ключ добавляем в GitHub Secrets
cat ~/.ssh/github_actions
```

## Шаг 5: Защита веток

В Settings → Branches → Add branch protection rule:

```
Branch name pattern: main
☑ Require a pull request before merging
☑ Require status checks to pass before merging
  └── Required checks: "phpcs"
☑ Require branches to be up to date before merging
```

Теперь нельзя влить в main без прохождения линтера.

## Продвинутые сценарии

### Деплой базы данных (миграции)

```php
<?php
// local/tools/migrate.php
use Bitrix\Main\Loader;
use Bitrix\Main\Application;

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

$connection = Application::getConnection();

// Миграции хранятся в local/migrations/, а журнал применённых — ВНЕ директории
// деплоя: rsync --delete снесёт любой файл, которого нет в репозитории
$migrationsDir = $_SERVER['DOCUMENT_ROOT'] . '/local/migrations/';
$appliedFile = '/var/lib/bitrix-deploy/migrations.applied';

// Получаем список применённых миграций
$applied = file_exists($appliedFile) 
    ? array_filter(explode("\n", file_get_contents($appliedFile)))
    : [];

// Находим новые миграции
$migrations = glob($migrationsDir . '*.sql');
sort($migrations);

foreach ($migrations as $file) {
    $name = basename($file);
    if (in_array($name, $applied)) {
        continue;
    }
    echo "Applying: $name\n";
    $sql = file_get_contents($file);

    try {
        $connection->executeSqlBatch($sql);
    } catch (\Throwable $e) {
        // Останавливаемся на первой ошибке: продолжать применять миграции
        // поверх неприменившейся — верный способ получить рассинхрон схемы
        fwrite(STDERR, "FAILED: {$name}: {$e->getMessage()}\n");
        exit(1);
    }

    file_put_contents($appliedFile, $name . "\n", FILE_APPEND);
    echo "Done: $name\n";
}

echo "All migrations applied.\n";
```

**Две мины в этом скрипте, которые я перенёс из боевого опыта в комментарии выше.**

Первая — журнал применённых миграций лежал внутри директории сайта. Следующий деплой с `rsync --delete` его удалял (в репозитории такого файла нет), и на очередном запуске **все миграции применялись заново**. На `ALTER TABLE` это заканчивается ошибкой, на `INSERT` — дублями данных. Журнал должен жить вне того, что синхронизируется.

Вторая — отсутствие остановки на ошибке. Без `exit(1)` пайплайн зеленеет, хотя схема базы осталась на середине пути, и узнаёте вы об этом от пользователей.

Третья, которую я оставляю как есть, но о которой стоит помнить: `.sql`-файлов недостаточно. Инфоблоки, свойства, настройки модулей и права в Битриксе создаются через API, а не запросами, поэтому реальные миграции — это PHP-классы с `up()`/`down()`. Готовые решения (`sprint.migration`, `arrilot/bitrix-migrations`) закрывают вопрос и умеют откатываться.

### Откат на предыдущую версию

```yaml
# .github/workflows/rollback.yml
name: Rollback
on:
  workflow_dispatch:
    inputs:
      commit:
        description: 'Commit SHA to rollback to'
        required: true
jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.commit }}
      # ... остальные шаги деплоя
```

### Кэширование composer

```yaml
- name: Get Composer cache directory
  id: composer-cache
  run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
  uses: actions/cache@v4
  with:
    path: |
      ${{ steps.composer-cache.outputs.dir }}
      vendor
    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
    restore-keys: |
      ${{ runner.os }}-composer-
```

## Итоги

Получившийся пайплайн закрывает базовый набор: линтер на pull request, автоматический деплой из `main`, отдельный контур для feature-веток, уведомления и защита веток.

Что важнее конкретных YAML-файлов — несколько принципов, которые определяют, будет ли этим пайплайном кто-то пользоваться.

**Деплой должен быть скучным.** Если выкладка занимает десять минут и иногда падает, команда начнёт выкладывать реже и большими кусками — то есть ровно так, как рисковее всего. Быстрый и предсказуемый деплой снижает размер изменения, а размер изменения — главный фактор риска.

**Откат должен быть быстрее, чем разбор причины.** В момент инцидента нужно вернуть рабочую версию, а не понять, что сломалось. Схема с `releases/` и симлинком даёт откат за секунду; `workflow_dispatch` с чужим SHA — за время полного деплоя, что уже хуже, но всё равно лучше ручного `git checkout` на боевом сервере.

**Конфигурация и данные не ездят с кодом.** `dbconn.php`, `.settings.php`, `upload/` и журнал миграций живут на сервере. Любой файл, который создаётся на боевом окружении и не лежит в репозитории, — кандидат на удаление ближайшим `rsync --delete`.

**Секреты в GitHub Actions — это доступ на боевой сервер.** SSH-ключ для деплоя стоит выпускать отдельным, ограничивать по командам через `authorized_keys` (`command=`, `no-port-forwarding`) и не переиспользовать для чего-то ещё. Сторонние actions пинуйте по SHA: они выполняются рядом с этими секретами.

**Дальнейшие шаги** в порядке отдачи: PHPStan (находит реальные ошибки, а не стиль), автотесты хотя бы на критичные сценарии оформления заказа, атомарный деплой через симлинк, отдельная миграционная система вместо самописных `.sql`.