my blog

$ cat github-actions/part-05.md

Building Custom Actions in GitHub Actions: Composite, JavaScript & Docker

After covering the fundamentals, now I want to share how to create your own reusable actions. This is where GitHub Actions becomes really powerful you can package your logic once and use it everywhere.

GitHub Actions supports three types of custom actions. I built all three in my home lab, and I’ll show you exactly how they work.

Why Create Custom Actions?

Before diving into code, here’s why I started building custom actions:

  1. DRY principle: Stop copying the same steps across workflows
  2. Encapsulation: Hide complexity behind simple interfaces
  3. Reusability: Use the same action in multiple projects
  4. Versioning: Update action logic without changing workflows

Type 1: Composite Actions

Composite actions combine multiple steps into one reusable action. Think of it as a function that wraps several workflow steps.

Here’s my composite action that sets up Node.js and caches dependencies:

File: actions/composite-cache-deps/action.yaml

name: Cache Node and NPM Dependencies
description: This action allows to cache both Node and NPM dependencies based on the package-lock.json file.

inputs:
  node_version:
    description: The Node.js version to use
    required: true
    default: '20.x'
  working-dir:
    description: The working directory where package-lock.json is located
    required: false
    default: '.'

runs:
  using: composite
  steps:
  - name: Setup node js
    uses: actions/setup-node@v4
    with:
      node-version: ${{ inputs.node_version }}
  
  - name: Cache NPM dependencies
    uses: actions/cache@v4
    with:
      path: ${{inputs.working-dir}}/node_modules
      key: node-modules-${{ inputs.target-env }}-${{ hashFiles(format('{0}/{1}', inputs.working-dir, 'package-lock.json')) }}
  
  - name: Install NPM dependencies
    if: steps.cache.outputs.cache-hit != 'true'
    run: ${{ inputs.target-env == 'dev' && 'npm install' || 'npm install --omit=dev' }}
    shell: bash
    working-directory: ${{ inputs.working-dir }}

How It Works

1. Define inputs:

inputs:
  node_version:
    description: The Node.js version to use
    required: true
    default: '20.x'

These work like function parameters. Users pass values when they use your action.

2. Set the action type:

runs:
  using: composite

The composite type tells GitHub this action contains multiple steps.

3. Write your steps:

steps:
  - name: Setup node js
    uses: actions/setup-node@v4
    with:
      node-version: ${{ inputs.node_version }}

Access inputs with ${{ inputs.INPUT_NAME }}.

Important: In composite actions, every run step MUST specify shell:

- name: Install NPM dependencies
  run: npm install
  shell: bash  # This is required!

Using the Composite Action

Here’s my workflow that uses this action:

File: workflows/17-1-custom-actions-composite.yaml

name: 17 - 1 - Custom Actions - Composite
run-name: 17 - 1 - Custom Actions - Composite | env - ${{ inputs.target-env }}

on:
  workflow_dispatch:
    inputs:
      target-env:
        description: Which environment to target for dependency installation
        type: choice
        options:
          - dev
          - prod

env:
  working-directory: react-app

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ env.working-directory }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node and NPM Dependencies
        id: setup-deps
        uses: ./.github/actions/composite-cache-deps
        with:
          node-version: 20.x
          working-dir: ${{ env.working-directory }}
          target-env: ${{ inputs.target-env }}
      
      - name: Print setup deps output
        run: |
          echo "Installed dependencies: ${{ steps.setup-deps.outputs.installed-deps }}"
      
      - name: Test
        run: npm run test
      
      - name: Build
        run: npm run build

Key points:

The path uses: ./.github/actions/composite-cache-deps tells GitHub to use a local action from my repo.

I can pass inputs just like with marketplace actions:

with:
  node-version: 20.x
  working-dir: ${{ env.working-directory }}

This action runs 3 steps (setup Node, cache, install) but my workflow only shows 1 step.

Type 2: JavaScript Actions

JavaScript actions run Node.js code. They’re more powerful than composite actions because you can write complex logic, make API calls, and interact with GitHub.

I built a JavaScript action that checks for dependency updates and creates a pull request automatically.

File: actions/js-dependency-update/action.yaml

name: Update NPM Dependencies
description: Checks if there are updates to NPM packages, and creates a PR with the updated package*.json files.

runs:
  using: "node20"
  main: "index.js"

Much simpler! Just tell GitHub:

  • using: “node20” – Run with Node.js 20
  • main: “index.js” – This is the entry point

File: actions/js-dependency-update/index.js

const core = require('@actions/core');
const exec = require('@actions/exec');
const github = require('@actions/github');

const setupGit = async () => {
  await exec.exec(`git config --global user.name "gh-automation"`);
  await exec.exec(`git config --global user.email "gh-automation@email.com"`);
};

