DEV Community

楊東霖
楊東霖

Posted on • Originally published at devtoolkit.cc

GitHub Actions Custom Actions: Build, Test, and Publish Reusable Workflows

GitHub Actions workflows are powerful, but as your CI/CD pipelines grow, you find yourself repeating the same logic across dozens of repositories. Custom actions solve this: they let you encapsulate reusable workflow logic into a single, versioned, shareable unit — either within your organization or published publicly to the GitHub Marketplace.

This guide covers all three types of custom actions (JavaScript, Docker, and composite), when to use each, best practices for building them with the Actions Toolkit, and how to test, version, and publish them.

The Three Types of Custom Actions

GitHub Actions supports three action types, each with different trade-offs:

  • JavaScript actions: Run directly on the runner VM using Node.js. Fast startup, no container overhead. Best for most custom actions.
  • Docker container actions: Run in a Docker container. Full control over the execution environment, but slower startup. Best when you need a specific OS or toolchain.
  • Composite actions: Combine multiple workflow steps into a single action. No code required — just YAML. Best for grouping existing actions and shell scripts.

Composite Actions: The Easiest Starting Point

Composite actions let you group workflow steps without writing any JavaScript. If you find yourself copying the same 5-step setup sequence across every repository, a composite action eliminates the duplication:

# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node, cache dependencies, and run build'
inputs:
  node-version:
    description: 'Node.js version to use'
    required: false
    default: '20'
  package-manager:
    description: 'Package manager (npm, pnpm, yarn)'
    required: false
    default: 'npm'
outputs:
  cache-hit:
    description: 'Whether the dependency cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

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

    - name: Setup pnpm
      if: inputs.package-manager == 'pnpm'
      uses: pnpm/action-setup@v3

    - name: Get cache directory
      id: cache-dir
      shell: bash
      run: |
        if [ "${{ inputs.package-manager }}" = "pnpm" ]; then
          echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
        elif [ "${{ inputs.package-manager }}" = "yarn" ]; then
          echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
        else
          echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
        fi

    - name: Cache dependencies
      id: cache
      uses: actions/cache@v4
      with:
        path: ${{ steps.cache-dir.outputs.dir }}
        key: ${{ runner.os }}-${{ inputs.package-manager }}-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ inputs.package-manager }}-

    - name: Install dependencies
      shell: bash
      run: |
        if [ "${{ inputs.package-manager }}" = "pnpm" ]; then
          pnpm install --frozen-lockfile
        elif [ "${{ inputs.package-manager }}" = "yarn" ]; then
          yarn install --frozen-lockfile
        else
          npm ci
        fi
Enter fullscreen mode Exit fullscreen mode
# Usage in any workflow
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node-project
        with:
          node-version: '20'
          package-manager: 'pnpm'
Enter fullscreen mode Exit fullscreen mode

JavaScript Actions: Full Control with TypeScript

Project Setup

mkdir my-action && cd my-action
npm init -y

npm install @actions/core @actions/github @actions/exec @actions/io
npm install -D typescript @types/node @vercel/ncc

# ncc bundles your action into a single file — no node_modules needed in the repo
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022"],
    "strict": true,
    "outDir": "./lib",
    "rootDir": "./src",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode
