Джобы, матрица и переиспользование: DAG, лимиты fan-out и вложенность reusable
Джобы образуют DAG через `needs`; матрица разворачивает один джоб во множество ног с `max-parallel` и `fail-fast`, управляющими ценой. Reusable workflows (`workflow_call`) факторизуют пайплайны в вызываемые единицы — лимит 4 уровня вложенности и 20 уникальных вызываемых файлов.
Счёт за CI утроился за ночь, и никто не мержил фичу. Причина — одна благонамеренная матрица. У платформенной команды был тест-джоб, покрывавший 4 ОС и 5 версий языка — аккуратные 20 ног — и кто-то добавил третье измерение database: [postgres, mysql, sqlite, mssql], чтобы «быть тщательным». Это декартово произведение: 4 × 5 × 4 = 80 джобов, каждый поднимает свежий раннер, каждый тянет те же зависимости. Хуже, fail-fast был выключен из-за флакости, поэтому одна падающая нога больше не замыкала остальные 79 — каждый запуск платил за все 80 до конца. Фиксом было не удалить покрытие; фиксом было понять, что матрица умножает, что include/exclude дают покрыть значимые комбинации без полного произведения, и что max-parallel ограничивает одновременное горение. Они срезали 80 ног до выверенных 14 через include, поставили max-parallel: 6, и счёт вернулся вниз — с лучше выбранным покрытием, чем давала слепая сетка.
Джобы — это DAG, а не скрипт
Большинство проблем со стоимостью и задержкой CI можно отследить до того, что граф джобов не был спроектирован осознанно — либо всё выполняется последовательно, когда в этом не было необходимости, либо всё параллельно, когда нужен был порядок. Понять DAG — значит понять, где именно чинить и то, и другое.
Джобы по умолчанию выполняются параллельно; needs: рисует рёбра зависимостей, сериализующие их в DAG (directed acyclic graph — направленный ациклический граф):
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact: ${{ steps.pack.outputs.name }}
steps: [ ... ]
test:
needs: build # ждёт build, читает его outputs
runs-on: ubuntu-latest
deploy:
needs: [build, test] # ждёт оба
if: ${{ github.ref == 'refs/heads/main' }}
timeout-minutes: 15 # убей зависший деплой, не жги 6чКаждый джоб выполняется на свежем раннере без общей файловой системы — потому данные между джобами идут через outputs (короткие строки) или артефакты (файлы), никогда через диск. Джоб не наследует ничего из чужого рабочего каталога. Два продакшн-контроля живут на этом уровне: timeout-minutes (дефолт — щедрые 360 минут / 6 часов на джоб — слишком долго для большинства работы, поэтому задай реальный потолок, иначе зависший шаг сожжёт часы времени раннера) и условия if:, закрывающие целые джобы. needs также пробрасывает сбой: если build падает, test и deploy пропускаются — если только нижестоящий джоб не подпишется выполниться всё равно через if: ${{ always() }} или if: ${{ !cancelled() }}.
Матрица: одно определение джоба, много ног
strategy.matrix разворачивает один джоб в одну ногу на комбинацию — декартово произведение каждого измерения:
strategy:
fail-fast: false # не отменяй соседей при падении одной ноги
max-parallel: 6 # максимум 6 ног выполняются одновременно
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
include:
- { os: ubuntu-latest, node: 22, coverage: true } # добавить/дополнить ногу
exclude:
- { os: macos-latest, node: 18 } # выкинуть комбинациюЧисла, управляющие ценой и поведением:
- Оно умножает.
3 os × 3 node = 9ног; добавление любого третьего измерения умножает снова. Это взрыв из Hook — матрица — самое лёгкое место в Actions, чтобы случайно увеличить счёт в 10 раз. fail-fast(дефолтtrue) отменяет все выполняющиеся и стоящие в очереди ноги в момент падения одной — быстрая обратная связь, но теряешь полную картину сбоя. Поставьfalse, чтобы видеть каждую падающую комбинацию, ценой оплаты их всех.max-parallelограничивает, сколько ног выполняются разом; без него ноги выполняются настолько параллельно, насколько позволяет лимит concurrency твоих раннеров (а матрица ограничена 256 ногами на запуск workflow).include/exclude— инструменты точности:excludeубирает сгенерированную комбинацию;includeдобавляет разовую ногу или дополняет существующую лишними ключами — способ получить выверенное покрытие вместо слепой сетки.
Вместе эти четыре рычага значат, что от взорвавшейся матрицы всегда можно оправиться: ограничь горение через max-parallel, обрежь произведение через exclude/include, реши, нужна ли быстрая обратная связь или полная картина, через fail-fast. Без max-parallel на матрице, о которой не подумал, одно новое измерение за ночь превращает управляемые 9 ног в пожар из 80 раннеров.
У матрицы os: [ubuntu, windows, macos] и node: [18, 20, 22] с fail-fast: false и без max-parallel. Нога node-18 на windows падает быстро. Сколько ног всего доходят до конца и почему?
▸Почему это работает
Почему матрица умножает, а не зипует измерения попарно? Потому что обычная потребность — настоящее покрытие: каждая ОС против каждого рантайма — и произведение выражает это прямо. Попарная семантика «зип» тихо пропускала бы комбинации и прятала баги совместимости, проявляющиеся лишь на конкретном пересечении. Размен в том, что произведение растёт мультипликативно, поэтому язык даёт exclude, чтобы вырезать ненужные комбинации, и include, чтобы вернуть немногие нужные дополнительные — позволяя начать с полного покрытия и обрезать, или начать разреженно и дополнить. Дисциплина сеньора — трактовать каждое новое измерение как множитель счёта, а не как бесплатную ось.
Reusable workflows: факторизуй пайплайн
Когда та же последовательность build-test-scan появляется в двадцати репозиториях, copy-paste гниёт. Reusable workflow — это workflow с on: workflow_call, который другие workflow вызывают как один джоб:
# .github/workflows/build.yml (reusable workflow)
on:
workflow_call:
inputs:
node: { type: string, required: true }
secrets:
token: { required: true }
# вызывающий
jobs:
ci:
uses: my-org/ci/.github/workflows/build.yml@v2 # пин к тегу/SHA
with: { node: "20" }
secrets: { token: ${{ secrets.NPM_TOKEN }} }Жёсткие лимиты, которые стоит запомнить: цепочка вызовов вложена до 4 уровней вглубь (reusable workflow вызывает другой, и так далее — workflow 4-го уровня сам уже не может вызвать reusable workflow), и один файл workflow может ссылаться максимум на 20 уникальных reusable workflows по всему дереву. Пинь ссылку uses: к тегу или SHA, никогда к движущейся ветке, по той же supply-chain-причине, по которой пинишь экшены — @main позволяет вызываемому workflow меняться под тобой. Секреты не наследуются автоматически; ты либо передаёшь их явно под secrets:, либо форвардишь все через secrets: inherit. Так крупные организации держат одно проаудированное определение пайплайна и вызывают его везде, вместо двадцати расходящихся копий.
Организация факторизует пайплайн в reusable workflows: верхний вызывающий uses 'ci.yml', который uses 'build.yml', который uses 'scan.yml', который uses 'sign.yml'. Кто-то добавляет пятый уровень, где sign.yml вызывает 'notify.yml'. Что происходит?
- 01Объясни, как матрица вычисляет свои ноги, и три рычага, управляющих её ценой и обратной связью.
- 02Что такое reusable workflow и какие лимиты и supply-chain-правила к нему применяются?
Джобы — единица параллелизма: они выполняются одновременно, пока рёбра needs: не сериализуют их в DAG, каждый на свежем раннере без общего диска, поэтому данные пересекают границы джобов только через outputs (короткие строки) или артефакты (файлы). Два контроля живут здесь, и оба важны на сеньорском масштабе: timeout-minutes по умолчанию — расточительные шесть часов, поэтому задай реальный потолок, иначе зависший шаг сожжёт часы раннера, а if: закрывает целые джобы, тогда как needs пробрасывает сбой вниз, если джоб не подпишется обратно через always() или !cancelled(). strategy.matrix разворачивает один джоб в декартово произведение измерений — самый лёгкий способ случайно умножить счёт за CI, ведь третья ось превращает девять ног в восемьдесят — и ты управляешь этим через fail-fast (отмена соседей при первом сбое ради скорости или false, чтобы видеть каждый сбой и платить за все), max-parallel (потолок параллельности, с 256 ногами как жёстким максимумом) и include/exclude для выверенного, а не слепого покрытия. Reusable workflows, объявленные через on: workflow_call, факторизуют пайплайн в вызываемые, версионно-зафиксированные единицы, чтобы одно проаудированное определение служило многим репозиториям; они вложены максимум на четыре уровня, файл может ссылаться максимум на двадцать уникальных вызываемых workflow, ссылка uses: должна быть зафиксирована к тегу или SHA по тем же supply-chain-причинам, по которым пинишь экшены, а секреты передаются явно или через secrets: inherit — никогда тихо. Сквозная линия: джобы образуют граф, матрица его умножает, а переиспользование его факторизует — каждое рычаг и на корректность, и на стоимость. Теперь, когда встретишь счёт за CI, утроившийся за ночь, или reusable workflow, сломавшийся после того как кто-то поднял зависимость, — сразу знаешь, какой рычаг проверить первым: измерение матрицы, прокравшееся незаметно, отсутствующий max-parallel или пин @main, ушедший в дрейф.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.