5.3 Building JavaScript Actions

Key Takeaways

  • JavaScript actions execute directly within the Node.js 20 runtime (runs.using: 'node20') embedded in the GitHub Actions runner agent, providing cross-platform execution on Linux, macOS, and Windows.
  • The @actions/core toolkit provides standard APIs for reading inputs (core.getInput), setting outputs (core.setOutput), failure handling (core.setFailed), log annotations, and automated secret masking (core.setSecret).
  • The @actions/github toolkit exposes the Octokit REST/GraphQL API client and the event webhook payload context (github.context) for interacting with pull requests, issues, and releases.
  • JavaScript actions support pre and post execution lifecycle hooks (runs.pre, runs.post, runs.post-if) to execute setup and teardown logic before and after main workflow steps.
  • Because runner agents do not execute npm install when running actions, all dependencies and source code must be compiled into a standalone bundle (typically dist/index.js) using @vercel/ncc.
Last updated: August 2026

Building JavaScript Actions

JavaScript Actions are the premier choice for authoring high-performance, cross-platform GitHub Actions. Because the GitHub Actions runner agent embeds a dedicated Node.js runtime, JavaScript actions execute directly on the runner host with sub-second startup overhead and full native compatibility across Linux, macOS, and Windows runners.

By leveraging the official @actions toolkit suite—specifically @actions/core and @actions/github—developers can build sophisticated workflows that interact with GitHub APIs, manipulate repository files, sanitize sensitive data, and manage multi-phase execution lifecycles. Mastery of JavaScript action development and bundling workflows is heavily tested on the GH-200 exam.


1. Runtime Environment & action.yml Specification

JavaScript actions declare their execution runtime using runs.using: 'node20' in action.yml. The runner utilizes its embedded Node.js 20 binary to execute the specified entrypoint script.

name: 'PR Labeler and Release Auditor'
description: 'Automatically verifies PR compliance and applies team labels via Octokit'

inputs:
  github-token:
    description: 'GitHub Access Token'
    required: true
    default: ${{ github.token }}
  required-label:
    description: 'Label to apply on pull requests'
    required: false
    default: 'status: reviewed'

outputs:
  label-applied:
    description: 'Boolean string indicating whether the label was attached'

runs:
  using: 'node20'
  pre: 'dist/pre.js'        # Optional setup hook
  main: 'dist/index.js'      # Primary execution entrypoint
  post: 'dist/cleanup.js'    # Optional teardown hook
  post-if: 'always()'       # Teardown condition (defaults to success())

[!NOTE] Node Runtime Deprecation Timeline: GitHub Actions previously supported node12 and node16. Both are fully deprecated. All modern JavaScript actions should target node20 to ensure long-term support and security compliance.


2. The @actions/core Toolkit

The @actions/core npm package provides fundamental utilities for interacting with the runner execution environment, handling inputs/outputs, controlling step status, and logging.

+-----------------------------------------------------------------------------+
|                        @ACTIONS/CORE API ARCHITECTURE                       |
|                                                                             |
|   +---------------------------------------------------------------------+   |
|   | INPUT / OUTPUT APIS                                                 |   |
|   | - core.getInput('name', { required: true })                        |   |
|   | - core.getBooleanInput('flag')                                      |   |
|   | - core.getMultilineInput('items')                                   |   |
|   | - core.setOutput('key', 'value')                                    |   |
|   +---------------------------------------------------------------------+   |
|                                      |                                      |
|   +----------------------------------+----------------------------------+   |
|   |                                  |                                  |   |
|   v                                  v                                  v   |
| +--------------------+    +--------------------+    +--------------------+  |
| | LOGGING & STATUS   |    | SECRET PROTECTION  |    | RUNNER STATE       |  |
| | - core.info()      |    | - core.setSecret() |    | - core.exportVar() |  |
| | - core.warning()   |    |   (Masks string in |    | - core.addPath()   |  |
| | - core.error()     |    |    all runner logs)|    | - core.saveState() |  |
| | - core.setFailed() |    +--------------------+    | - core.getState()  |  |
| +--------------------+                              +--------------------+  |
+-----------------------------------------------------------------------------+

Key @actions/core Methods

FunctionPurpose & Operational Behavior
core.getInput(name, options)Reads an input passed via with:. Returns a string with whitespace trimmed. If options.required is true and the input is missing, throws an error.
core.getBooleanInput(name)Parses boolean inputs (true, false, yes, no, on, off). Throws a TypeError if the value cannot be converted to a boolean.
core.getMultilineInput(name)Reads a multi-line input string and splits it into an array of string elements.
core.setOutput(name, value)Registers an action output by appending name=value to the $GITHUB_OUTPUT file stream.
core.setFailed(message)Sets the step status to failed, logs an error annotation in the UI, and sets the Node process exit code to 1. Subsequent steps in the job are halted.
core.setSecret(secret)Registers a sensitive string with the runner's masking engine. Any subsequent occurrence of this string in stdout/stderr logs is replaced with ***.
core.exportVariable(name, val)Sets an environment variable persisted to subsequent steps by appending to $GITHUB_ENV.
core.addPath(inputPath)Prepends a directory path to the runner's system $PATH via $GITHUB_PATH.
core.startGroup(name) / endGroup()Creates collapsible log group sections in the GitHub Actions console UI.

3. The @actions/github Toolkit & Octokit Integration

The @actions/github package exposes the current webhook execution context (github.context) and provides a pre-configured Octokit REST and GraphQL API client (github.getOctokit(token)).

// Complete JavaScript Action Implementation: src/index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    // 1. Read validated inputs
    const token = core.getInput('github-token', { required: true });
    const labelName = core.getInput('required-label') || 'status: reviewed';
    
    // 2. Instantiate authenticated Octokit client
    const octokit = github.getOctokit(token);
    const context = github.context;

    // 3. Verify event trigger is a pull request
    if (!context.payload.pull_request) {
      core.info('This action only operates on pull_request events. Skipping.');
      core.setOutput('label-applied', 'false');
      return;
    }

    const prNumber = context.payload.pull_request.number;
    const owner = context.repo.owner;
    const repo = context.repo.repo;

    core.info(`Processing Pull Request #${prNumber} on ${owner}/${repo}`);

    // 4. Attach label using GitHub REST API
    await octokit.rest.issues.addLabels({
      owner,
      repo,
      issue_number: prNumber,
      labels: [labelName]
    });

    core.info(`Successfully added label "${labelName}" to PR #${prNumber}`);
    core.setOutput('label-applied', 'true');
  } catch (error) {
    // 5. Handle errors and fail step gracefully
    core.setFailed(`Action execution failed: ${error.message}`);
  }
}

run();

4. Execution Lifecycle Hooks: pre, main, and post

JavaScript actions support a three-phase execution lifecycle that allows actions to perform prerequisite environment setup and post-job teardown:

+-----------------------------------------------------------------------------+
|                   JAVASCRIPT ACTION LIFECYCLE HOOKS                         |
|                                                                             |
|   JOB START                                                                 |
|       |                                                                     |
|       v                                                                     |
|   [1. PRE STEP (runs.pre)]    ---> Runs BEFORE all workflow job steps       |
|       |                            (e.g., install daemon, pre-auth credentials|
|       v                                                                     |
|   [2. WORKFLOW JOB STEPS]     ---> Checkout, Build, Test                    |
|       |                                                                     |
|       v                                                                     |
|   [3. MAIN STEP (runs.main)]  ---> Action executes its primary logic        |
|       |                                                                     |
|       v                                                                     |
|   [4. REMAINING JOB STEPS]    ---> Deployment, Notify                       |
|       |                                                                     |
|       v                                                                     |
|   [5. POST STEP (runs.post)]  ---> Runs at the VERY END of the job          |
|                                    (e.g., stop daemon, clean secrets, post  |
|                                     telemetry). Conditioned on post-if.     |
|   JOB COMPLETION                                                            |
+-----------------------------------------------------------------------------+

State Sharing Between Phases (saveState & getState)

Because pre, main, and post execute as separate Node.js processes, memory is not shared across phases. To pass data from main to post (such as a running process PID or temporary directory path), use core.saveState and core.getState:

// In main script:
core.saveState('PID', serverProcess.pid.toString());

// In post cleanup script:
const pid = core.getState('PID');
if (pid) {
  process.kill(Number(pid));
  core.info(`Successfully terminated daemon process ${pid}`);
}

5. Packaging & Dependency Distribution with @vercel/ncc

A critical operational difference between building standard Node.js applications and GitHub Actions is dependency handling:

[!IMPORTANT] Runners Do Not Execute npm install: When a runner invokes a JavaScript action via uses: owner/repo@v1, it clones the action repository and immediately executes node dist/index.js. The runner does not run npm install. Therefore, all node_modules dependencies must be pre-packaged into the repository.

Bundling with @vercel/ncc

Committing the raw node_modules/ folder creates bloated repositories with tens of thousands of loose files. The official GitHub standard is to compile the source code and all dependencies into a single minified JavaScript bundle using @vercel/ncc.

# 1. Install dev dependencies
npm install @actions/core @actions/github
npm install --save-dev @vercel/ncc

# 2. Add build script in package.json:
# "scripts": { "build": "ncc build src/index.js -o dist --minify --source-map" }

# 3. Compile bundle into dist/index.js
npm run build

# 4. Commit dist/index.js to Git
git add dist/index.js
git commit -m "chore: bundle distribution package"
Test Your Knowledge

A JavaScript action author wants to ensure that if an API call fails with an HTTP 404 error, the action step is marked as failed in the GitHub UI, an error annotation is logged, and subsequent steps in the job are halted. Which method from @actions/core should be invoked inside the catch block?

A
B
C
D
Test Your Knowledge

A team publishes a new JavaScript action to a public GitHub repository. When users invoke the action in their workflows using uses: my-org/my-action@v1, the step fails immediately with the error Cannot find module '@actions/core'. What step did the action authors omit during their release process?

A
B
C
D
Test Your Knowledge

An engineer is writing a JavaScript action that starts a local mock database container during setup and must guarantee that the container is stopped when the job finishes, even if intervening workflow steps fail. How should this teardown behavior be configured in action.yml?

A
B
C
D