Skip to content
Codeloom
CI/CD

Building Custom GitHub Actions: JavaScript, Docker & Composite

Create your own GitHub Actions from scratch using JavaScript, Docker containers, and composite steps. Includes publishing to the marketplace.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • The three types of GitHub Actions and when to use each
  • Building a JavaScript action with inputs, outputs, and error handling
  • Creating a Docker action for language-agnostic tooling
  • Composing existing actions into reusable composite actions
  • Testing and publishing your action to the GitHub Marketplace

Prerequisites

  • Basic GitHub Actions workflow experience
  • JavaScript or Docker fundamentals

When the 20,000+ actions on the GitHub Marketplace do not cover your use case, you can build your own. Custom actions encapsulate reusable logic that any workflow can call with a simple uses: reference. GitHub supports three types: JavaScript actions, Docker container actions, and composite actions. Each serves different needs.

When to Build a Custom Action

Build a custom action when you find yourself copying the same multi-step logic across workflows or repositories. Common examples include:

  • Posting deployment notifications to Slack with your org’s specific format
  • Running a proprietary linting or compliance tool
  • Parsing test results and commenting on pull requests
  • Interacting with internal APIs that no public action supports

JavaScript Actions

JavaScript actions run directly on the runner without a container, making them the fastest option. They have access to the GitHub Actions toolkit packages.

Project Structure

my-action/
  action.yml
  index.js
  package.json
  node_modules/   # Must be committed or use ncc to bundle

action.yml

Every action needs a metadata file:

# action.yml
name: 'PR Size Labeler'
description: 'Adds size labels to pull requests based on lines changed'
author: 'your-org'

inputs:
  github-token:
    description: 'GitHub token for API access'
    required: true
  small-threshold:
    description: 'Max lines for small label'
    required: false
    default: '50'
  medium-threshold:
    description: 'Max lines for medium label'
    required: false
    default: '200'

outputs:
  label:
    description: 'The size label that was applied'
  lines-changed:
    description: 'Total lines changed in the PR'

runs:
  using: 'node20'
  main: 'dist/index.js'

branding:
  icon: 'tag'
  color: 'blue'

Implementation

Install the toolkit packages:

npm init -y
npm install @actions/core @actions/github

Write the action logic:

// index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    const token = core.getInput('github-token', { required: true });
    const smallThreshold = parseInt(core.getInput('small-threshold'));
    const mediumThreshold = parseInt(core.getInput('medium-threshold'));

    const octokit = github.getOctokit(token);
    const { context } = github;

    if (!context.payload.pull_request) {
      core.setFailed('This action only works on pull request events');
      return;
    }

    const { data: pr } = await octokit.rest.pulls.get({
      owner: context.repo.owner,
      repo: context.repo.repo,
      pull_number: context.payload.pull_request.number,
    });

    const linesChanged = pr.additions + pr.deletions;
    let label;

    if (linesChanged <= smallThreshold) {
      label = 'size/S';
    } else if (linesChanged <= mediumThreshold) {
      label = 'size/M';
    } else {
      label = 'size/L';
    }

    // Remove existing size labels
    const existingLabels = pr.labels
      .filter(l => l.name.startsWith('size/'))
      .map(l => l.name);

    for (const existingLabel of existingLabels) {
      await octokit.rest.issues.removeLabel({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: pr.number,
        name: existingLabel,
      });
    }

    // Add the new label
    await octokit.rest.issues.addLabels({
      owner: context.repo.owner,
      repo: context.repo.repo,
      issue_number: pr.number,
      labels: [label],
    });

    core.setOutput('label', label);
    core.setOutput('lines-changed', linesChanged.toString());
    core.info(`Applied label "${label}" (${linesChanged} lines changed)`);
  } catch (error) {
    core.setFailed(`Action failed: ${error.message}`);
  }
}

run();

Bundling with ncc

Instead of committing node_modules, bundle everything into a single file:

npm install -D @vercel/ncc
npx ncc build index.js -o dist

This creates dist/index.js with all dependencies inlined. Commit the dist directory.

Using Your JavaScript Action

# In any workflow
jobs:
  label-pr:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    permissions:
      pull-requests: write
    steps:
      - uses: your-org/pr-size-labeler@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          small-threshold: '100'

Docker Actions

Docker actions run inside a container, so they can use any language or tool. They are slower to start (container build + pull overhead) but provide complete environment control.

Project Structure

docker-action/
  action.yml
  Dockerfile
  entrypoint.sh

action.yml for Docker

name: 'Database Migration Check'
description: 'Validates SQL migration files for common issues'
inputs:
  migrations-path:
    description: 'Path to migration files'
    required: false
    default: './migrations'
  dialect:
    description: 'SQL dialect (postgres, mysql, sqlite)'
    required: false
    default: 'postgres'
outputs:
  issues-found:
    description: 'Number of issues detected'

runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.migrations-path }}
    - ${{ inputs.dialect }}

Dockerfile

FROM python:3.12-slim

RUN pip install sqlfluff sqlparse

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

