Как устроен GitHub Actions: workflow, job, step и артефакты без путаницы
Разберём архитектуру workflow и типовые сценарии: разделение по jobs, корректный кэш, сбор артефактов, повторное использование шагов и устойчивость к падениям. Будет много практических примеров для реальных проектов.
Содержание
Как устроен GitHub Actions: workflow, job, step и артефакты без путаницы
GitHub Actions часто воспринимают как “набор кнопок для CI/CD”. На практике же это достаточно строгая модель исполнения, где маленькая ошибка в структуре (или в предположениях о порядке выполнения) приводит к трудноуловимым проблемам: кэши не срабатывают, артефакты “пропадают”, тесты выполняются не там, где вы думали, а падения маскируются.
Ниже разберём, как устроены ключевые сущности workflow, job, step и artifacts, как правильно структурировать пайплайн под реальные проекты и как сделать его устойчивым к типичным сбоям. Примеры будут максимально приближены к практике: Node/пакеты, тесты, сборки, кэш зависимостей и перенос результатов между стадиями.
Базовая модель исполнения: workflow → job → step
Что такое workflow
Workflow — это конфигурация процесса в файле .github/workflows/<name>.yml. В ней описываются:
- когда запускать пайплайн (
on) - какие окружения/версии использовать
- наборы задач (
jobs)
Один workflow может запускаться по нескольким триггерам и содержать несколько независимых jobs.
Ключевой момент: workflow — это шаблон, который GitHub исполняет. Он не является “пошаговым скриптом сверху вниз”. Внутри него есть DAG-подобная логика (зависимости между jobs через needs), а внутри job — последовательность шагов.
Что такое job
Job — это единица вычисления с собственным:
- окружением (runner)
- набором шагов (
steps) - контекстом переменных и секретов
- статусом (успешно/ошибка/пропуск)
Jobs могут выполняться параллельно (если нет needs) и не разделяют файловую систему между собой. То есть то, что вы собрали в одном job, напрямую в другом job не окажется — нужны механизмы передачи: artifacts (или более продвинутые варианты вроде caches или external storage).
Что такое step
Step — атомарная операция внутри job: запуск команды, вызов действия (action), установка зависимостей, публикация отчёта и т.д.
Важно: внутри одного job шаги выполняются последовательно (за исключением специализированных конструкций), поэтому порядок steps критичен.
Пример минимальной структуры:
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install
run: npm ci
- name: Test
run: npm test
Здесь:
- workflow — “CI”
- job — “build”
- steps — checkout, install, test
Типовые архитектурные паттерны workflow
Разделяйте по смысловым этапам, а не “всё в один job”
Одна из самых частых ошибок — класть сборку, тесты, упаковку и публикацию в один job. В результате:
- нельзя гибко управлять зависимостями (
needs) - параллелизм отсутствует
- сложнее ускорять пайплайн кэшами и артефактами
- при частичных сбоях теряется весь результат
Правильнее разделять на job по смыслу:
lint(статический анализ)test(юнит/интеграционные тесты)build(сборка артефактов)package/publish(публикация)
Например:
jobs:
lint:
runs-on: ubuntu-latest
steps: ...
test:
runs-on: ubuntu-latest
needs: lint
steps: ...
build:
runs-on: ubuntu-latest
needs: test
steps: ...
Даже если сейчас всё запускается последовательно, разбиение сохраняет структуру и даёт “пространство для оптимизации” без переписывания всего workflow.
Используйте needs для явных зависимостей
needs — это не просто “какой job после какого”. Это механизм управления выполнением: job с needs не стартует, пока нужный job не завершится.
Практический нюанс: needs также помогает передавать статус — вы можете понять, в каком месте пайплайн “сломался”, а не гадать по логам одного большого job.
Кэш зависимостей: что кэшировать и как не ошибиться
Почему кэш может “не работать”
Кэш в GitHub Actions — не магия. Он возвращает и сохраняет директории по ключу. Если ключ не совпал — кэш считается отсутствующим. Поэтому кэш часто “не срабатывает” из-за:
- Неправильно выбранной директории (кэшируется не то, что реально ускоряет)
- Нестабильного ключа (например, зависит от “воды” вроде времени, или от файлов, которые меняются каждую правку)
- Отсутствия fallback restore-ключей (или они подобраны неверно)
- Разных ОС/раннеров для ключа и восстановления
- Для языков/менеджеров пакетов — кэш “не тем способом”: npm/yarn/pnpm имеют собственные директории кэша
Практический пример: pnpm (подходит многим monorepo)
Допустим, вы используете pnpm. Типично стоит кэшировать ~/.pnpm-store и lock-файл для стабильности ключа.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm' # полезная встроенная опция (если поддерживается в вашем случае)
# Если встроенный cache не подходит — можно явно через actions/cache:
# - name: Cache pnpm store
# uses: actions/cache@v4
# with:
# path: ~/.pnpm-store
# key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
# restore-keys: |
# ${{ runner.os }}-pnpm-
- name: Install
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
Замечание: actions/setup-node@v4 умеет кэшировать зависимости для распространённых менеджеров, но его поведение зависит от контекста проекта. Если ваш репозиторий нестандартный (например, отдельные workspace-структуры), явный кэш может быть надёжнее.
Как выбирать ключ кэша
Лучший ключ — тот, который обновляется при изменении “влияющих” факторов. Для зависимостей это обычно lock-файл:
- npm:
package-lock.json - yarn:
yarn.lock - pnpm:
pnpm-lock.yaml
Пример ключа:
key: ${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
Почему важно учитывать runner.os:
- различия окружений могут приводить к несовместимости кэшей
- вы снижаете шанс странных ошибок при переходе между linux/windows/mac
Что НЕ кэшировать
Не стоит пытаться кэшировать:
node_modulesцеликом (для разных версий node/архитектур это часто ломается)- результаты сборки, которые зависят от множества параметров (если только вы не выстроили единый детерминизм)
- временные директории, которые живут только в текущем job
Артефакты: как переносить результаты между jobs
Что такое artifacts
Артефакты — механизм хранения файлов, созданных на одном job, чтобы скачать их в другом job или потом посмотреть через интерфейс workflow.
Практически artifacts решают проблему “разделённых файловых систем” между jobs.
Типовой сценарий: build → test/report → deploy
- job
buildсобирает приложение и публикует артефакт (например,dist/) - job
testзапускает тесты, но может использовать build-результат (если нужно) - job
deployскачивает артефакт и разворачивает
Пример:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: app-dist
path: dist/
if-no-files-found: error
retention-days: 7
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-dist
path: ./dist
- run: ls -la dist
- name: Deploy
run: ./scripts/deploy.sh
Обратите внимание на if-no-files-found: error. Если dist/ не создался, пайплайн должен упасть с понятной причиной, а не “успешно завершиться” и потом сломаться в другом месте.
Нюансы передачи артефактов
- Не смешивайте имена артефактов: при нескольких build-матрицах следите за уникальностью
name. - Стабильный
path: относительные пути чувствительны кworking-directory. Для монорепозиториев лучше явно указыватьpath. - Подумайте о “размере”: артефакты могут быть большими. Старайтесь публиковать то, что действительно нужно следующему этапу.
Повторное использование шагов: composite actions и локальная логика
Почему reusable workflows и composite actions полезны
Когда pipeline разрастается, вы сталкиваетесь с двумя проблемами:
- копипаст одних и тех же шагов в разные workflow-файлы
- необходимость поддерживать логику инсталляции/сборки в нескольких местах
GitHub предлагает два популярных подхода:
- Composite actions — набор шагов как “мини-действие”.
- Reusable workflows — переиспользование целого workflow с параметрами.
В контексте “повторного использования шагов” чаще удобнее composite action.
Пример composite action: установка и тестирование (локальный)
Допустим, вы хотите вынести шаги setup-node, npm ci, запуск тестов.
Файл /.github/actions/node-test/action.yml:
name: Node install and test
description: Install dependencies and run tests
inputs:
node-version:
required: true
working-directory:
required: false
default: .
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Install
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Test
working-directory: ${{ inputs.working-directory }}
run: npm test
Теперь в workflow:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
uses: ./.github/actions/node-test
with:
node-version: 20
working-directory: .
Так вы уменьшаете шум в workflow и концентрируете логику в одном месте.
Подводные камни composite actions
- Они не могут полностью заменить “настоящий” action по возможностям, но для шагов — идеальны.
- Следите за версионированием: если composite action хранится в репозитории, при переносе может быть важно фиксировать ссылку по ref (для внешнего использования).
Устойчивость к падениям: continue-on-error, условия и ранний выход
Разделяйте “критические” и “некритические” проверки
Часто в реальных проектах:
- линтер — важен, но иногда его можно настроить так, чтобы он давал отчёт, не блокируя весь пайплайн (зависит от политики команды)
- отправка статуса в сторонние сервисы — может быть некритична
- загрузка артефактов — лучше делать так, чтобы она не “скрывала” проблемы, но и не ломала отчётность без причины
continue-on-error позволяет продолжить выполнение шага даже при ошибке.
Например, сохранить junit-отчёт после падения тестов:
- name: Run tests
id: tests
run: npm test -- --reporter=junit || true
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-report
path: test-results/
if-no-files-found: warn
Тут:
- тесты не останавливают job (
|| true) - артефакты пытаемся загрузить всегда (
if: always()) - если отчёта нет — предупреждаем, но не делаем из этого “основную” причину отказа
Важный нюанс: если вам принципиально нужно, чтобы job падал при ошибках тестов, используйте другой механизм: сохраняйте отчёт в любом случае, но возвращайте корректный код. Например, можно запомнить результат и в конце завершить job с ошибкой.
Условия if: always(), if: success(), if: failure()
Это базовые инструменты устойчивости:
if: always()— выполнится независимо от статуса предыдущих шаговif: success()— только при успехеif: failure()— только при провале
Классический пример — загрузка отчётов при падении линтера или тестов.
- name: Lint
run: npm run lint
- name: Upload lint report
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-report
path: reports/lint/
if-no-files-found: warn
“Раннее падение” и экономия времени
Иногда лучший способ устойчивости — не скрывать проблемы, а обнаруживать их раньше:
- делайте линт/форматирование до дорогих шагов
- проверяйте зависимости и версии инструментов перед сборкой
- в матрицах (например, тесты под разные версии node) используйте разумный порядок
Если job очевидно упадёт из-за несовместимости, нет смысла собирать огромный билд.
Комбинации: matrix, parallelism и общий смысл пайплайна
Matrix: тестирование на разных версиях
GitHub Actions поддерживает стратегию матрицы, где каждый элемент создаёт отдельную “вариацию” job.
Пример:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
fail-fast: false полезен, когда вы хотите увидеть все несовместимости, а не остановиться на первой ошибке.
Артефакты в matrix
Если каждое значение матрицы производит отчёт, обязательно именуйте артефакты с включением matrix.node, иначе они будут перетираться или вам будет сложно разобраться.
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.node }}
path: test-results/
if-no-files-found: warn
Пример “почти реального” workflow: lint + test + build + deploy
Ниже — цельный пример, где отражены принципы:
- разбиение на jobs
- кэширование зависимостей
- публикация build-артефакта
- загрузка отчётов при падении
- устойчивость и понятные условия
name: CI/CD
on:
push:
branches: [ main ]
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install
run: npm ci
- name: Lint
run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install
run: npm ci
- name: Run tests
run: npm test -- --reporter=junit
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit
path: test-results/
if-no-files-found: warn
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install
run: npm ci
- name: Build
run: npm run build
- name: Upload dist
uses: actions/upload-artifact@v4
Комментарии
Пока нет комментариев