JavaScript actions: the node20 runtime, the toolkit, and the ncc bundle
A JavaScript action runs as a node20 process on the runner with the @actions/toolkit for inputs, outputs, and the GitHub API. Its dependencies must be committed — bundled with ncc into one file — because runners do not run npm install for an action.
The action worked on the author’s laptop and in the test workflow, then failed for every consumer with Cannot find module '@actions/core'. The author had a correct action.yml pointing at main: index.js, an index.js that imported the toolkit, and a package.json listing the dependency — everything you would expect. What they did not have was node_modules committed to the repo, and they assumed the runner would npm install it the way their laptop did. It does not. A JavaScript action is handed to a node20 runtime and run exactly as committed — no install step, no package resolution. The fix was not more dependencies but fewer files that ship: run ncc build to compile index.js and its entire dependency tree into one dist/index.js, point action.yml at that, and commit it. The action that had silently depended on a build step nobody ran now carried its own runtime inside a single bundled file.
In the next ten minutes you will understand why the runner’s no-install rule exists, what the toolkit gives you that raw env vars don’t, and how to guard against the bundle-drift bug that quietly ships stale code to every consumer.
The node20 runtime and the toolkit
If you have ever written a GitHub Actions workflow and wondered why some actions feel like proper programs — reading structured inputs, masking secrets in logs, posting rich API calls — while others feel like shell scripts that happen to run in CI, the answer is the toolkit. Here is the contract between your code and the runner.
A JavaScript action declares runs.using: "node20" and main: pointing at an entry file. At call time GitHub Actions spawns a Node.js 20 process (the supported LTS runtime; older node16/node12 are deprecated) and executes that file on the host runner — no container, so it runs on Linux, macOS, and Windows alike, and it boots in well under a second versus a Docker action’s image pull. The contract between your code and the runner is the @actions/toolkit — a set of npm packages you call instead of poking at magic environment variables:
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 reads action.yml inputs (which arrive as INPUT_* env vars, but never read those directly); core.setOutput and core.setFailed are the right way to emit outputs and fail — setFailed sets the exit code so the step turns red. github.context hands you the parsed event payload, and github.getOctokit is a pre-authenticated API client. Reaching past the toolkit to parse process.env.GITHUB_EVENT_PATH yourself is the classic junior smell.
A JavaScript action's index.js imports @actions/core. package.json lists it as a dependency, but the action fails on consumers' runners with 'Cannot find module @actions/core'. Why?
Why you bundle: the ncc problem
The runner does not run npm install for an action — it runs your main: file as-is. So the dependency tree has to be present in the repository. Committing a raw node_modules works but is ugly: hundreds of folders, thousands of files, a noisy diff on every dependency bump, and a real supply-chain surface to review. The standard answer is @vercel/ncc: it compiles your entry file and all of its node_modules into a single dist/index.js, tree-shaking unused code along the way. You point action.yml at dist/index.js, commit that one file, and .gitignore node_modules. A toolkit-based action that pulls in @actions/core plus @actions/github (which carries Octokit) bundles to a few hundred kilobytes to low single-digit megabytes — one file, deterministic, reviewable as a diff.
When you ship a change to your action’s logic, ask yourself: did I rebuild dist/? That question should be automatic — here is why missing it silently ships the wrong code to every consumer.
The discipline this imposes is real and a common bug source: the committed bundle can drift from the source. If you edit index.js, run tests, and forget ncc build, CI keeps running the old dist/index.js and your change silently does nothing. Teams guard this with a CI check that runs ncc build and fails if git diff --exit-code dist/ is dirty — proving the committed bundle matches source. Two more senior notes: pin the runtime to node20 (don’t leave a deprecated version that GitHub will sunset), and for supply-chain safety, consumers should pin your action by full commit SHA, not a tag — uses: org/labeler@<40-char-sha> — because a tag like @v1 is mutable and a compromised release could ship malicious code inside that bundled dist/index.js to everyone tracking the tag.
A team edits a JavaScript action's source index.js, adds tests, and ships. The action's behavior in CI is unchanged — the new code never runs. The dist/ bundle is committed and action.yml points at it. What most likely happened?
- 01Why must a JavaScript action's dependencies be committed to the repo, and what tool solves it cleanly?
- 02What does the @actions/toolkit give you, and what is the anti-pattern it replaces?
A JavaScript action declares runs.using: "node20" and a main: entry file, and GitHub Actions runs it as a Node.js 20 process directly on the host runner — no container, so it works across Linux, macOS, and Windows and boots in under a second, far cheaper than a Docker action’s image pull. Your code reaches the runner through the @actions/toolkit: @actions/core for inputs, outputs, secret masking, and setFailed; @actions/github for the parsed context event payload and a pre-authenticated Octokit — using these instead of reading INPUT_* env vars or parsing the event JSON by hand is the dividing line between a robust action and a fragile one. The defining constraint is that runners never run npm install for an action: it executes your main: file exactly as committed, so the dependency tree must be in the repo. The standard solution is @vercel/ncc, which compiles the entry file and all of node_modules into a single tree-shaken dist/index.js of a few hundred kilobytes to a few megabytes; you commit that one file and gitignore node_modules. The recurring bug is bundle drift — editing source but forgetting ncc build leaves the runner executing the old dist/, so guard it with a CI check that the rebuilt bundle matches the committed one. Pin the runtime to a supported Node version, and have consumers pin your action by full commit SHA rather than a mutable tag, so a compromised release cannot ship a malicious bundle to everyone tracking @v1. Now when you see a JavaScript action behave identically after a source change, your first question is: was ncc build re-run and is the committed dist/ actually up to date?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.