const validateBranchName = ({ branchName }) =>
  /^[a-zA-Z0-9_\-\.\/]+$/.test(branchName);

const validateDirectoryName = ({ dirName }) =>
  /^[a-zA-Z0-9_\-\/]+$/.test(dirName);

const setupLogger = ({ debug, prefix } = { debug: false, prefix: '' }) => ({
  debug: (message) => {
    if (debug) {
      core.info(`DEBUG ${prefix}${prefix ? ' : ' : ''}${message}`);
    }
  },
  info: (message) => {
    core.info(`${prefix}${prefix ? ' : ' : ''}${message}`);
  },
  error: (message) => {
    core.error(`${prefix}${prefix ? ' : ' : ''}${message}`);
  },
});

async function run() {
  const baseBranch = core.getInput('base-branch', { required: true });
  const headBranch = core.getInput('head-branch', { required: true });
  const ghToken = core.getInput('gh-token', { required: true });
  const workingDir = core.getInput('working-directory', { required: true });
  const debug = core.getBooleanInput('debug');
  const logger = setupLogger({ debug, prefix: '[js-dependency-update]' });

  const commonExecOpts = {
    cwd: workingDir,
  };
  
  core.setSecret(ghToken);

  logger.debug('Validating inputs base-branch, head-branch, working-directory');

  if (!validateBranchName({ branchName: baseBranch })) {
    core.setFailed(
      'Invalid base-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'
    );
    return;
  }

  if (!validateBranchName({ branchName: headBranch })) {
    core.setFailed(
      'Invalid head-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'
    );
    return;
  }

  if (!validateDirectoryName({ dirName: workingDir })) {
    core.setFailed(
      'Invalid working directory name. Directory names should include only characters, numbers, hyphens, underscores, and forward slashes.'
    );
    return;
  }

  logger.debug(`Base branch is ${baseBranch}`);
  logger.debug(`Head branch is ${headBranch}`);
  logger.debug(`Working directory is ${workingDir}`);

  logger.debug('Checking for package updates');
  await exec.exec('npm update', [], {
    ...commonExecOpts,
  });

  const gitStatus = await exec.getExecOutput(
    'git status -s package*.json',
    [],
    {
      ...commonExecOpts,
    }
  );

  let updatesAvailable = false;

  if (gitStatus.stdout.length > 0) {
    updatesAvailable = true;

    logger.debug('There are updates available!');
    logger.debug('Setting up git');
    await setupGit();

    logger.debug('Committing and pushing package*.json changes');
    await exec.exec(`git checkout -b ${headBranch}`, [], {
      ...commonExecOpts,
    });
    await exec.exec(`git add package.json package-lock.json`, [], {
      ...commonExecOpts,
    });
    await exec.exec(`git commit -m "chore: update dependencies`, [], {
      ...commonExecOpts,
    });
    await exec.exec(`git push -u origin ${headBranch} --force`, [], {
      ...commonExecOpts,
    });

    logger.debug('Fetching octokit API');
    const octokit = github.getOctokit(ghToken);

    try {
      logger.debug(`Creating PR using head branch ${headBranch}`);

      await octokit.rest.pulls.create({
        owner: github.context.repo.owner,
        repo: github.context.repo.repo,
        title: `Update NPM dependencies`,
        body: `This pull request updates NPM packages`,
        base: baseBranch,
        head: headBranch,
      });
    } catch (e) {
      logger.error(
        'Something went wrong while creating the PR. Check logs below.'
      );
      core.setFailed(e.message);
      logger.error(e);
    }
  } else {
    logger.info('No updates at this point in time.');
  }

  logger.debug(`Setting updates-available output to ${updatesAvailable}`);
  core.setOutput('updates-available', updatesAvailable);
}

run();

Understanding the JavaScript Action

1. Using @actions toolkit:

const core = require('@actions/core');
const exec = require('@actions/exec');
const github = require('@actions/github');

GitHub provides these packages for common tasks:

  • @actions/core – Get inputs, set outputs, log messages
  • @actions/exec – Run shell commands
  • @actions/github – Interact with GitHub API

2. Getting inputs:

const baseBranch = core.getInput('base-branch', { required: true });
const workingDir = core.getInput('working-directory', { required: true });
const debug = core.getBooleanInput('debug');

3. Setting outputs:

core.setOutput('updates-available', updatesAvailable);

4. Marking secrets:

core.setSecret(ghToken);

This masks the token in logs so it never appears in plain text.

5. Running commands:

await exec.exec('npm update', [], {
  cwd: workingDir,
});

6. Using GitHub API:

const octokit = github.getOctokit(ghToken);

await octokit.rest.pulls.create({
  owner: github.context.repo.owner,
  repo: github.context.repo.repo,
  title: `Update NPM dependencies`,
  body: `This pull request updates NPM packages`,
  base: baseBranch,
  head: headBranch,
});

