Author and maintain actions

Building your own actions. The three action types and when each fits, the files and metadata an action needs, how an action talks to the runner, why actions fail and how to read it, and how to share, publish and version an action so its users can trust what they pin.

Skill
3
Exam weight
15–20%
Skills measured
January 2026
Sections
3

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"]
Choose the simplest type that does the job. Composite actions reuse existing steps; JavaScript actions run directly on the runner on any OS; Docker actions bring their own environment but only run on Linux runners.
JavaScriptDocker containerComposite
runs.usingnode24dockercomposite
Runs onLinux, Windows, macOSLinux only (and Docker on self-hosted)Whatever its steps support
SpeedFastest — no container to buildSlowest — image built or pulled each runAs fast as its steps
Entry pointmain, optional pre and postimage, args, entrypoint, pre-entrypoint, post-entrypointsteps
DependenciesBundled into dist/ or committed node_modulesInstalled in the imageOther actions and the runner's tools
Best forAPI calls, logic, cross-platform toolsA fixed toolchain or language other than JavaScriptSharing a sequence of steps

JavaScript actions

action.yml
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
src/index.js
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 main with its own bundled Node.js — the version named in runs.using, regardless of what setup-node installed for the job. Today that is node24: 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) or node_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.
  • pre and post run before and after the job's steps (with pre-if and post-if to condition them); state passes from main to post with core.saveState / core.getState, which use $GITHUB_STATE.

Docker container actions

action.yml
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'
Dockerfile
FROM node:24-alpine
RUN npm install --global markdownlint-cli2
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
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
  • image is either a path to a Dockerfile (built on every run, which is slow) or docker:// plus a published image (pulled — faster, and pinnable by digest).
  • args are passed to the entry point; inputs also arrive as INPUT_<NAME> environment variables (upper-cased, spaces to underscores).
  • The runner mounts the workspace at /github/workspace and sets it as the working directory; do not set WORKDIR or USER in 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

.github/actions/install-and-build/action.yml
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 run step needs an explicit shell — there is no default inside a composite action.
  • Outputs need a value that 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's GITHUB_TOKEN.
  • It has no access to the secrets context — pass what it needs through inputs.
  • In the log it appears as one step, with its own steps folded inside.
.github/workflows/ci.yml excerpt
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:

ItemWhat it isStatus
Immutable actionsActions 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 publicationPreviewed in 2025, then removed from GitHub's public roadmap on 11 December 2025 in favour of building on immutable releases
Immutable releasesA repository or organisation setting: once a release is published, its tag cannot be moved or deleted and its assets cannot changeGenerally available since October 2025
SHA pinning policyThe allowed-actions policy can require every action to be pinned to a full commit SHAAvailable 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 itGenerally available since 28 October 2026 (not on GitHub Enterprise Server)

Implications to carry into the exam:

  • A tag such as @v2 is 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
Run octo-org/add-pr-label@v2
with:
label: built
github-token: ***
node:internal/modules/esm/resolve:873
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@actions/core' imported from /home/runner/work/_actions/octo-org/add-pr-label/v2/src/index.js
Error: Cannot find package '@actions/core'
ErrorCauseFix
Cannot find package '@actions/core'Dependencies not bundled or committedBundle 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 folderCheck out first; use the folder path, not the file
Required property is missing: shellA composite run step without shellAdd shell: bash (or pwsh…)
An old action starts failing after September 2026It 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 goneUpgrade to a release that declares node24, or publish one
exec /entrypoint.sh: permission deniedEntry point not executable in Gitgit update-index --chmod=+x entrypoint.sh
Container action is only supported on LinuxDocker action on a Windows or macOS runnerRun that job on Linux, or rewrite as JavaScript
Input required and not supplied: labelrequired: 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 warningA with: key the action does not declareCheck 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

JavaScript action
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
Docker action
markdown-lint/
├── action.yml
├── Dockerfile
├── entrypoint.sh       # must be executable: git update-index --chmod=+x entrypoint.sh
├── README.md
└── LICENSE
local action
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

KeyRequiredNotes
nameYesShown in the Actions tab and the Marketplace; must be unique there
descriptionYesOne short sentence
authorNo
inputs.<id>Nodescription (required), required, default, deprecationMessage. Read as INPUT_<ID> or core.getInput(); in composites as ${{ inputs.id }}
outputs.<id>Nodescription; composites also need value
runsYesusing plus the type's own keys
brandingNoicon (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 equivalentEffect
getInput('name')$INPUT_NAMERead 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_kPass state from pre/main to post
startGroup / endGroup::group::title / ::endgroup::Fold log lines
error · warning · notice::error file=…,line=…::msgAnnotations on the run and the PR diff
debug(msg)::debug::msgOnly with step debug logging
summary.addRaw(…).write()>> "$GITHUB_STEP_SUMMARY"Job summary Markdown
setFailed(msg)echo "::error::msg"; exit 1Fail 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:

shell
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

ModelWho can use itHow
LocalWorkflows in the same repositoryuses: ./path after checkout; versioned with the repository itself
Public repositoryAnyoneuses: owner/repo@ref, with or without a Marketplace listing
Private repositoryRepositories you allowIn the action's repository: Settings → Actions → General → Access → accessible from repositories owned by the user, the organisation or the enterprise
Internal repositoryThe enterprise's organisationsThe same Access setting, at enterprise scope
GitHub MarketplaceAnyone, discoverable by searchA 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

Publish an action to the GitHub Marketplace
  1. Prepare the repository

    The action must live in a public repository that holds a single action, with its action.yml at the root and no workflow files. Its name must be unique in the Marketplace and must not match a Marketplace category, a GitHub feature, or another user or organisation.

  2. Add the metadata the listing shows

    A clear description, branding (icon and color), and a README with inputs, outputs and a usage example. GitHub checks the metadata when you draft the release and lists any problems.

  3. 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.yml is detected. The first time, you accept the GitHub Marketplace Developer Agreement.

  4. Choose categories and a semantic version tag

    Pick a primary category (and optionally a second), then create a tag such as v1.0.0 and write release notes.

  5. 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.

  6. Point the major tag at the release

    Create or move v1 so 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
A release tag (v1.2.1) and its commit are fixed; the major tag (v1) is moved by the maintainer to each new v1 release. A full SHA always means one commit.
Consumer referenceGetsTrade-off
@mainWhatever is on the branch nowNever for production — every push changes your workflow
@v1The latest v1 releaseConvenient, gets fixes; trusts the maintainer not to break or compromise it
@v1.2.1That releaseStable; still a tag, unless it is an immutable release
@<full SHA> # v1.2.1That exact commitThe 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 for node24.
  • 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.
.github/workflows/release.yml
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