// package.json scripts
{
  "scripts": {
    "build": "ncc build src/index.ts -o dist --source-map --minify",
    "package": "npm run build",
    "test": "jest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Action Metadata (action.yml)

# action.yml — defines inputs, outputs, and runtime
name: 'Semantic PR Checker'
description: 'Validates PR titles follow Conventional Commits specification'
author: 'Your Name'

inputs:
  github-token:
    description: 'GitHub token for API access'
    required: true
  types:
    description: 'Comma-separated list of allowed commit types'
    required: false
    default: 'feat,fix,docs,style,refactor,test,chore,perf,ci,build,revert'
  require-scope:
    description: 'Whether a scope is required (e.g., feat(ui): ...)'
    required: false
    default: 'false'

outputs:
  valid:
    description: 'Whether the PR title is valid'
  type:
    description: 'The commit type extracted from the PR title'
  scope:
    description: 'The commit scope (if present)'

runs:
  using: node20
  main: dist/index.js
Enter fullscreen mode Exit fullscreen mode

Action Implementation

// src/index.ts
import * as core from '@actions/core';
import * as github from '@actions/github';

interface ParsedTitle {
  type: string;
  scope?: string;
  breaking: boolean;
  description: string;
}

function parsePRTitle(title: string): ParsedTitle | null {
  // Conventional Commits: type(scope)!: description
  const regex = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/;
  const match = title.match(regex);
  if (!match) return null;

  return {
    type: match[1]!,
    scope: match[2],
    breaking: match[3] === '!',
    description: match[4]!,
  };
}

async function run(): Promise<void> &#123;
  try &#123;
    const token = core.getInput('github-token', &#123; required: true &#125;);
    const allowedTypes = core.getInput('types').split(',').map(s => s.trim());
    const requireScope = core.getInput('require-scope') === 'true';

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

    // Get PR title from context or fetch it
    let prTitle: string;
    if (context.eventName === 'pull_request') &#123;
      prTitle = context.payload.pull_request?.title as string;
    &#125; else &#123;
      // Fetch from API if triggered differently
      const &#123; data: pr &#125; = await octokit.rest.pulls.get(&#123;
        ...context.repo,
        pull_number: context.payload.number as number,
      &#125;);
      prTitle = pr.title;
    &#125;

    core.info(`Checking PR title: "$&#123;prTitle&#125;"`);

    const parsed = parsePRTitle(prTitle);

    if (!parsed) &#123;
      core.setOutput('valid', 'false');
      core.setFailed(
        `PR title does not follow Conventional Commits format.\n` +
        `Expected: type(scope): description\n` +
        `Got: "$&#123;prTitle&#125;"\n` +
        `Examples: feat(auth): add OAuth2 support, fix: resolve memory leak`
      );
      return;
    &#125;

    if (!allowedTypes.includes(parsed.type)) &#123;
      core.setOutput('valid', 'false');
      core.setFailed(
        `PR type "$&#123;parsed.type&#125;" is not allowed.\n` +
        `Allowed types: $&#123;allowedTypes.join(', ')&#125;`
      );
      return;
    &#125;

    if (requireScope && !parsed.scope) &#123;
      core.setOutput('valid', 'false');
      core.setFailed(`PR title must include a scope: $&#123;parsed.type&#125;(scope): description`);
      return;
    &#125;

    // All checks passed
    core.setOutput('valid', 'true');
    core.setOutput('type', parsed.type);
    if (parsed.scope) core.setOutput('scope', parsed.scope);

    if (parsed.breaking) &#123;
      core.warning('This PR introduces a breaking change (!)');
    &#125;

    core.info(`✅ PR title is valid: type=$&#123;parsed.type&#125;$&#123;parsed.scope ? `, scope=$&#123;parsed.scope&#125;` : ''&#125;`);

  &#125; catch (error) &#123;
    core.setFailed(error instanceof Error ? error.message : String(error));
  &#125;
&#125;

run();
Enter fullscreen mode Exit fullscreen mode

Working with the GitHub API

// Common patterns with @actions/github
const octokit = github.getOctokit(core.getInput('github-token'));
const &#123; owner, repo &#125; = github.context.repo;

// Comment on a PR
await octokit.rest.issues.createComment(&#123;
  owner, repo,
  issue_number: github.context.payload.pull_request?.number!,
  body: '## Analysis Results\n\nAll checks passed ✅',
&#125;);

// Add labels to a PR
await octokit.rest.issues.addLabels(&#123;
  owner, repo,
  issue_number: github.context.payload.pull_request?.number!,
  labels: ['ready-for-review'],
&#125;);

// List changed files in a PR
const &#123; data: files &#125; = await octokit.rest.pulls.listFiles(&#123;
  owner, repo,
  pull_number: github.context.payload.pull_request?.number!,
  per_page: 100,
&#125;);
const changedFiles = files.map(f => f.filename);
Enter fullscreen mode Exit fullscreen mode

Testing Your Action

Unit Testing with Jest

// src/__tests__/index.test.ts
jest.mock('@actions/core');
jest.mock('@actions/github');

import * as core from '@actions/core';
import &#123; parsePRTitle &#125; from '../index'; // Export for testing

describe('parsePRTitle', () => &#123;
  it('parses standard conventional commit', () => &#123;
    const result = parsePRTitle('feat(auth): add OAuth2 support');
    expect(result).toEqual(&#123;
      type: 'feat',
      scope: 'auth',
      breaking: false,
      description: 'add OAuth2 support',
    &#125;);
  &#125;);

  it('parses breaking change', () => &#123;
    const result = parsePRTitle('feat!: remove deprecated API');
    expect(result).toEqual(&#123;
      type: 'feat',
      scope: undefined,
      breaking: true,
      description: 'remove deprecated API',
    &#125;);
  &#125;);

  it('returns null for invalid format', () => &#123;
    expect(parsePRTitle('update some stuff')).toBeNull();
    expect(parsePRTitle('WIP: in progress')).toBeNull();
  &#125;);
&#125;);
Enter fullscreen mode Exit fullscreen mode

Integration Testing with act

act is a CLI tool that runs GitHub Actions locally using Docker:

# Install act
brew install act  # macOS
# Or: https://github.com/nektos/act

# Create a test workflow
# .github/workflows/test-action.yml
name: Test Action
on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./  # Uses the action in the current directory
        with:
          github-token: $&#123;&#123; secrets.GITHUB_TOKEN &#125;&#125;

# Run locally
act push -s GITHUB_TOKEN=your_token
Enter fullscreen mode Exit fullscreen mode

Versioning and Publishing

Semantic Versioning for Actions

# Tag your releases
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

# Also create/update a major version tag (allows users to pin to v1)
git tag -fa v1 -m "Update v1 tag"
git push origin v1 --force

# Users can reference any of these:
uses: your-org/your-action@v1       # Latest v1.x (recommended for users)
uses: your-org/your-action@v1.2.3   # Exact version (most stable)
uses: your-org/your-action@main     # Latest (risky — never for production)
Enter fullscreen mode Exit fullscreen mode

Release Workflow

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Build
        run: npm run package

      - name: Verify dist is up to date
        run: |
          git diff --exit-code dist/ || (echo "dist/ is not up to date. Run 'npm run package' and commit." && exit 1)

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true
          files: |
            dist/index.js
            action.yml
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

  • Pin third-party actions to full commit SHA: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 instead of @v4. Prevents supply chain attacks via tag mutation.
  • Set minimum permissions: Request only the permissions your action needs in the workflow's permissions block.
  • Never log secrets: core.setSecret() masks values in logs, but avoid logging anything that might contain sensitive data at all.
  • Validate all inputs: Inputs come from workflow files and could be influenced by untrusted sources (PR titles, issue bodies). Validate and sanitize before use in shell commands.
// Always validate inputs before using in shell commands
const branchName = core.getInput('branch-name');
if (!/^[a-zA-Z0-9_/-]+$/.test(branchName)) &#123;
  core.setFailed(`Invalid branch name: $&#123;branchName&#125;`);
  return;
&#125;

// Use @actions/exec instead of string interpolation in shell
import * as exec from '@actions/exec';
await exec.exec('git', ['checkout', '-b', branchName]); // Safe
// NOT: await exec.exec(`git checkout -b $&#123;branchName&#125;`); // Injection risk
Enter fullscreen mode Exit fullscreen mode

Publishing to the GitHub Marketplace

To list your action in the Marketplace, add branding to your action.yml:

# action.yml — add branding block
branding:
  icon: 'check-circle'   # Feather icon name
  color: 'green'         # Background color
Enter fullscreen mode Exit fullscreen mode

Then create a GitHub Release with a version tag. GitHub automatically prompts you to publish to the Marketplace when you create a release from a repository containing a top-level action.yml.

Conclusion

Custom GitHub Actions are one of the highest-leverage investments in your CI/CD infrastructure. A well-built action can save hours of workflow duplication across dozens of repositories and enforce organizational standards automatically.

  • Start with composite actions for grouping existing actions and shell scripts — no code required
  • Use JavaScript actions with TypeScript for complex logic, API calls, and reusable behavior
  • Reserve Docker actions for when you need a specific toolchain or OS environment
  • Always bundle with ncc, pin your dependencies, and test before publishing

For more on CI/CD automation, see our guide on GitHub Actions vs GitLab CI and the best CI/CD pipeline for small teams.

Free Developer Tools

If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.

Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder

🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.

Top comments (0)