entrypoint Script

#!/bin/bash
set -euo pipefail

MIGRATIONS_PATH="$1"
DIALECT="$2"
ISSUES=0

echo "Checking migrations in $MIGRATIONS_PATH for $DIALECT dialect..."

for file in "$MIGRATIONS_PATH"/*.sql; do
  if [ ! -f "$file" ]; then
    echo "No migration files found"
    echo "issues-found=0" >> "$GITHUB_OUTPUT"
    exit 0
  fi

  echo "Checking: $file"

  # Check for missing transaction wrappers
  if ! grep -qi "BEGIN\|START TRANSACTION" "$file"; then
    echo "::warning file=$file::Migration is not wrapped in a transaction"
    ISSUES=$((ISSUES + 1))
  fi

  # Check for destructive operations without IF EXISTS
  if grep -qi "DROP TABLE\|DROP COLUMN" "$file" && ! grep -qi "IF EXISTS" "$file"; then
    echo "::error file=$file::Destructive operation without IF EXISTS"
    ISSUES=$((ISSUES + 1))
  fi

  # Lint SQL syntax
  sqlfluff lint "$file" --dialect "$DIALECT" --format github-annotation || true
done

echo "issues-found=$ISSUES" >> "$GITHUB_OUTPUT"

if [ "$ISSUES" -gt 0 ]; then
  echo "::warning::Found $ISSUES issue(s) in migration files"
fi

The ::warning and ::error prefixes are GitHub Actions workflow commands that create annotations on the pull request.

Using the Docker Action

jobs:
  check-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: your-org/migration-checker@v1
        id: check
        with:
          migrations-path: './db/migrations'
          dialect: 'postgres'
      - if: steps.check.outputs.issues-found != '0'
        run: echo "${{ steps.check.outputs.issues-found }} migration issues found"

Composite Actions

Composite actions combine multiple steps (including calls to other actions) into a single reusable unit. They require no build step and are the simplest type to create.

action.yml for Composite

name: 'Setup and Test Node App'
description: 'Checks out code, sets up Node, installs deps, and runs tests'
inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '22'
  test-command:
    description: 'Test command to run'
    required: false
    default: 'npm test'
outputs:
  coverage-percent:
    description: 'Code coverage percentage'
    value: ${{ steps.coverage.outputs.percent }}

runs:
  using: 'composite'
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0

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

    - shell: bash
      run: npm ci

    - shell: bash
      run: npm run lint

    - shell: bash
      run: ${{ inputs.test-command }}

    - id: coverage
      shell: bash
      run: |
        COVERAGE=$(npx nyc report --reporter=text-summary 2>/dev/null | grep 'Statements' | awk '{print $3}' | tr -d '%')
        echo "percent=${COVERAGE:-unknown}" >> "$GITHUB_OUTPUT"

Key differences from other action types:

  • Every run step must specify shell.
  • The using value is composite, not node20 or docker.
  • Steps can mix uses (calling other actions) and run (shell commands).

Using the Composite Action

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: your-org/setup-and-test@v1
        id: test
        with:
          node-version: '22'
          test-command: 'npm run test:ci'
      - run: echo "Coverage is ${{ steps.test.outputs.coverage-percent }}%"

Testing Your Action

Local Testing with act

The act tool runs GitHub Actions locally:

# Install act
brew install act

# Run a workflow that uses your action
act pull_request -W .github/workflows/test.yml

CI Testing

Create a test workflow in your action’s repository:

# .github/workflows/test-action.yml
name: Test Action

on:
  push:
  pull_request:

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

      # Test the action from the current commit
      - uses: ./
        id: action-test
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Verify outputs
        run: |
          if [ -z "${{ steps.action-test.outputs.label }}" ]; then
            echo "Expected label output to be set"
            exit 1
          fi

Publishing to the Marketplace

  1. Ensure your action.yml has name, description, author, and branding fields.
  2. Create a GitHub release with a semver tag.
  3. On the release page, check “Publish this Action to the GitHub Marketplace.”
  4. Create a moving major version tag:
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

# Create/update the v1 major version tag
git tag -f v1 v1.0.0
git push --force origin v1

Consumers reference @v1 and automatically get patch and minor updates when you move the tag forward.

Choosing the Right Type

TypeStartupLanguageUse Case
JavaScriptFastJavaScript/TypeScriptAPI interactions, label management, notifications
DockerSlowAny languageCustom tools, complex dependencies, binary requirements
CompositeFastShell + other actionsCombining existing actions, standardizing workflows

Start with composite actions for simple orchestration. Use JavaScript for anything needing GitHub API interaction. Use Docker when you need a specific runtime or system-level tools.

Summary

Custom GitHub Actions let you package reusable CI/CD logic that any repository can consume with a single uses line. JavaScript actions are fast and ideal for GitHub API work. Docker actions provide full environment control at the cost of startup time. Composite actions combine existing steps without any build process. Test locally with act, test in CI with self-referencing workflows, and publish with semver tags for stable consumption across your organization.