Загрузка...

Баг в пайплайне: почему постоянный PR dev → main удваивает запуски GitHub Actions и как это исправить

Баг в пайплайне: почему постоянный PR dev → main удваивает запуски GitHub Actions и как это исправить

Недавно я разбирался с тем, куда так стремительно улетают бесплатные минуты в 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:

  1. GitHub фиксирует прямой пуш в ветку dev и радостно запускает первый воркфлоу по событию push.
  2. В ту же секунду GitHub видит: ветка dev обновилась, а значит обновился источник для открытого PR #105, чья целевая ветка — main! Для GitHub это событие pull_request (тип действия synchronize).
  3. Поскольку в секции 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 — скорее всего, вы прямо сейчас сжигаете половину лимита впустую.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *