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.
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
node12andnode16. Both are fully deprecated. All modern JavaScript actions should targetnode20to 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
| Function | Purpose & 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 viauses: owner/repo@v1, it clones the action repository and immediately executesnode dist/index.js. The runner does not runnpm install. Therefore, allnode_modulesdependencies 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"
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 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?
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?