GitHub Actions: workflows, jobs, steps
Workflow — это YAML-файл в .github/workflows, запускаемый событиями on:. Он содержит jobs, которые по умолчанию идут параллельно на свежих runner-VM; каждый job состоит из steps, где шаг либо использует action, либо запускает shell-команду.
Разработчик открывает pull request и смотрит на вкладку Actions. Тесты прошли. Он мёржит — и job деплоя, который собрал свою копию приложения на другом runner, падает, потому что ожидаемого артефакта никогда не было: job тестов собрал его в своей VM, и эту VM выбросили в тот момент, когда job завершился. Ничего не передалось. Модель в этом беспощадна: каждый job — это чистая машина, которая не знает о других ничего, пока ты сам их не свяжешь. Выучи четыре существительных — workflow, job, step, runner — и большая часть путаницы в CI испаряется.
Четыре существительных: workflow, job, step, runner
Всё в GitHub Actions — это одна из четырёх вещей. Workflow — это один YAML-файл, лежащий в .github/workflows/ твоего репозитория: ci.yml, deploy.yml, по файлу на workflow. Workflow содержит один или несколько jobs. Каждый job выполняется на runner — виртуальной машине (GitHub-hosted ubuntu-latest или твой self-hosted сервер), которая поднимается с нуля под этот job и уничтожается, когда он заканчивается. Внутри job — упорядоченный список steps, и каждый step делает ровно одно из двух: либо uses: опубликованный action, либо run: shell-команду.
Триггер стоит наверху. Ключ on: объявляет, какие события запускают workflow. Четыре, к которым тянешься постоянно: push (коммит прилетел в ветку), pull_request (PR открыт или обновлён), workflow_dispatch (человек нажал «Run workflow» — кнопка ручного запуска) и schedule (cron-выражение, например ночной прогон). Один workflow может слушать сразу несколько событий.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # кнопка ручного запуска
schedule:
- cron: '0 3 * * *' # ночью в 03:00 UTC
jobs:
test:
runs-on: ubuntu-latest # runner: свежая VM
steps:
- uses: actions/checkout@v4 # action: клонировать репозиторий
- uses: actions/setup-node@v4 # action: поставить Node
with:
node-version: 22
cache: npm
- run: npm ci # shell-команда
- run: npm testЭто полноценный, реальный CI-workflow: на каждый push в main и каждый PR подними свежую Ubuntu-VM, выкачай код, поставь Node 22, детерминированно установи зависимости через npm ci и запусти тесты. actions/checkout и actions/setup-node — канонические первые два шага почти любого Node-workflow: runner стартует пустым, поэтому ты обязан явно склонировать собственный репозиторий и поставить тулчейн, прежде чем что-либо ещё сможет выполниться.
Jobs по умолчанию параллельны и изолированы
Это правило удивляет. Перечисли два job в одном workflow — и они выполнятся одновременно, на отдельных runner’ах. GitHub не сериализует их за тебя. Это фича — твои lint, type-check и тесты завершатся за время самого медленного, а не их суммы, — но это значит, что ни один job не видит файловую систему, окружение или вывод другого. Каждый runner — это чистая VM, которая заканчивается в тот момент, когда заканчивается её job.
Чтобы навязать порядок, используй needs:. Job с needs: [test] не стартует, пока test не завершится успешно, превращая плоское множество jobs в направленный ациклический граф (DAG). Так ты выражаешь «lint и тесты параллельно, потом деплой — только если оба прошли».
jobs:
lint:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm run lint }]
test:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm test }]
deploy:
needs: [lint, test] # ждёт оба; запускается только если оба прошли
runs-on: ubuntu-latest
steps: [{ run: ./deploy.sh }]Поскольку каждый job — это собственная VM, ты не можешь просто передать файл из одного job в следующий. Если build создаёт папку dist/, нужную deploy, то job сборки должен выгрузить её через actions/upload-artifact, а job деплоя — скачать через actions/download-artifact. Аналогично, per-runner кэш (actions/cache или сокращение cache: у setup-node) сохраняет вещи вроде node_modules между прогонами во времени — это не канал для передачи данных между jobs в одном прогоне. Артефакты двигают данные вбок внутри прогона; кэш двигает данные вперёд между прогонами.
▸Почему это работает
Почему ${{ github.sha }} работает, а голое github.sha — нет? У GitHub Actions есть собственный язык выражений, вычисляемый до того, как runner что-либо выполнит. ${{ }} — это маркер, который говорит «вычисли это выражение и подставь результат». Внутри него ты читаешь контексты — структурированные объекты вроде github (метаданные события: github.sha, github.ref, github.actor), env, secrets, matrix и runner. Вне скобок github.sha — это просто буквальный текст. Поэтому run: echo ${{ github.sha }} печатает хэш коммита, а run: echo github.sha печатает слова. Контексты — это то, как workflow узнаёт, что его запустило, и подстраивается.
Поток одного прогона
Когда срабатывает событие, GitHub читает подходящий файл workflow, строит граф jobs из needs: и отправляет каждый готовый job на runner. Внутри каждого runner steps выполняются сверху вниз, и первый упавший шаг (ненулевой код выхода) роняет job, если ты не отказался от этого явно. Вот вся цепочка от начала до конца.
| Существительное | Что это | Аналогия |
|---|---|---|
workflow | Один YAML-файл в .github/workflows/, запускаемый событиями on: | Книга рецептов, открытая поводом (push, расписание) |
job | Единица работы; по умолчанию параллельна, порядок — через needs: | Одно блюдо — несколько готовятся разом, пока одно не ждёт другого |
step | Один action (uses:) или одна shell-команда (run:), по порядку | Одна инструкция в рецепте |
runner | Свежая VM (runs-on:), на которой идёт job, уничтожаемая после | Одноразовая кухня, отдраенная и выброшенная под каждое блюдо |
▸Почему это работает
Сеньорская гигиена, превращающая рабочий workflow в безопасный. Пинни сторонние actions к полному commit SHA, а не к подвижному тегу: uses: some/action@a1b2c3d… неизменяем, тогда как @v3 может быть перенацелен тем, кто владеет тегом — дыра в цепочке поставок. Ставь least-privilege permissions:, в идеале по умолчанию read-only, и поднимай права по конкретному job только там, где нужно, чтобы скомпрометированный шаг не смог запушить в твой репозиторий через авто-выданный GITHUB_TOKEN. Добавь concurrency: с cancel-in-progress, чтобы новый push отменял уже устаревший прогон на той же ветке, а не жёг runner на превзойдённом коде.
В workflow есть job сборки и job деплоя. deploy падает, потому что папки dist/, созданной build, нет. Почему?
Нужен шаг, который клонирует твой репозиторий на свежий runner, прежде чем тесты смогут прочитать код. Выбери правильный механизм.
- 01Дай определение workflow, job, step и runner и объясни, как один прогон проходит через все четыре.
- 02Почему jobs идут параллельно, что меняет needs: и почему нельзя просто делить файлы между jobs?
GitHub Actions сводится к четырём существительным. Workflow — это один YAML-файл в .github/workflows/, запускаемый событиями, которые ты перечисляешь под on: — push, pull_request, ручной workflow_dispatch или schedule по cron. Workflow держит jobs, и jobs по умолчанию идут параллельно, каждый на своём runner: свежей виртуальной машине, названной в runs-on:, которая создаётся под job и уничтожается по его завершении. Job — это упорядоченный список steps, где каждый шаг либо uses: опубликованный action — actions/checkout и actions/setup-node канонически первые два, потому что runner стартует пустым, — либо run: shell-команду. Минимальный CI ровно таков: checkout, настрой тулчейн, поставь через npm ci, прогони тесты. Два следствия определяют модель. Первое: jobs изолированы — используй needs:, чтобы упорядочить их в DAG, и переноси данные артефактами (вбок внутри прогона) или кэшем (вперёд между прогонами), а не предполагая общий диск. Второе: workflow читает собственный контекст через синтаксис выражений ${{ github.sha }}, который движок вычисляет до выполнения шагов. Добавь сеньорскую гигиену — пинни actions к commit SHA, давай least-privilege permissions: и отменяй превзойдённые прогоны через concurrency: — и рабочий пайплайн становится безопасным. Теперь, когда в следующий раз увидишь deploy-job с ошибкой «нет такого файла», первый вопрос — не «почему упала сборка», а «передал ли build арtefакт через upload-artifact».
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.