Недавно я разбирался с тем, куда так стремительно улетают бесплатные минуты в GitHub Actions. Открываешь вкладку Actions в репозитории после обычного рабочего дня — а там сплошной дубляж: на каждый пуш коммита в ветку dev запускается не один пайплайн с тестами, а сразу два абсолютно одинаковых воркфлоу параллельно.
Два одинаковых прогона тестов Pest/PHPUnit, двойная сборка ассетов Vite, двойная нагрузка на раннеры. Если вы коммитите часто или работаете в связке с AI-агентом, лимит минут сгорает ровно в два раза быстрее, а билды выстраиваются в очередь. Самое неприятное, что этот баг сидел во многих моих деплой-сценариях годами, потому что сам по себе синтаксис конфига кажется абсолютно каноничным и написан строго по официальным гайдам.
Анатомия проблемы: откуда берется дубль
Классический блок триггеров в файле .github/workflows/ci.yml кочует из проекта в проект и обычно выглядит так:
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
Логика задумывалась прозрачной: мы хотим проверять коммиты при пуше в основные ветки (main и dev), а также прогонять тесты для любых входящих Pull Request в эти ветки.
Но почти во всех проектах, где используется модель с интеграционной веткой (GitFlow или приближенная к нему), существует постоянно открытый Pull Request — например, PR #105: dev → main, который висит неделями, пока накапливаются фичи до ближайшего релиза.
И вот что происходит в момент выполнения git push origin dev:
- GitHub фиксирует прямой пуш в ветку
devи радостно запускает первый воркфлоу по событиюpush. - В ту же секунду GitHub видит: ветка
devобновилась, а значит обновился источник для открытогоPR #105, чья целевая ветка —main! Для GitHub это событиеpull_request(тип действияsynchronize). - Поскольку в секции
pull_request.branchesуказана веткаmain, GitHub запускает второй независимый воркфлоу на тот же самый коммит.
Почему стандартный concurrency не помогает
Казалось бы, для борьбы с параллельными запусками в GitHub Actions есть стандартный механизм concurrency. Обычно его настраивают так, как советует документация:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Но здесь кроется неочевидная подлянка. Давайте посмотрим, как GitHub заполняет переменную github.ref для этих двух параллельных событий:
- Для события
push:github.refравенrefs/heads/dev. - Для события
pull_request:github.refравенrefs/pull/105/merge.
Для планировщика Actions CI-refs/heads/dev и CI-refs/pull/105/merge — это две совершенно разные группы параллелизма. Они не блокируют и не отменяют друг друга. В итоге оба воркфлоу успешно стартуют бок о бок, сжигая минуты и забивая очередь.
Как это вылечить: два точных шага
Чтобы раз и навсегда устранить паразитные дубли, нужно решить две задачи: научить джобы не запускаться повторно для PR из ветки dev, и объединить группу concurrency по реальному имени ветки.
Шаг 1. Условие на уровне джоб (skip redundant PR checks)
Для основных джоб пайплайна (линтеры, анализ кода, автотесты) добавляем условие в блоке if:
jobs:
quality:
name: Code Quality
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.head_ref != 'dev'
steps:
# ...
tests:
name: Tests & Coverage
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.head_ref != 'dev'
steps:
# ...
Как работает это логическое выражение:
- Если событие — это
push(в любую ветку) — первая частьgithub.event_name != 'pull_request'возвращаетtrue, и джоба честно запускается. - Если событие — это
pull_request, первая часть даетfalse, и проверяется вторая:github.head_ref != 'dev'. - Если PR открыт из ветки
devвmain— условие даетfalse, и джоба пропускается (ведь ровно те же самые коммиты уже прямо сейчас тестируются событиемpush). - Если же разработчик открыл обычный фиче-PR (например,
feature/loginвdevилиmain) — условие вернетtrue, и PR будет протестирован со всеми проверками.
Шаг 2. Унификация группы concurrency
Вместо разнородного github.ref привяжем группу параллелизма к реальному имени ветки:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
В чем магия конструкции ${{ github.head_ref || github.ref_name }}:
- В контексте
pull_requestпеременнаяgithub.head_refсодержит настоящее имя ветки-источника (например,feature/auth). - В контексте
pushпеременнаяgithub.head_refпустая (null), поэтому срабатывает оператор||и беретсяgithub.ref_name(которая для пуша равна простому имени ветки, напримерdevилиmain).
Теперь все события, происходящие вокруг одной и той же ветки, имеют одинаковый ключ группы. А благодаря флагу 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.
- Расход бесплатных минут сокращается ровно в два раза.
- Быстрые повторные пуши автоматически отменяют устаревшие билды, экономя время ожидания.
Если у вас в проектах принята модель с долгоживущей веткой dev и постоянным PR в main, проверьте свои .github/workflows — скорее всего, вы прямо сейчас сжигаете половину лимита впустую.