При использовании интеграционной ветки (GitFlow или схема с постоянным открытым PR dev → main) стандартный триггер в GitHub Actions приводит к скрытому оверхеду: каждый пуш в dev запускает не один пайплайн, а два идентичных прогона одновременно. В итоге удваивается время сборки контейнеров, повторно прогоняются тесты и в два раза быстрее выжигается лимит бесплатных минут раннеров. Проблема кроется в механике обработки событий push и pull_request.synchronize.
Анатомия проблемы: почему GitHub запускает два воркфлоу
Типовой блок триггеров в .github/workflows/ci.yml часто копируют из документации без учета постоянных релизных PR:
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
Замысел триггера понятен: гонять тесты на прямых коммитах в main и dev, а также валидировать входящие Pull Request. Но если в репозитории постоянно открыт релизный PR из dev в main, команда git push origin dev инициирует два независимых события:
- Событие
push: GitHub фиксирует обновление веткиdevи запускает воркфлоу по первому правилу. - Событие
pull_request(действиеsynchronize): GitHub проверяет открытые PR, видит обновление ветки-источника (dev) для PR вmainи по второму правилу запускает второй параллельный воркфлоу на тот же самый коммит.
Почему стандартный concurrency не отменяет дубли
Попытка изолировать параллельные запуски через базовый блок concurrency не дает результата:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Причина — в формировании контекстной переменной github.ref для разных типов событий:
- Для события
pushзначениеgithub.refравноrefs/heads/dev. - Для события
pull_requestзначениеgithub.refравноrefs/pull/<pr_number>/merge.
Планировщик GitHub Actions считает ci-refs/heads/dev и ci-refs/pull/105/merge двумя разными группами параллелизма. Они не отменяют друг друга, и оба воркфлоу выполняются параллельно на раннерах.
Решение: фильтрация на уровне джоб и нормализация группы
Для полного устранения дублирования вносятся две точечные правки в конфигурацию воркфлоу.
1. Условие пропуска повторных проверок (skip redundant PR checks)
На уровне каждой джобы (линтеры, анализ типов, автотесты) задается проверка источника PR:
jobs:
quality:
name: Code Quality
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.head_ref != 'dev'
steps:
- uses: actions/checkout@v4
# ...
tests:
name: Tests & Coverage
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.head_ref != 'dev'
steps:
- uses: actions/checkout@v4
# ...
Логика работы условия:
- При событии
pushпервая часть условия (github.event_name != 'pull_request') возвращаетtrue— джоба штатно запускается. - При событии
pull_requestпроверяется вторая часть:github.head_ref != 'dev'. - Если это PR из
devвmain, условие возвращаетfalse, и джоба пропускается (так как эти коммиты уже проверяются в соседнем событииpush). - Если открыт обычный фиче-ветковый PR (например,
feature/billing → dev), условие возвращаетtrue, и тесты выполняются в полном объеме.
2. Унификация группы concurrency по имени ветки
Вместо разнородного github.ref группа привязывается к фактическому имени ветки через оператор ||:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
Механика подстановки:
- В контексте
pull_requestпеременнаяgithub.head_refсодержит имя ветки-источника (feature/billingилиdev). - В контексте
pushпеременнаяgithub.head_refпуста, и выражение откатывается наgithub.ref_name(краткое имя ветки, напримерdev).
В результате все события вокруг одной ветки получают одинаковый строковый ключ. А параметр cancel-in-progress: true автоматически отменяет предыдущий незавершенный прогон при быстром повторном пуше, не занимая очередь сборки.
Итоговый шаблон ci.yml
Рабочая конфигурация пайплайна с защитой от дублирующих прогонов:
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
tests:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.head_ref != 'dev'
strategy:
matrix:
php: ['8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- run: composer install --prefer-dist --no-interaction
- run: php artisan test
Результат
- Полное устранение задвоенных воркфлоу в списке Actions.
- Двукратная экономия доступных минут на раннерах.
- Автоматическая отмена устаревших запусков при серии последовательных пушей.