This creates a pull request programmatically!

File: actions/js-dependency-update/package.json

{
  "name": "js-dependency-update",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "@actions/core": "^1.11.1",
    "@actions/exec": "^1.1.1",
    "@actions/github": "^6.0.1"
  }
}

Using the JavaScript Action

File: workflows/17-2-custom-actions-js.yaml

name: 17 - 2 - Custom Actions - JS
run-name: 17 - 2 - Custom Actions - JS | BB - ${{ inputs.base-branch }} TB - ${{ inputs.target-branch }} WD - ${{ inputs.working-dir }}

on:
  workflow_dispatch:
    inputs:
      base-branch:
        description: 'Base branch for the dependency update'
        type: string
        default: main
      target-branch:
        description: 'Target branch for the dependency update'
        type: string
        default: update-dependencies
      working-dir:
        description: 'Working directory for the dependency update'
        type: string
        default: 17-custom-actions/react-app
      debug:
        description: 'Enable debug mode for the dependency update'
        type: boolean
        default: false

permissions:
  contents: write
  pull-requests: write

jobs:
  dependency-update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Check for dependency updates
        id: update-deps
        uses: ./.github/actions/js-dependency-update
        with:
          base-branch: ${{ inputs.base-branch }}
          head-branch: ${{ inputs.target-branch }}
          working-directory: ${{ inputs.working-dir }}
          gh-token: ${{ secrets.GITHUB_TOKEN }}
          debug: ${{ inputs.debug }}
      
      - name: Print custom action output
        run: |
          echo "Updates available: ${{ steps.update-deps.outputs.updates-available }}"

Important: Notice the permissions section:

permissions:
  contents: write
  pull-requests: write

JavaScript actions that interact with GitHub need explicit permissions.

Type 3: Docker Actions

Docker actions run in containers. They’re perfect when you need specific tools or languages that aren’t available in GitHub runners.

I built a Docker action that pings a URL to check if it’s reachable.

File: actions/docker-ping-url/action.yaml

name: Ping URL
description: Ping URL until maximum trials have exceeded. If result is not 200 until then, fails the action.

inputs:
  url:
    description: URL to ping.
    required: true
  max_trials:
    description: Maximum number of trials until action fails.
    default: '10'
    required: false
  delay:
    description: Delay in seconds between trials.
    default: '5'
    required: false

outputs:
  url-reachable:
    description: Whether the URL is reachable

runs:
  using: docker
  image: Dockerfile

Instead of composite or node20, we use docker and point to a Dockerfile.

File: actions/docker-ping-url/Dockerfile

FROM python:alpine3.19

WORKDIR /app

COPY requirements.txt .

RUN python -V
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD [ "python", "/app/main.py" ]

This creates a Python environment with the dependencies I need.

File: actions/docker-ping-url/requirements.txt

certifi==2023.11.17
charset-normalizer==3.3.2
idna==3.6
requests==2.31.0
urllib3==2.1.0

File: actions/docker-ping-url/main.py

import os
import requests
import time


def set_output(file_path, key, value):
    with open(file_path, 'a') as file:
        print(f'{key}={value}', file=file)


def ping_url(url, delay, max_trials):
    trials = 0

    while trials < max_trials:
        try:
            response = requests.get(url)
            if response.status_code == 200:
                print(f"Website {url} is reachable.")
                return True
        except requests.ConnectionError:
            print(f"Website {url} is unreachable. Retrying in {delay} seconds...")
            time.sleep(delay)
            trials += 1
        except requests.exceptions.MissingSchema:
            print(f"Invalid URL format: {url}. Make sure the URL has a valid schema (e.g., http:// or https://)")
            return False
    
    return False


def run():
    website_url = os.getenv("INPUT_URL")
    delay = int(os.getenv("INPUT_DELAY"))
    max_trials = int(os.getenv("INPUT_MAX_TRIALS"))

    website_reachable = ping_url(website_url, delay, max_trials)

    set_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)
    
    if not website_reachable:
        raise Exception(f"Website {website_url} is malformed or unreachable.")

    print(f"Website {website_url} is reachable.")


if __name__ == "__main__":
    run()

Understanding Docker Actions

1. Input variables become environment variables:

GitHub automatically converts inputs to environment variables with INPUT_ prefix:

  • url input → INPUT_URL environment variable
  • max_trials → INPUT_MAX_TRIALS
  • delay → INPUT_DELAY
website_url = os.getenv("INPUT_URL")
delay = int(os.getenv("INPUT_DELAY"))

2. Setting outputs:

def set_output(file_path, key, value):
    with open(file_path, 'a') as file:
        print(f'{key}={value}', file=file)

set_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)

Same concept as JavaScript – write to $GITHUB_OUTPUT file.

3. The .dockerignore file:

