Create and troubleshoot custom actions
Action types
flowchart TD
Q["What does the action need?"] --> A{"Only a few steps that<br/>already work in a workflow?"}
A -->|"yes"| COMP["Composite action<br/>runs.using: composite"]
A -->|"no"| B{"A specific OS toolchain or<br/>system packages, Linux only?"}
B -->|"yes"| DOCK["Docker container action<br/>runs.using: docker"]
B -->|"no — fast, any OS,<br/>talks to the API"| JS["JavaScript action<br/>runs.using: node24"] | JavaScript | Docker container | Composite | |
|---|---|---|---|
runs.using | node24 | docker | composite |
| Runs on | Linux, Windows, macOS | Linux only (and Docker on self-hosted) | Whatever its steps support |
| Speed | Fastest — no container to build | Slowest — image built or pulled each run | As fast as its steps |
| Entry point | main, optional pre and post | image, args, entrypoint, pre-entrypoint, post-entrypoint | steps |
| Dependencies | Bundled into dist/ or committed node_modules | Installed in the image | Other actions and the runner's tools |
| Best for | API calls, logic, cross-platform tools | A fixed toolchain or language other than JavaScript | Sharing a sequence of steps |
JavaScript actions
name: Add PR label
description: Adds a label to the pull request that triggered the workflow
author: octo-org
inputs:
label:
description: Label to add
required: true
github-token:
description: Token used to call the API
default: ${{ github.token }}
outputs:
added:
description: "'true' when the label was added"
runs:
using: node24 # node20 was removed from the runners in September 2026
main: dist/index.js # bundled: dependencies are not installed for you
post: dist/cleanup.js # optional; pre: runs before, post: after the job
branding:
icon: tag
color: blue import * as core from '@actions/core';
import * as github from '@actions/github';
try {
const label = core.getInput('label', { required: true });
const octokit = github.getOctokit(core.getInput('github-token'));
const { owner, repo } = github.context.repo;
const number = github.context.payload.pull_request?.number;
if (!number) {
core.notice('Not a pull request event; nothing to label.');
} else {
core.startGroup('Calling the API');
await octokit.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [label] });
core.endGroup();
core.setOutput('added', 'true'); // → $GITHUB_OUTPUT
await core.summary.addHeading('Label added').addRaw(`Added ${label} to #${number}`).write();
}
} catch (error) {
core.setFailed(error instanceof Error ? error.message : String(error)); // exit code 1
} - The runner runs
mainwith its own bundled Node.js — the version named inruns.using, regardless of whatsetup-nodeinstalled for the job. Today that isnode24: Node 24 became the default on 16 June 2026 and Node 20 was removed on 23 September 2026. - The runner does not run
npm install. Commit a bundle (@vercel/ncc, esbuild, Rollup) ornode_modules. - The toolkit packages —
@actions/core,@actions/github,@actions/exec,@actions/tool-cache,@actions/cache,@actions/artifact— wrap workflow commands and the API. The current majors are ESM-only. preandpostrun before and after the job's steps (withpre-ifandpost-ifto condition them); state passes frommaintopostwithcore.saveState/core.getState, which use$GITHUB_STATE.
Docker container actions
name: Markdown lint
description: Lints Markdown files in a container
inputs:
globs:
description: Files to lint
default: '**/*.md'
outputs:
problems:
description: Number of problems found
runs:
using: docker
image: Dockerfile # built on every run — or a prebuilt docker://image:tag
args:
- ${{ inputs.globs }}
env:
LINT_STRICT: 'true' FROM node:24-alpine
RUN npm install --global markdownlint-cli2
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] #!/bin/sh -l
# The workspace is mounted at /github/workspace; GITHUB_OUTPUT points at a mounted file.
problems=$(markdownlint-cli2 "$1" 2>&1 | grep -c ':' || true)
echo "problems=$problems" >> "$GITHUB_OUTPUT"
if [ "$problems" -gt 0 ]; then
echo "::error::$problems Markdown problems found"
exit 1
fi imageis either a path to aDockerfile(built on every run, which is slow) ordocker://plus a published image (pulled — faster, and pinnable by digest).argsare passed to the entry point;inputsalso arrive asINPUT_<NAME>environment variables (upper-cased, spaces to underscores).- The runner mounts the workspace at
/github/workspaceand sets it as the working directory; do not setWORKDIRorUSERin the Dockerfile — GitHub runs the container as root by default and expects the workspace path. - Only Linux runners can run them. On Windows or macOS the step fails with Container action is only supported on Linux.
Composite actions
name: Install and build
description: Installs dependencies with caching, then builds
inputs:
node-version:
description: Node.js version
default: '24'
working-directory:
description: Folder that holds package.json
default: '.'
outputs:
build-dir:
description: Where the build output is
value: ${{ steps.build.outputs.dir }} # composite outputs need a value
runs:
using: composite
steps:
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
shell: bash # required on every run step
working-directory: ${{ inputs.working-directory }}
- id: build
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
WORKDIR: ${{ inputs.working-directory }}
run: |
npm run build
echo "dir=$WORKDIR/dist" >> "$GITHUB_OUTPUT" - Every
runstep needs an explicitshell— there is no default inside a composite action. - Outputs need a
valuethat maps a step output; JavaScript and Docker actions set outputs at runtime instead. - A composite action can
uses:other actions (including composites), and its steps run on the caller's runner, in the caller's job, with the caller'sGITHUB_TOKEN. - It has no access to the
secretscontext — pass what it needs throughinputs. - In the log it appears as one step, with its own steps folded inside.
steps:
- uses: actions/checkout@v7 # needed before a local action
- uses: ./.github/actions/install-and-build # local: path in this repository
id: build
with:
working-directory: web
- uses: octo-org/add-pr-label@v2 # public or shared private repository
with:
label: built
- uses: octo-org/monorepo/actions/notify@v1 # an action in a subdirectory
- uses: docker://ghcr.io/octo-org/lint:3.2 # a published container image
- run: echo "Output is in ${{ steps.build.outputs.build-dir }}" Immutable actions and what shipped instead
The study guide asks you to "understand the immutable actions rollout on hosted runners and implications for version pinning and registry sources". The product moved on after the guide was written, so it helps to know both the plan and what actually shipped. Checked on 5 November 2026:
| Item | What it is | Status |
|---|---|---|
| Immutable actions | Actions published as OCI packages to ghcr.io (via actions/publish-immutable-action) and downloaded by runners from pkg.actions.githubusercontent.com, so a version could never change after publication | Previewed in 2025, then removed from GitHub's public roadmap on 11 December 2025 in favour of building on immutable releases |
| Immutable releases | A repository or organisation setting: once a release is published, its tag cannot be moved or deleted and its assets cannot change | Generally available since October 2025 |
| SHA pinning policy | The allowed-actions policy can require every action to be pinned to a full commit SHA | Available since August 2025 |
| Workflow lockfiles | .github/workflows/actions.lock, generated by gh actions-lock, records the exact commit of every action and its transitive dependencies; a Require lockfile policy enforces it | Generally available since 28 October 2026 (not on GitHub Enterprise Server) |
Implications to carry into the exam:
- A tag such as
@v2is a mutable reference: the maintainer moves it with every release. Only a full commit SHA — or a commit recorded in a lockfile — is guaranteed to be the code you reviewed. - Immutable releases protect the tag of a release (
v2.3.1), not a floating major tag that is not itself a release. - On self-hosted runners, the 2025 rollout asked administrators to allow
pkg.actions.githubusercontent.com(covered by*.actions.githubusercontent.com); runners that could not reach it were opted out automatically. If a question mentions that host, it is about this rollout. - On GitHub Enterprise Server, actions from GitHub.com come through GitHub Connect or are copied in with
actions-sync— the "registry source" the action is really downloaded from.
If an answer option says 'use immutable actions'
Read it as "make the version you run unchangeable". Today that is done by pinning to a full commit SHA (optionally enforced by policy), by a workflow lockfile, or — for the publisher — by turning on immutable releases. The difference between the study guide and the current product is listed in the exam tips.
Troubleshoot action execution and errors
Run octo-org/add-pr-label@v2
| Error | Cause | Fix |
|---|---|---|
Cannot find package '@actions/core' | Dependencies not bundled or committed | Bundle into dist/ and point main at it |
Can't find 'action.yml', 'action.yaml' or 'Dockerfile' | Local action used before actions/checkout, wrong path, or file in the wrong folder | Check out first; use the folder path, not the file |
Required property is missing: shell | A composite run step without shell | Add shell: bash (or pwsh…) |
| An old action starts failing after September 2026 | It declares node20 (or older); runners now run every JavaScript action on Node 24, which it was never tested on, and the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION opt-out is gone | Upgrade to a release that declares node24, or publish one |
exec /entrypoint.sh: permission denied | Entry point not executable in Git | git update-index --chmod=+x entrypoint.sh |
Container action is only supported on Linux | Docker action on a Windows or macOS runner | Run that job on Linux, or rewrite as JavaScript |
Input required and not supplied: label | required: true input missing from with: | Pass it; note that required is enforced by the toolkit's getInput(…, { required: true }), not by the runner |
| Unexpected input warning | A with: key the action does not declare | Check spelling against action.yml |
Turn on step debug logging (ACTIONS_STEP_DEBUG) to see core.debug() output and the inputs
the action actually received; test JavaScript actions locally by setting INPUT_* variables; and give the
action a test workflow in its own repository that runs it with uses: ./.
Define action structure and metadata
Required files and directory structure
add-pr-label/
├── action.yml # metadata — required, at the root for a published action
├── dist/
│ ├── index.js # bundled entry point (ncc, esbuild or Rollup), committed
│ └── cleanup.js
├── src/index.js # source
├── package.json
├── README.md # inputs, outputs, an example workflow
└── LICENSE markdown-lint/
├── action.yml
├── Dockerfile
├── entrypoint.sh # must be executable: git update-index --chmod=+x entrypoint.sh
├── README.md
└── LICENSE my-service/
└── .github/
├── actions/
│ └── install-and-build/
│ └── action.yml # uses: ./.github/actions/install-and-build
└── workflows/
└── ci.yml
The only file every action needs is its metadata file, action.yml or
action.yaml. For a published action it sits at the root of the repository; an action in a subdirectory
is referenced as owner/repo/path@ref. A README with inputs, outputs and an example, and a licence, are
what users — and the Marketplace — expect.
Metadata syntax
| Key | Required | Notes |
|---|---|---|
name | Yes | Shown in the Actions tab and the Marketplace; must be unique there |
description | Yes | One short sentence |
author | No | |
inputs.<id> | No | description (required), required, default, deprecationMessage. Read as INPUT_<ID> or core.getInput(); in composites as ${{ inputs.id }} |
outputs.<id> | No | description; composites also need value |
runs | Yes | using plus the type's own keys |
branding | No | icon (a Feather icon name) and color (white, black, yellow, blue, green, orange, red, purple, gray-dark) for the Marketplace badge |
Workflow commands within actions
An action talks to the runner exactly as a run: step does — through workflow commands on standard output
and environment files. In JavaScript the toolkit does it for you; in Docker and composite actions you write them
yourself.
Toolkit (@actions/core) | Shell equivalent | Effect |
|---|---|---|
getInput('name') | $INPUT_NAME | Read an input |
setOutput('k', v) | echo "k=v" >> "$GITHUB_OUTPUT" | Set an output |
exportVariable('K', v) | echo "K=v" >> "$GITHUB_ENV" | Env var for later steps |
addPath(dir) | echo "dir" >> "$GITHUB_PATH" | Prepend to PATH for later steps |
setSecret(v) | echo "::add-mask::v" | Mask a value in logs |
saveState / getState | $GITHUB_STATE / $STATE_k | Pass state from pre/main to post |
startGroup / endGroup | ::group::title / ::endgroup:: | Fold log lines |
error · warning · notice | ::error file=…,line=…::msg | Annotations on the run and the PR diff |
debug(msg) | ::debug::msg | Only with step debug logging |
summary.addRaw(…).write() | >> "$GITHUB_STEP_SUMMARY" | Job summary Markdown |
setFailed(msg) | echo "::error::msg"; exit 1 | Fail the step |
When an action prints output it does not control — a test runner, a log file — use ::stop-commands::
with a one-off token so that lines beginning with :: are not interpreted as commands:
TOKEN=$(uuidgen)
echo "::stop-commands::$TOKEN" # stop treating :: lines as commands
cat untrusted-output.log # a line like ::set-env… is now just text
echo "::$TOKEN::" # resume Distribute and maintain actions
Distribution models
| Model | Who can use it | How |
|---|---|---|
| Local | Workflows in the same repository | uses: ./path after checkout; versioned with the repository itself |
| Public repository | Anyone | uses: owner/repo@ref, with or without a Marketplace listing |
| Private repository | Repositories you allow | In the action's repository: Settings → Actions → General → Access → accessible from repositories owned by the user, the organisation or the enterprise |
| Internal repository | The enterprise's organisations | The same Access setting, at enterprise scope |
| GitHub Marketplace | Anyone, discoverable by search | A public repository with one action at its root, published from a release |
Private actions and reusable workflows share one switch
The same Access setting shares both actions and reusable workflows from a private or internal repository. Sharing does not give the caller read access to the code itself — only the right to run it.
Publish an action to the GitHub Marketplace
-
Prepare the repository
The action must live in a public repository that holds a single action, with its
action.ymlat the root and no workflow files. Itsnamemust be unique in the Marketplace and must not match a Marketplace category, a GitHub feature, or another user or organisation. -
Add the metadata the listing shows
A clear
description,branding(iconandcolor), and a README with inputs, outputs and a usage example. GitHub checks the metadata when you draft the release and lists any problems. -
Draft a release and tick Publish this Action to the GitHub Marketplace
Under Releases → Draft a new release, the banner offers the option once
action.ymlis detected. The first time, you accept the GitHub Marketplace Developer Agreement. -
Choose categories and a semantic version tag
Pick a primary category (and optionally a second), then create a tag such as
v1.0.0and write release notes. -
Publish the release
The listing goes live. Later releases update it; un-ticking the option on a release, or delisting from the listing page, removes it. Organisations that GitHub has verified get the verified creator badge on their listings.
-
Point the major tag at the release
Create or move
v1so users who pin the major get the update — by hand or with a workflow like the one below.
Versioning and release strategies
flowchart LR V1["tag v1<br/>moves with each v1 release"] --> C3["commit c3f9a21<br/>release v1.2.1"] V11["tag v1.2.1<br/>immutable release"] --> C3 V12["tag v1.2.0<br/>immutable release"] --> C2["commit 8b7e4d0"] SHA["@c3f9a21…<br/>full SHA pin"] --> C3
| Consumer reference | Gets | Trade-off |
|---|---|---|
@main | Whatever is on the branch now | Never for production — every push changes your workflow |
@v1 | The latest v1 release | Convenient, gets fixes; trusts the maintainer not to break or compromise it |
@v1.2.1 | That release | Stable; still a tag, unless it is an immutable release |
@<full SHA> # v1.2.1 | That exact commit | The only immutable reference; update with Dependabot or Renovate |
- Follow semantic versioning: a breaking change — a removed input, a new required input, a Node runtime upgrade — is a new major (
v2). Most first-party actions released a new major fornode24. - Maintain the major tag (
v1) for consumers who want updates, and keep the previous major working for a deprecation period. - Mark retiring inputs with
deprecationMessage; the runner prints it as a warning whenever the input is used. - Test the action against its own test workflow before tagging; draft releases let you review notes before they are public.
- With immutable releases on, a release's tag cannot be force-pushed — keep the floating major as a plain tag (not a release) if you move it.
name: Release
on:
release:
types: [published]
permissions:
contents: write
jobs:
move-major-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Point v<major> at this release
env:
TAG: ${{ github.event.release.tag_name }} # e.g. v1.2.1
run: |
MAJOR="${TAG%%.*}" # v1
git tag -f "$MAJOR" "$TAG"
git push origin "$MAJOR" --force