GitHub Actions Custom Actions

Custom actions let you package a group of steps into a single reusable unit. When the same set of steps appears in multiple workflows or repositories, a custom action eliminates the duplication. GitHub supports three types of custom actions: composite, JavaScript, and Docker-based.

When to Build a Custom Action

Use a custom action when:
  ✓ The same steps repeat across multiple workflows
  ✓ You want to share workflow logic across repositories
  ✓ A task is too complex for a single run step
  ✓ You want to publish functionality to the GitHub Marketplace

Stick with inline steps when:
  ✓ The logic is specific to one workflow
  ✓ The task is a simple shell command
  ✓ A Marketplace action already exists for the task

Composite Actions — The Simplest Type

A composite action bundles multiple run and uses steps into one reusable action. It runs in the caller's environment using the caller's runner.

Creating a Composite Action

Create a folder and an action.yml file inside it:

my-repo/
└── .github/
    └── actions/
        └── setup-and-test/
            └── action.yml
# .github/actions/setup-and-test/action.yml

name: 'Setup and Test'
description: 'Sets up Node.js, installs dependencies, and runs tests'

inputs:
  node-version:
    description: 'Node.js version to use'
    required: false
    default: '20'
  test-command:
    description: 'Command to run tests'
    required: false
    default: 'npm test'

outputs:
  test-result:
    description: 'Whether tests passed'
    value: ${{ steps.run-tests.outputs.result }}

runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      run: npm ci
      shell: bash

    - name: Run tests
      id: run-tests
      run: |
        if ${{ inputs.test-command }}; then
          echo "result=pass" >> $GITHUB_OUTPUT
        else
          echo "result=fail" >> $GITHUB_OUTPUT
        fi
      shell: bash

Key rules for composite actions:

  • Every run step must specify a shell explicitly
  • Use ${{ inputs.INPUT_NAME }} to access inputs
  • Write outputs to $GITHUB_OUTPUT just like in regular steps

Using the Composite Action

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run setup and tests
        id: tests
        uses: ./.github/actions/setup-and-test
        with:
          node-version: '20'
          test-command: 'npm run test:ci'

      - name: Show test result
        run: echo "Tests: ${{ steps.tests.outputs.test-result }}"

JavaScript Actions — For Dynamic Logic

A JavaScript action runs Node.js code directly on the runner. This gives you access to the full GitHub API through the official @actions/core and @actions/github packages — much more powerful than shell scripts.

my-action/
├── action.yml
├── index.js
└── node_modules/   (must be committed)
# action.yml
name: 'My JS Action'
description: 'Does something with the GitHub API'
inputs:
  message:
    description: 'Message to post'
    required: true
outputs:
  issue-url:
    description: 'URL of the created issue'
runs:
  using: 'node20'
  main: 'index.js'
// index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    const message = core.getInput('message');
    const token = core.getInput('github-token');
    const octokit = github.getOctokit(token);
    const context = github.context;

    const response = await octokit.rest.issues.create({
      owner: context.repo.owner,
      repo: context.repo.repo,
      title: message,
      body: `Created automatically by GitHub Actions run #${context.runNumber}`
    });

    core.setOutput('issue-url', response.data.html_url);
    core.info(`Issue created: ${response.data.html_url}`);
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

Docker Actions — For Any Language

A Docker action runs inside a container. Use this when your action requires specific system tools or a language other than JavaScript.

# action.yml
name: 'Python Analysis Action'
description: 'Runs a Python script for analysis'
runs:
  using: 'docker'
  image: 'Dockerfile'
# Dockerfile
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY analyze.py .
ENTRYPOINT ["python", "/analyze.py"]

Composite vs JavaScript vs Docker

Type         | Language    | Speed   | Best for
-------------|-------------|---------|-------------------------------
Composite    | Shell/YAML  | Fastest | Grouping existing steps
JavaScript   | Node.js     | Fast    | GitHub API interactions
Docker       | Any         | Slower  | Non-JS languages, system tools

Publishing an Action to the Marketplace

Any public repository with an action.yml at the root level can be listed on the GitHub Marketplace:

  1. Move action.yml to the repository root
  2. Go to the repository on GitHub
  3. Click Draft a release
  4. Check Publish this Action to the GitHub Marketplace
  5. Fill in the category and description
  6. Publish the release

After publishing, users can discover and use your action using the standard owner/repo@version format.

Leave a Comment

Your email address will not be published. Required fields are marked *