Composite-экшены: упаковка шагов без накладных расходов рантайма
Composite-экшен упаковывает последовательность шагов в один action.yml без отдельного рантайма — он выполняется inline на раннере за миллисекунды, но наследует shell раннера и делит файловую систему вместо изоляции зависимостей.
Один и тот же блок из одиннадцати шагов настройки — checkout, установка тулчейна, восстановление кэша, аутентификация в реестре, печать баннера версии — был скопирован в девятнадцать воркфлоу монорепы. Когда тулчейн поднял мажорную версию, платформенная команда правила его в девятнадцати файлах; три пропустили, и они неделю выкатывали сломанные ночные сборки. Решением был не JavaScript-экшен и не Docker-образ: это был composite-экшен — те одиннадцать шагов дословно подняли в единый action.yml, опубликовали как org/setup-toolchain@v2 и вызывали одной строкой uses:. Ни нового рантайма, ни шага бандлинга, ни pull контейнера. Первый прогон после миграции оказался быстрее inline-версии, которую он заменил, ведь единственное, что composite добавил, — слой косвенности, который раннер разрешает до выполнения любого шага. Хрупкость никогда не была в шагах — она была в наличии девятнадцати их копий.
К концу урока ты будешь точно знать, когда composite-экшен (действие GitHub Actions, упаковывающее шаги в один reusable-блок) — правильный инструмент, какие два правила авторинга ловят опытных инженеров врасплох и где его дизайн без изоляции превращается в проблему.
Что такое composite-экшен на самом деле
Зачем вообще использовать composite, а не reusable workflow или скрипт? Потому что composite — единственный тип, не добавляющий никаких накладных рантайма: когда одиннадцать скопированных шагов становятся бременем поддержки, composite схлопывает их в одну строку uses: без контейнера и без Node-процесса. Вот как это работает изнутри.
Composite-экшен — это метаданные, а не программа. Его action.yml объявляет runs.using: "composite" и список steps, а в момент вызова GitHub Actions вклеивает эти шаги в вызывающую джобу так, будто вы написали их inline — они выполняются на том же раннере, в том же рабочем каталоге, под тем же $GITHUB_WORKSPACE. Нет процесса Node.js для запуска и нет образа контейнера для pull, так что накладные расходы быть экшеном — по сути парсинг YAML: однозначные миллисекунды против джобы, которая уже стоит секунды.
# .github/actions/setup-toolchain/action.yml
name: "Setup Toolchain"
description: "Install the toolchain, restore cache, authenticate"
inputs:
version:
description: "Toolchain version"
required: false
default: "20"
outputs:
cache-hit:
description: "Whether the dependency cache was restored"
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.version }}
- id: cache
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ hashFiles('package-lock.json') }}
- name: Install
shell: bash
run: npm ciДва правила кусают здесь старших авторов. Первое: каждый шаг run: внутри composite обязан объявить shell: — нет дефолта уровня джобы для наследования, так что голый блок run: проваливает валидацию. Второе: inputs — это не переменные окружения: внутри composite вы читаете их как ${{ inputs.version }}, а не $VERSION, и чтобы поднять значение к вызывающему, прокидываете его через outputs[].value, ссылающийся на output шага, как показывает проводка cache-hit выше.
В action.yml composite-экшена есть шаг с `run: npm ci` и без других ключей. Воркфлоу падает при загрузке экшена с ошибкой валидации. Чего не хватает?
Компромисс: скорость, купленная общим состоянием
То, что composite даёт бесплатно, — это же и то, что может ранить: отсутствие изоляции. Он выполняется в окружении вызывающего, видит его файлы и мутирует его файловую систему. Если ваш composite запускает npm install -g some-cli, этот бинарь теперь в PATH для каждого последующего шага джобы — удобно, пока два composite не поставят конфликтующие глобальные версии. Нет песочницы node_modules, нет вендоринга зависимостей; composite, зависящий от jq, предполагает, что jq уже есть на раннере. Это нормально для оркестрации — выстраивания инструментов, что у раннера уже есть, — и неверно для логики, нуждающейся в своих запиненных зависимостях. Этот последний случай — ровно то, для чего существуют JavaScript- и Docker-экшены.
Прежде чем ставить глобальный CLI внутри composite, спроси себя: ожидает ли следующий composite в этой джобе чистый PATH — или ему всё равно, что твой бинарь уже там? Этот вопрос вскрывает настоящую опасность до того, как она выстрелит в продакшне.
Ещё два острых края. Composite могут вкладываться и вызывать другие экшены, но обработка ошибок грубая: упавший шаг прерывает composite так же, как упавший шаг джобы прерывает джобу, и до 1.0 нельзя было даже использовать continue-on-error пошагово (теперь поддерживается, но условия if: на шагах composite всё ещё вычисляются иначе, чем на шагах воркфлоу — они не видят состояние success()/failure() уровня джобы так же). И пиннинг безопасности применяется рекурсивно: когда ваш composite делает uses: actions/cache@v4, этот тег изменяем. Для всего, что касается креденшелов, пиньте вложенные экшены полным commit SHA — uses: actions/cache@<40-символьный-sha> — чтобы скомпрометированный тег в зависимости не мог внедрить код в каждый воркфлоу, что вызывает ваш composite.
Команде нужен переиспользуемый экшен, выполняющий нетривиальную логику со своими запиненными сторонними зависимостями, изолированными от того, что окажется на раннере. Почему composite-экшен плохо подходит?
- 01Какой рантайм использует composite-экшен и какие накладные расходы он добавляет к джобе по сравнению с JavaScript- или Docker-экшеном?
- 02Назови три вещи, что специфично кусают авторов composite-экшенов, помимо того, что требует обычный шаг воркфлоу.
Composite-экшен — легчайший способ сделать шаги GitHub Actions переиспользуемыми: его action.yml ставит runs.using: "composite" и перечисляет steps, а в момент вызова эти шаги вклеиваются в вызывающую джобу и выполняются inline на том же раннере. Отдельного рантайма нет — ни процесса Node, ни контейнера — так что накладные быть экшеном это парсинг YAML, однозначные миллисекунды против джобы, измеряемой секундами; миграция со скопированных inline-шагов на composite обычно даёт чистый выигрыш в скорости плюс устранение N разошедшихся копий. Цена — отсутствие изоляции: экшен делит shell, PATH и $GITHUB_WORKSPACE раннера, так что глобальные установки протекают в последующие шаги, а экшен полагает, что его инструменты уже есть на раннере, а не вендорит своё дерево зависимостей. Это делает composite идеальным для оркестрации имеющихся у раннера инструментов и неверным для самодостаточной логики с запиненными сторонними библиотеками — работы для JavaScript- и Docker-экшенов. Грабли авторинга, ловящие старших: каждому шагу run: нужен явный shell:, ведь нет унаследованного дефолта; inputs читаются как ${{ inputs.name }}, а не env-переменные, а outputs поднимаются через outputs[].value, привязанный к output шага; вложенные ссылки uses: — изменяемые теги, что стоит пинить по полному 40-символьному commit SHA всякий раз, когда креденшелы где-то рядом с воркфлоу, чтобы скомпрометированный тег зависимости не мог внедрить код в каждого вызывающего. Теперь, когда встретишь скопированные блоки настройки в нескольких воркфлоу, ты знаешь: тяни к composite — и пиннинг по SHA ставь ещё до того, как первый потребитель прогонит через него креденшелы.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.