open atlas
↑ Back to track
CI/CD pipelines CICD · 09 · 02

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.

CICD Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

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.

Quiz

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 taguses: 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.

Quiz

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?

Recall before you leave
  1. 01
    Why must a JavaScript action's dependencies be committed to the repo, and what tool solves it cleanly?
  2. 02
    What does the @actions/toolkit give you, and what is the anti-pattern it replaces?
Recap

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.