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.
Choosing a type
Section titled “Choosing a type”| Composite | JavaScript | Docker container | |
|---|---|---|---|
| Language | YAML + shell | TypeScript/JavaScript | Anything |
| Runs on | Any runner | Any runner | Linux only |
| Startup cost | None | ~1s | 10s–2min (image pull or build) |
| Needs a build step | No | Yes (bundling) | Yes (image) |
| Access to the toolkit | Via other actions | Directly | Via environment variables |
| Good for | Sequencing existing steps | API work, logic, outputs | Existing 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.
A JavaScript action
Section titled “A JavaScript action”action.yml declares the interface:
name: Label by sizedescription: 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: blueruns.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:
| Call | Does |
|---|---|
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.summary | Writes 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.
Bundling is not optional
Section titled “Bundling is not optional”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 fiWhat 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.
A Docker container action
Section titled “A Docker container action”name: Scan configdescription: 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.
Testing
Section titled “Testing”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:
INPUT_LARGE-THRESHOLD=500 GITHUB_REPOSITORY=OWNER/REPO node dist/index.jsCrude, 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.
Versioning and publishing
Section titled “Versioning and publishing”Consumers reference OWNER/action@v1. The convention is a moving major tag that follows the latest
compatible release:
-
Release
v1.2.0as an annotated tag. -
Move the
v1tag to the same commit:git tag -fa v1 -m "v1.2.0" && git push origin v1 --force. -
Consumers on
@v1pick up the change; consumers on@v1.2.0do 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.
Responsibilities of a published action
Section titled “Responsibilities of a published action”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.setFailedwith 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.
Exercise
Section titled “Exercise”-
Scaffold a JavaScript action with
action.yml, an input with a default and one output. -
Bundle it with
nccand commitdist/. Add the dist-is-current check and confirm it fails when you change the source without rebuilding. -
Add
uses: ./to the action’s own CI so every pull request runs it. -
Call
core.setSecreton a derived value after logging it. Confirm the earlier log line is not masked retroactively. -
Tag
v1.0.0, movev1to it, and consume the action from another repository at@v1.