JavaScript-экшены: рантайм node20, toolkit и бандл ncc
JavaScript-экшен выполняется как процесс node20 на раннере с @actions/toolkit для inputs, outputs и GitHub API. Его зависимости должны быть закоммичены — собраны через ncc в один файл — ведь раннеры не делают npm install для экшена.
Экшен работал на ноутбуке автора и в тестовом воркфлоу, затем падал у каждого потребителя с Cannot find module '@actions/core' (ошибка Node.js: модуль не найден). У автора был корректный action.yml, указывающий на main: index.js, index.js, импортирующий toolkit, и package.json, перечисляющий зависимость — всё, что ожидаешь. Чего у него не было — закоммиченного в репозиторий node_modules, и он полагал, что раннер сделает npm install так же, как его ноутбук. Не сделает. JavaScript-экшен передаётся рантайму node20 и выполняется ровно как закоммичен — без шага установки, без разрешения пакетов. Фиксом было не больше зависимостей, а меньше отгружаемых файлов: запустить ncc build, чтобы скомпилировать index.js и всё его дерево зависимостей в один dist/index.js, указать на него в action.yml и закоммитить. Экшен, что молча зависел от шага сборки, который никто не запускал, теперь нёс свой рантайм внутри одного собранного файла.
За следующие десять минут ты поймёшь, почему раннер не делает npm install, что toolkit даёт тебе сверх сырых env-переменных — и как защититься от дрейфа бандла, который молча отгружает устаревший код каждому потребителю.
Рантайм node20 и toolkit
Если ты когда-нибудь писал воркфлоу GitHub Actions и замечал, что одни экшены читают структурированные inputs, маскируют секреты и делают API-вызовы, а другие похожи на просто скрипты в CI — разница в toolkit. Вот контракт между твоим кодом и раннером.
JavaScript-экшен объявляет runs.using: "node20" и main:, указывающий на входной файл. В момент вызова GitHub Actions спавнит процесс Node.js 20 (поддерживаемый LTS-рантайм; старые node16/node12 устарели) и выполняет этот файл на раннере — без контейнера, так что он работает на Linux, macOS и Windows одинаково и стартует менее чем за секунду против pull образа Docker-экшена. Контракт между вашим кодом и раннером — это @actions/toolkit — набор npm-пакетов, которые вы вызываете вместо ковыряния в магических переменных окружения:
import * as core from "@actions/core";
import * as github from "@actions/github";
async function run() {
const token = core.getInput("github-token", { required: true });
core.setSecret(token); // mask it in logs
const label = core.getInput("label");
const octokit = github.getOctokit(token);
const { owner, repo } = github.context.repo;
const pr = github.context.payload.pull_request;
await octokit.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [label],
});
core.setOutput("labeled", "true"); // available to later steps
}
run().catch((err) => core.setFailed(err.message));core.getInput читает inputs из action.yml (они приходят как env-переменные INPUT_*, но напрямую их никогда не читайте); core.setOutput и core.setFailed — верный способ отдать outputs и упасть — setFailed ставит код выхода, так что шаг краснеет. github.context отдаёт распарсенный payload события, а github.getOctokit — заранее аутентифицированный клиент API. Дотягиваться мимо toolkit к парсингу process.env.GITHUB_EVENT_PATH вручную — классический джуниорский запах.
index.js JavaScript-экшена импортирует @actions/core. package.json перечисляет его как зависимость, но экшен падает на раннерах потребителей с 'Cannot find module @actions/core'. Почему?
Почему собирают: проблема ncc
Раннер не делает npm install для экшена — он выполняет ваш файл main: как есть. Так что дерево зависимостей должно присутствовать в репозитории. Закоммитить сырой node_modules работает, но уродливо: сотни папок, тысячи файлов, шумный diff на каждом бампе зависимости и реальная supply-chain-поверхность для ревью. Стандартный ответ — @vercel/ncc: он компилирует ваш входной файл и всё его node_modules в единый dist/index.js, попутно tree-shaking неиспользуемый код. Вы указываете main: в action.yml на dist/index.js, коммитите этот один файл и кладёте node_modules в .gitignore. Экшен на toolkit, тянущий @actions/core плюс @actions/github (несущий Octokit), собирается в несколько сотен килобайт до единиц мегабайт — один файл, детерминированный, ревьюимый как diff.
Когда ты отгружаешь изменение логики экшена, задай себе вопрос: пересобран ли dist/? Этот вопрос должен стать рефлексом — вот почему его пропуск молча отгружает старый код каждому потребителю.
Дисциплина, которую это навязывает, реальна и частый источник багов: закоммиченный бандл может разойтись с исходником. Если вы правите index.js, гоняете тесты и забываете ncc build, CI продолжает гонять старый dist/index.js, и ваше изменение молча ничего не делает. Команды стерегут это CI-проверкой, что запускает ncc build и падает, если git diff --exit-code dist/ грязный — доказывая, что закоммиченный бандл совпадает с исходником. Ещё две старшие заметки: пиньте рантайм на node20 (не оставляйте устаревшую версию, которую GitHub отключит), и для supply-chain-безопасности потребители должны пинить ваш экшен по полному commit SHA, а не тегу — uses: org/labeler@<40-символьный-sha> — ведь тег вроде @v1 изменяем, и скомпрометированный релиз мог бы отгрузить зловредный код внутри этого собранного dist/index.js всем, кто отслеживает тег.
Команда правит исходный index.js JavaScript-экшена, добавляет тесты и отгружает. Поведение экшена в CI не изменилось — новый код не выполняется. Бандл dist/ закоммичен, action.yml указывает на него. Что вероятнее всего произошло?
- 01Почему зависимости JavaScript-экшена должны коммититься в репо и какой инструмент решает это чисто?
- 02Что даёт @actions/toolkit и какой анти-паттерн он заменяет?
JavaScript-экшен объявляет runs.using: "node20" и входной файл main:, и GitHub Actions выполняет его как процесс Node.js 20 прямо на раннере — без контейнера, так что он работает на Linux, macOS и Windows и стартует менее чем за секунду, куда дешевле pull образа Docker-экшена. Ваш код достаёт раннер через @actions/toolkit: @actions/core для inputs, outputs, маскировки секретов и setFailed; @actions/github для распарсенного payload события context и заранее аутентифицированного Octokit — использование их вместо чтения env-переменных INPUT_* или ручного парсинга JSON события — граница между надёжным экшеном и хрупким. Определяющее ограничение в том, что раннеры никогда не делают npm install для экшена: он выполняет ваш файл main: ровно как закоммичен, так что дерево зависимостей должно быть в репо. Стандартное решение — @vercel/ncc, компилирующий входной файл и весь node_modules в единый tree-shaken dist/index.js в несколько сотен килобайт до нескольких мегабайт; вы коммитите этот один файл и gitignore-ите node_modules. Повторяющийся баг — дрейф бандла: правка исходника с забытым ncc build оставляет раннер выполняющим старый dist/, так что стерегите это CI-проверкой, что пересобранный бандл совпадает с закоммиченным. Пиньте рантайм на поддерживаемую версию Node, а потребители пусть пинят ваш экшен по полному commit SHA, а не изменяемому тегу, чтобы скомпрометированный релиз не мог отгрузить зловредный бандл всем, кто отслеживает @v1. Теперь, когда встретишь JavaScript-экшен, что ведёт себя одинаково после правки исходника, твой первый вопрос будет: пересобран ли ncc build — и актуален ли закоммиченный dist/?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.