Skip to content

Building Custom GitHub Actions

Lesson 4 of 11Advanced5 min readGitHub Actions & CI/CD · Advanced ActionsVerified: Node 24 action runtime, action.yml metadata schema, August 2026

Most teams should not write a custom action. A composite action covers the majority of “we do these five steps everywhere” cases with no build step, no dependencies and no release process.

Write a real action when you need logic that shell cannot express cleanly, when you must call the GitHub API with structured error handling, or when you are publishing something other teams will depend on.

CompositeJavaScriptDocker container
LanguageYAML + shellTypeScript/JavaScriptAnything
Runs onAny runnerAny runnerLinux only
Startup costNone~1s10s–2min (image pull or build)
Needs a build stepNoYes (bundling)Yes (image)
Access to the toolkitVia other actionsDirectlyVia environment variables
Good forSequencing existing stepsAPI work, logic, outputsExisting CLI tools, non-JS languages

The Linux-only restriction on Docker actions is the constraint people hit late. An action that must work on windows-latest or macos-latest cannot be a Docker action, regardless of how convenient the packaging is.

action.yml declares the interface:

name: Label by size
description: Adds a size label to a pull request based on lines changed.
inputs:
token:
description: "Token used to call the API"
required: true
large-threshold:
description: "Line count above which the PR is labelled large"
required: false
default: "500"
outputs:
label:
description: "The label that was applied"
runs:
using: node24
main: dist/index.js
branding:
icon: tag
color: blue

runs.using names the runtime. Node action runtimes are versioned and retired over time — an action declaring an unsupported runtime stops working, so this line needs revisiting occasionally rather than being written once and forgotten.

main points at a bundled file, which is the part that surprises people.

The implementation uses the Actions toolkit:

import * as core from '@actions/core';
import * as github from '@actions/github';
async function run() {
try {
const token = core.getInput('token', { required: true });
const threshold = Number(core.getInput('large-threshold'));
const pr = github.context.payload.pull_request;
if (!pr) {
core.info('Not a pull request event; nothing to do.');
return;
}
const changed = pr.additions + pr.deletions;
const label = changed > threshold ? 'size/large' : 'size/small';
const octokit = github.getOctokit(token);
await octokit.rest.issues.addLabels({
...github.context.repo,
issue_number: pr.number,
labels: [label],
});
core.setOutput('label', label);
core.summary.addHeading('Pull request size').addRaw(`${changed} lines — \`${label}\``);
await core.summary.write();
} catch (error) {
core.setFailed(error instanceof Error ? error.message : String(error));
}
}
run();

The toolkit calls worth knowing:

CallDoes
core.getInput(name)Reads an input. Always a string; getBooleanInput parses YAML booleans
core.setOutput(name, value)Writes a step output
core.setFailed(message)Sets exit code 1 and logs an error — use instead of process.exit
core.setSecret(value)Registers a value for masking, for secrets the action derives itself
core.exportVariable(k, v)Sets an environment variable for later steps
core.summaryWrites to the job summary
core.debug(message)Visible only when debug logging is enabled

core.setFailed rather than throw or process.exit(1): it produces a clean error annotation instead of an unhandled rejection stack trace.

An action runs directly from the checked-out repository. There is no npm install step, so node_modules must either be committed — thousands of files, unreviewable in a diff — or the action must be bundled into a single file:

{
"scripts": {
"build": "ncc build src/index.js -o dist --license licenses.txt"
}
}

dist/ is then committed. That feels wrong the first time; it is how every published action works.

- run: npm ci
- run: npm run build
- name: Verify dist is current
run: |
if [ -n "$(git status --porcelain dist/)" ]; then
echo "::error::dist/ is out of date — run npm run build and commit the result"
git diff dist/
exit 1
fi

What it doesFails CI if the committed dist/ does not match a fresh build of the source.

Why we run itdist/ is what actually runs. A source change merged without rebuilding means the action's behaviour silently does not match its code — and the next person to read the source draws the wrong conclusion about a bug.

Expected resultA diff printed and a failed job when someone forgets to run the build.

Every serious action repository has some version of this check.

name: Scan config
description: Runs a policy scanner over configuration files.
inputs:
path:
description: "Directory to scan"
default: "."
runs:
using: docker
image: Dockerfile
args:
- ${{ inputs.path }}

image: Dockerfile builds the image on every run, which adds a minute or more to each job. Pointing at a prebuilt image instead — image: docker://ghcr.io/OWNER/scanner:1.4.2 — removes the build, and should reference a tag or digest you control rather than a moving one.

Inputs arrive as INPUT_<NAME> environment variables, uppercased with spaces and dashes replaced by underscores. The container runs as root by default with the workspace mounted at /github/workspace.

Three layers, in increasing cost:

Unit tests on the logic, with the toolkit mocked. Fast, and covers the branching.

Local execution with the inputs supplied as environment variables:

Terminal window
INPUT_LARGE-THRESHOLD=500 GITHUB_REPOSITORY=OWNER/REPO node dist/index.js

Crude, and it catches wiring errors — a misspelled input name, a missing default — in seconds.

Self-testing in CI. The action’s own repository uses the action:

- uses: ./
with:
token: ${{ secrets.GITHUB_TOKEN }}
large-threshold: "100"

uses: ./ runs the action from the checkout, so every pull request exercises the real thing. This is the highest-value test and the cheapest to add.

Consumers reference OWNER/action@v1. The convention is a moving major tag that follows the latest compatible release:

  1. Release v1.2.0 as an annotated tag.

  2. Move the v1 tag to the same commit: git tag -fa v1 -m "v1.2.0" && git push origin v1 --force.

  3. Consumers on @v1 pick up the change; consumers on @v1.2.0 do not.

That force-push is exactly why security-conscious consumers pin the commit SHA instead — a moving tag means the code they run can change without any action on their part. Both facts are true at once: publishers should maintain the major tag, and consumers should pin. See pinning actions.

Publishing to the Marketplace requires the repository to be public, action.yml at the root, and a release created through the GitHub UI with the Marketplace option selected.

An action you publish runs inside other people’s workflows with their token:

  • Do not log inputs. Someone will pass a secret to an input you did not expect to be sensitive.
  • Fail with a useful message. core.setFailed with context, not a stack trace.
  • Pin your own dependencies, and keep the bundle auditable.
  • Declare the minimum token permissions your action needs in the README, so consumers can scope it — see least-privilege permissions.
  • Treat event payload data as untrusted. A pull request title or branch name is attacker-supplied input, and passing it to a shell is the injection bug.
  1. Scaffold a JavaScript action with action.yml, an input with a default and one output.

  2. Bundle it with ncc and commit dist/. Add the dist-is-current check and confirm it fails when you change the source without rebuilding.

  3. Add uses: ./ to the action’s own CI so every pull request runs it.

  4. Call core.setSecret on a derived value after logging it. Confirm the earlier log line is not masked retroactively.

  5. Tag v1.0.0, move v1 to it, and consume the action from another repository at @v1.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.