Writing Your Own GitHub Actions
Build your own GitHub Actions: when to bother, the action.yml metadata file, and the three types - composite, JavaScript, and Docker - with worked examples and versioning.
Most of a GitHub Actions workflow is glue: a few run: steps and a handful of marketplace actions someone else wrote. That is usually the right call. But once you find yourself copying the same twenty lines of shell into five workflows, or reaching for a language richer than bash to talk to an API, it is time to package that logic as a custom action. A custom action is a reusable unit you reference with uses:, the same way you already use actions/checkout. This guide covers when it is worth building one, the metadata file that defines it, the three action types and their tradeoffs, and how to publish and version one so others can depend on it safely.
When to build an action vs just using run: steps
The honest default is: do not build a custom action. If a job needs to run some commands, run: steps are simpler, more readable, and easier to debug than anything you can package. You see exactly what runs, in order, with no indirection. Reach for a custom action only when the plain steps start costing you more than they save.
The signals that it is time:
- Repetition across workflows. The same block of steps appears in several jobs or several repos. A custom action gives you one place to change it.
- Real logic, not just commands. You need loops, error handling, retries, JSON parsing, or an API call with pagination. That is painful in bash and pleasant in JavaScript.
- A clean interface matters. You want callers to pass a few named inputs and get named outputs back, without knowing how the work is done.
- You are packaging a tool for others. Anything you intend to publish on the Marketplace has to be an action.
One clarification before you go further: a custom action is not the same as a reusable workflow. An action is a single step you drop into a job (it does one thing - set up a tool, post a comment, compute a value). A reusable workflow is a whole job or set of jobs you call from another workflow. If you are trying to share an entire CI pipeline shape, that is a reusable workflow (see the reusable workflows guide). If you are trying to share a single step, that is an action. This guide is about actions.
The action.yml metadata file
Every action - whatever its type - is defined by a metadata file named action.yml (or action.yaml) at the root of the action's directory. This file is the action's contract: its name, the inputs it accepts, the outputs it produces, and how it actually runs. GitHub reads it when a workflow references the action with uses:.
The four parts that matter:
- name and description - shown in the Marketplace and in logs. Required.
- inputs - the named parameters callers pass. Each input can declare a
description, whether it isrequired, and adefault. Callers set them underwith:. - outputs - the named values the action returns, which later steps read via
steps.<id>.outputs.<name>. - runs - how the action executes. This is what determines the action's type:
composite,node20, ordocker. Everything else in the file is the same across types; onlyrunschanges shape.
name: "Greet"
description: "Prints a greeting and returns the time it ran"
inputs:
who:
description: "Name to greet"
required: false
default: "world"
outputs:
greeted-at:
description: "ISO timestamp of when the greeting ran"
runs:
using: "composite"
steps:
- run: echo "hello, ${{ inputs.who }}"
shell: bash
Get the action.yml right and the rest is implementation. It is the part callers actually see, so name your inputs and outputs the way you want people to type them.
The three action types
There are exactly three kinds of action, and the difference is entirely in the runs block. Pick the simplest one that can do the job.
- Composite - bundles a sequence of shell steps (and calls to other actions) into one. No code to compile, nothing to build. This is the simplest type and the one to reach for first.
- JavaScript - runs directly on the runner via Node, with no container. It starts fast, has full access to the runner filesystem, and gets the official
@actions/*toolkit libraries. This is the type for real logic that outgrows shell. - Docker container - runs your code inside a container you define, so you can use any language or toolchain, pinned to exact versions. The most flexible and the slowest, because the image has to be pulled or built before the step runs. Also Linux-runners only.
The tradeoff in one line: composite for bundling shell, JavaScript for fast logic on the runner, Docker when you need a specific environment and can accept the startup cost.
Composite: bundling shell steps
A composite action wraps steps you would otherwise paste into a workflow. The runs.using is composite and runs.steps is a list that looks almost exactly like the steps in a normal job - with one rule: every run: step must set shell: explicitly (there is no default shell inside a composite action).
Here is a complete composite action that installs a pinned tool and reports the version it installed. It lives in its own directory with just this one file.
# .github/actions/setup-tool/action.yml
name: "Setup Tool"
description: "Installs a pinned version of mytool and reports it"
inputs:
version:
description: "Version of mytool to install"
required: true
outputs:
installed-version:
description: "The version that was installed"
value: ${{ steps.report.outputs.version }}
runs:
using: "composite"
steps:
- name: Install
shell: bash
run: |
curl -sSL "https://example.com/mytool/${{ inputs.version }}/mytool" -o /usr/local/bin/mytool
chmod +x /usr/local/bin/mytool
- name: Report
id: report
shell: bash
run: echo "version=$(mytool --version)" >> "$GITHUB_OUTPUT"
Two details do the real work. Inside the action, a step exposes an output by writing name=value to the $GITHUB_OUTPUT file - that is how the report step publishes version. Then the action's own top-level outputs.installed-version wires that step output out to the caller with value: ${{ steps.report.outputs.version }}. Composite actions are the only type where you plumb outputs through in the metadata like this; it trips people up the first time.
A workflow uses it exactly like any marketplace action:
steps:
- uses: actions/checkout@v4
- id: tool
uses: ./.github/actions/setup-tool
with:
version: "1.4.0"
- run: echo "Got ${{ steps.tool.outputs.installed-version }}"
Note the uses: ./.github/actions/setup-tool - a relative path, because this action lives in the same repo. Actions can live in your repo like this, or in their own published repo.
JavaScript: logic on the runner
When shell stops being pleasant, a JavaScript action gives you a real programming language running directly on the runner - no container, so it starts fast. GitHub publishes a toolkit for exactly this: @actions/core (read inputs, set outputs, log, fail the step) and @actions/github (a ready-to-use authenticated Octokit client plus the event context).
The runs.using names a Node version and points at your entry file:
# action.yml
name: "PR Labeler"
description: "Adds a label to the current pull request"
inputs:
label:
description: "Label to add"
required: true
token:
description: "GITHUB_TOKEN for the API call"
required: true
outputs:
labeled:
description: "true if the label was applied"
runs:
using: "node20"
main: "dist/index.js"
The code reads inputs with core.getInput, does its work, and sets an output with core.setOutput. If something goes wrong, core.setFailed marks the step failed with a message:
// index.js
const core = require("@actions/core");
const github = require("@actions/github");
async function run() {
try {
const label = core.getInput("label", { required: true });
const token = core.getInput("token", { required: true });
const octokit = github.getOctokit(token);
const { owner, repo, number } = github.context.issue;
await octokit.rest.issues.addLabels({
owner,
repo,
issue_number: number,
labels: [label],
});
core.info(`Applied label "${label}" to PR #${number}`);
core.setOutput("labeled", "true");
} catch (err) {
core.setFailed(err.message);
}
}
run();
Notice the entry point in action.yml is dist/index.js, not index.js. A JavaScript action must ship its dependencies, and GitHub does not run npm install for you at action time. The standard practice is to bundle everything - your code plus node_modules - into one file with a tool like @vercel/ncc (ncc build index.js -o dist) and commit that dist/ output. Callers get a single self-contained file with nothing to install. Forgetting to build and commit dist/ is the number-one reason a JavaScript action fails with "cannot find module."
Reading inputs and setting outputs is the whole interface. Inputs declared in action.yml arrive as environment variables that core.getInput("label") reads by name; outputs you core.setOutput("labeled", ...) become steps.<id>.outputs.labeled for later steps. The names in the metadata and the names in the code have to match.
Docker container: any language, any toolchain
When you need a language or set of system tools that is not already on the runner - Python with specific packages, Go, a legacy CLI, an exact pinned environment - a Docker action packages all of it. The action runs inside a container you define, so what is installed is entirely up to you. The cost is startup: the image must be built or pulled before the step can run, which adds real seconds, and Docker actions only run on Linux runners.
# action.yml
name: "Scan"
description: "Runs a scanner in a pinned Python environment"
inputs:
path:
description: "Directory to scan"
default: "."
runs:
using: "docker"
image: "Dockerfile"
args:
- ${{ inputs.path }}
image: "Dockerfile" tells GitHub to build the container from a Dockerfile next to the action.yml (you can also point at a prebuilt image like docker://ghcr.io/me/scan:1.2.0 to skip the build). Inputs reach the container as INPUT_* environment variables and, as shown here, can be passed as args to the entrypoint. The container writes outputs the same way every action does - by appending to the file named in the $GITHUB_OUTPUT environment variable, which the runner mounts in.
Reach for Docker only when composite or JavaScript genuinely cannot do the job. It is the most powerful and the least convenient of the three.
Referencing inputs and outputs
Across all three types the caller-facing shape is identical, and it is worth stating on its own because it is where the pieces connect:
- Passing inputs: the caller sets them under
with:, keyed by the input names fromaction.yml. - Reading them inside the action: composite steps use
${{ inputs.name }}; JavaScript usescore.getInput("name"); Docker gets them asINPUT_NAMEenv vars. - Producing outputs inside the action: a shell/composite step writes
name=valueto$GITHUB_OUTPUT; JavaScript callscore.setOutput("name", value). - Consuming outputs in the workflow: the caller gives the step an
idand reads${{ steps.<id>.outputs.<name> }}.
The one asymmetry to remember is composite actions: they need the extra outputs.<name>.value mapping in the metadata to forward an inner step's output out to the caller. JavaScript and Docker actions do not - anything they write to $GITHUB_OUTPUT (or via setOutput) is already visible to the caller.
Publishing and versioning
An action is just a repository with an action.yml at its root, so publishing is mostly about tagging it in a way callers can trust. The convention that the whole ecosystem follows:
- Tag releases with semver, e.g.
v1.4.0. This is the immutable, exact version. - Also maintain a moving major tag, e.g.
v1, that you re-point at each newv1.x.yrelease. This is why you can writeuses: actions/checkout@v4and quietly get every non-breaking fix. When you shipv1.4.1, you movev1to that commit; when you make a breaking change, you cutv2and start a new moving tag.
git tag -a v1.4.0 -m "Release 1.4.0" # immutable exact version
git tag -fa v1 -m "Move v1 to 1.4.0" # move the major tag to this release
git push origin v1.4.0
git push origin v1 --force # update the moving tag
To list an action on the GitHub Marketplace, the repo needs a public action.yml with name, description, and (recommended) branding, plus a released tag; GitHub then offers a "Publish this Action to the Marketplace" flow from the release page. Marketplace listing is optional - an action works fine referenced by owner/repo@ref whether or not it is listed.
Now the flip side: when you consume other people's actions, pin them by full commit SHA. A tag like @v4 is mutable - the author (or an attacker who compromises the account) can move it to point at different code, and your workflow, which often runs with a GITHUB_TOKEN and access to secrets, will happily run whatever it now points to. Pinning to a 40-character commit SHA makes the reference immutable: you run exactly the code you reviewed.
# Convenient but mutable - the tag can be moved under you:
- uses: actions/checkout@v4
# Immutable - pinned to an exact commit you can audit:
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
Keep the trailing # v4.1.0 comment so humans (and tools like Dependabot, which can update these SHAs for you) still see the friendly version. The rule of thumb: publish with moving tags for your consumers' convenience, but consume with pinned SHAs for your own safety.
The shape of it
A custom action packages one reusable step. Do not build one until plain run: steps genuinely repeat or outgrow bash - and when you do, it is always the same skeleton: an action.yml declaring inputs, outputs, and a runs block. That runs block picks the type: composite to bundle shell (start here), JavaScript for fast logic on the runner with @actions/core and @actions/github, or Docker when you need a specific environment and can pay the startup cost. Inputs come in via with: and go out via $GITHUB_OUTPUT or setOutput; composite actions are the one type that needs the extra outputs.value mapping to forward them. Ship it with a semver tag and a moving major tag so callers get fixes for free - and when you are the caller, pin other people's actions by SHA so you only ever run the code you reviewed. If you are trying to share a whole pipeline rather than a single step, you want a reusable workflow instead.