File: actions/docker-ping-url/.dockerignore

venv

This prevents copying unnecessary files into the Docker image.

Using the Docker Action

File: workflows/17-3-custom-actions-docker.yaml

name: 17 - 3 - Custom Actions - Docker

on:
  workflow_dispatch:
    inputs:
      url:
        description: URL to ping for reachability test.
        type: choice
        options:
          - 'https://www.google.com'
          - 'https://www.invalid-aidfghaidgfiazgd.com'
          - 'invalid-aidfghaidgfiazgd'
      max_trials:
        description: Maximum trials until action fails.
        default: '10'
        required: false
      delay:
        description: Delay in seconds between trials.
        default: '5'
        required: false

jobs:
  ping-url:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Ping URL
        id: ping-url
        uses: ./.github/actions/docker-ping-url
        with:
          url: ${{ inputs.url }}
          max_trials: ${{ inputs.max_trials }}
          delay: ${{ inputs.delay }}
      
      - name: Print output from ping url
        run: |
          echo "URL reachable: ${{ steps.ping-url.outputs.url-reachable }}"

I can test with valid and invalid URLs to see how the action behaves.

Comparing the Three Types

FeatureCompositeJavaScriptDocker
LanguageYAML (workflow steps)JavaScript (Node.js)Any language
ComplexitySimpleMediumComplex
SpeedFastFastSlow (builds image)
DependenciesActions marketplace onlynpm packagesAnything in Docker
Best forCombining existing stepsGitHub API interactionCustom tools/languages
Shell required?Yes (in run steps)NoN/A

When to Use Each Type

Use Composite when:

  • You’re combining existing actions/steps
  • You don’t need custom logic
  • Speed is important
  • Example: Setup Node + Cache + Install dependencies

Use JavaScript when:

  • You need to interact with GitHub API
  • You want complex conditional logic
  • You need to process data
  • Example: Create PRs, manage issues, analyze workflows

Use Docker when:

  • You need specific tools not in GitHub runners
  • You’re using a language other than JavaScript
  • You need exact dependency versions
  • Example: Python scripts, Ruby gems, custom binaries

What I Learned Building These Actions

  1. Composite actions must specify shell: Every run: step needs shell: bash or shell: pwsh.
  2. JavaScript actions need node_modules: You must commit node_modules/ or users can’t run your action. Add this to .gitignore exception:
  3. Docker actions are slow: Building the Docker image takes time. First run can take 2-3 minutes. Subsequent runs are faster if image is cached.
  4. Inputs are always strings: Even if you pass a boolean or number, it arrives as a string. Convert it:
  5. Use core.setSecret(): Mask sensitive data in logs:
  6. Validate inputs early: Check inputs before doing work. My JavaScript action validates branch names and directory paths at the start.
  7. Outputs are optional: Not every action needs outputs. My composite action doesn’t have any because caching happens silently.
  8. Test locally first: For JavaScript actions, run node index.js with environment variables set. For Docker actions, build and run the image locally.
  9. Working directory matters: Composite actions inherit working directory from the job, but you can override it per step. JavaScript and Docker actions always run from the action directory.
  10. Error handling is critical: Always handle failures gracefully:

Real-World Example: Combining Custom Actions

Here’s how I use all three in one workflow:

name: Complete CI/CD with Custom Actions

on:
  push:
    branches: [main]

jobs:
  setup-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      # Composite action
      - name: Setup dependencies
        uses: ./.github/actions/composite-cache-deps
        with:
          node-version: 20.x
          working-dir: ./app
      
      - name: Test
        run: npm test
        working-directory: ./app

  check-dependencies:
    runs-on: ubuntu-latest
    needs: setup-and-test
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      
      # JavaScript action
      - name: Update dependencies
        uses: ./.github/actions/js-dependency-update
        with:
          base-branch: main
          head-branch: update-deps
          working-directory: ./app
          gh-token: ${{ secrets.GITHUB_TOKEN }}

  deploy-and-verify:
    runs-on: ubuntu-latest
    needs: setup-and-test
    steps:
      - uses: actions/checkout@v4
      
      - name: Deploy application
        run: echo "Deploying..."
      
      # Docker action
      - name: Verify deployment
        uses: ./.github/actions/docker-ping-url
        with:
          url: https://my-app.example.com
          max_trials: 15
          delay: 10

This workflow:

  1. Uses composite action to cache and install dependencies
  2. Uses JavaScript action to check for updates and create PR
  3. Uses Docker action to verify deployment health

$ ls -t root-access/ | head -3

Hello world! uncategorized 1 min Building a Job Board API, Part 1: The Foundation job-board-api · 1/1 16 min Part 13 – Kubernetes Autoscaling in Action: A FastAPI Microservice That Scales Itself home-lab · 13/13 6 min

Leave a comment

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