Skip to main content

Custom Actions Development

Learn to build powerful, reusable GitHub Actions that can be shared across teams and organizations.

Action Types Overview

GitHub Actions supports three main types of custom actions, each with specific use cases and implementation approaches.

Action Types Comparison

When to Use Each Type

Action TypeBest ForProsCons
JavaScriptSimple operations, API calls, file manipulationFast, lightweight, easy to developLimited to Node.js ecosystem
DockerComplex environments, specific tools, system dependenciesFull control, any runtime, isolatedSlower startup, larger size
CompositeCombining multiple steps, workflow compositionReusable, flexible, maintainableLimited to GitHub-hosted runners

JavaScript Actions

JavaScript actions are the most common type of custom actions, running directly on GitHub's Node.js runtime.

Basic JavaScript Action Structure

Action Metadata (action.yml)

name: 'Custom JavaScript Action'
description: 'A custom action built with JavaScript'
author: 'Your Name'

inputs:
name:
description: 'Name to greet'
required: true
default: 'World'
title:
description: 'Title to use'
required: false
default: 'Hello'

outputs:
greeting:
description: 'The generated greeting'
value: ${{ steps.greeting.outputs.message }}

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

Action Implementation (src/index.js)

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

async function run() {
try {
// Get inputs
const name = core.getInput('name', { required: true });
const title = core.getInput('title', { required: false });

// Validate inputs
if (!name || name.trim() === '') {
throw new Error('Name cannot be empty');
}

// Process logic
const greeting = `${title} ${name}!`;

// Set outputs
core.setOutput('greeting', greeting);

// Log information
core.info(`Generated greeting: ${greeting}`);

// Set success status
core.setSuccess('Action completed successfully');

} catch (error) {
core.setFailed(`Action failed: ${error.message}`);
}
}

run();

Package Configuration (package.json)

{
"name": "custom-greeting-action",
"version": "1.0.0",
"description": "A custom GitHub Action for generating greetings",
"main": "dist/index.js",
"scripts": {
"build": "ncc build src/index.js -o dist",
"test": "jest",
"lint": "eslint src/",
"package": "npm run build && npm run test"
},
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1"
},
"devDependencies": {
"@vercel/ncc": "^0.34.0",
"jest": "^29.5.0",
"eslint": "^8.42.0"
}
}

Advanced JavaScript Actions

GitHub API Integration

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

async function createIssue(title, body, labels = []) {
const token = core.getInput('github-token', { required: true });
const octokit = github.getOctokit(token);

const { owner, repo } = github.context.repo;

try {
const response = await octokit.rest.issues.create({
owner,
repo,
title,
body,
labels
});

core.info(`Created issue #${response.data.number}`);
core.setOutput('issue-number', response.data.number);
core.setOutput('issue-url', response.data.html_url);

} catch (error) {
core.setFailed(`Failed to create issue: ${error.message}`);
}
}

async function run() {
const title = core.getInput('title', { required: true });
const body = core.getInput('body', { required: true });
const labels = core.getInput('labels').split(',').map(label => label.trim());

await createIssue(title, body, labels);
}

run();

File System Operations

const core = require('@actions/core');
const fs = require('fs').promises;
const path = require('path');

async function updateVersion(filePath, newVersion) {
try {
const content = await fs.readFile(filePath, 'utf8');
const updatedContent = content.replace(
/"version":\s*"[^"]*"/,
`"version": "${newVersion}"`
);

await fs.writeFile(filePath, updatedContent);
core.info(`Updated version to ${newVersion} in ${filePath}`);

} catch (error) {
core.setFailed(`Failed to update version: ${error.message}`);
}
}

async function run() {
const filePath = core.getInput('file-path', { required: true });
const version = core.getInput('version', { required: true });

await updateVersion(filePath, version);
}

run();

Testing JavaScript Actions

Unit Tests (tests/index.test.js)

const core = require('@actions/core');
const { run } = require('../src/index');

// Mock the GitHub Actions toolkit
jest.mock('@actions/core');
jest.mock('@actions/github');

describe('Custom Action', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('should generate greeting with default title', async () => {
core.getInput.mockReturnValueOnce('John');
core.getInput.mockReturnValueOnce('');

await run();

expect(core.setOutput).toHaveBeenCalledWith('greeting', 'Hello John!');
expect(core.info).toHaveBeenCalledWith('Generated greeting: Hello John!');
});

test('should handle empty name input', async () => {
core.getInput.mockReturnValueOnce('');

await run();

expect(core.setFailed).toHaveBeenCalledWith('Action failed: Name cannot be empty');
});
});

Docker Actions

Docker actions provide complete control over the execution environment and can use any runtime or tools.

Basic Docker Action

Action Metadata (action.yml)

name: 'Custom Docker Action'
description: 'A custom action built with Docker'
author: 'Your Name'

inputs:
image:
description: 'Docker image to analyze'
required: true
output-format:
description: 'Output format for results'
required: false
default: 'json'

outputs:
vulnerabilities:
description: 'Security vulnerabilities found'
value: ${{ steps.scan.outputs.vulnerabilities }}

runs:
using: 'docker'
image: 'Dockerfile'

Dockerfile

FROM alpine:latest

# Install required tools
RUN apk add --no-cache \
docker \
curl \
jq \
bash

# Copy action script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]

Entrypoint Script (entrypoint.sh)

#!/bin/bash

set -e

# Get inputs
IMAGE="$INPUT_IMAGE"
OUTPUT_FORMAT="$INPUT_OUTPUT_FORMAT"

# Validate inputs
if [ -z "$IMAGE" ]; then
echo "Error: Image name is required"
exit 1
fi

# Perform security scan
echo "Scanning image: $IMAGE"

# Pull the image
docker pull "$IMAGE"

# Run security scan (example using Trivy)
SCAN_RESULT=$(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --format "$OUTPUT_FORMAT" "$IMAGE")

# Output results
echo "vulnerabilities<<EOF" >> "$GITHUB_OUTPUT"
echo "$SCAN_RESULT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

echo "Scan completed successfully"

Advanced Docker Actions

Multi-Stage Docker Action

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Runtime stage
FROM alpine:latest
RUN apk add --no-cache nodejs npm curl

# Copy built application
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/

# Copy action files
COPY action.yml /action.yml
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

Environment-Specific Docker Action

FROM ubuntu:20.04

# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1

# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
curl \
&& rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt /requirements.txt
RUN pip3 install -r /requirements.txt

# Copy action files
COPY . /action
WORKDIR /action

# Set entrypoint
ENTRYPOINT ["python3", "/action/main.py"]

Composite Actions

Composite actions allow you to combine multiple workflow steps into a reusable action.

Basic Composite Action

Action Metadata (action.yml)

name: 'Deploy Application'
description: 'Composite action for deploying applications'
author: 'Your Name'

inputs:
environment:
description: 'Target environment'
required: true
version:
description: 'Application version'
required: false
default: 'latest'
config-file:
description: 'Configuration file path'
required: false
default: 'deploy.yml'

outputs:
deployment-url:
description: 'URL of deployed application'
value: ${{ steps.deploy.outputs.url }}

runs:
using: 'composite'
steps:
- name: Validate inputs
shell: bash
run: |
if [ -z "${{ inputs.environment }}" ]; then
echo "Error: Environment is required"
exit 1
fi
echo "Deploying to ${{ inputs.environment }}"

- name: Build application
shell: bash
run: |
echo "Building application version ${{ inputs.version }}"
# Add your build commands here

- name: Deploy to environment
id: deploy
shell: bash
run: |
echo "Deploying to ${{ inputs.environment }}"
DEPLOY_URL="https://${{ inputs.environment }}.example.com"
echo "url=$DEPLOY_URL" >> $GITHUB_OUTPUT
echo "Deployment completed: $DEPLOY_URL"

- name: Health check
shell: bash
run: |
echo "Performing health check..."
# Add health check commands here
echo "Health check passed"

Advanced Composite Actions

Multi-Environment Deployment

name: 'Multi-Environment Deploy'
description: 'Deploy to multiple environments with validation'
author: 'Your Name'

inputs:
environments:
description: 'Comma-separated list of environments'
required: true
parallel:
description: 'Deploy to environments in parallel'
required: false
default: 'false'

runs:
using: 'composite'
steps:
- name: Parse environments
id: parse
shell: bash
run: |
IFS=',' read -ra ENVS <<< "${{ inputs.environments }}"
for env in "${ENVS[@]}"; do
echo "environment-${env}=true" >> $GITHUB_OUTPUT
done
echo "count=${#ENVS[@]}" >> $GITHUB_OUTPUT

- name: Deploy to environments
if: steps.parse.outputs.parallel == 'true'
shell: bash
run: |
echo "Parallel deployment to: ${{ inputs.environments }}"
# Implement parallel deployment logic

- name: Deploy to environments sequentially
if: steps.parse.outputs.parallel != 'true'
shell: bash
run: |
echo "Sequential deployment to: ${{ inputs.environments }}"
# Implement sequential deployment logic

Self-Hosted Runners

Self-hosted runners provide more control over the execution environment and can access private resources.

Runner Setup

Basic Runner Registration

# Download runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz

# Configure runner
./config.sh --url https://github.com/your-org/your-repo --token YOUR_TOKEN

# Install service
sudo ./svc.sh install
sudo ./svc.sh start

Docker-based Runner

FROM ubuntu:20.04

# Install dependencies
RUN apt-get update && apt-get install -y \
curl \
tar \
&& rm -rf /var/lib/apt/lists/*

# Download and install runner
RUN mkdir -p /opt/actions-runner
WORKDIR /opt/actions-runner

RUN curl -o actions-runner-linux-x64-2.311.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
RUN tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz

# Copy configuration script
COPY configure-runner.sh /configure-runner.sh
RUN chmod +x /configure-runner.sh

ENTRYPOINT ["/configure-runner.sh"]

Runner Configuration

Labels and Groups

jobs:
build:
runs-on: [self-hosted, linux, x64, docker]
steps:
- name: Build on self-hosted runner
run: echo "Building on self-hosted runner"

Environment Variables

jobs:
deploy:
runs-on: [self-hosted, production]
env:
DEPLOY_ENV: production
ACCESS_TOKEN: ${{ secrets.PRODUCTION_ACCESS_TOKEN }}
steps:
- name: Deploy to production
run: |
echo "Deploying to $DEPLOY_ENV"
# Use production-specific tools and configurations

Runner Management

Scaling with Multiple Runners

# .github/workflows/scale-runners.yml
name: Scale Self-Hosted Runners

on:
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: true
type: choice
options:
- scale-up
- scale-down
- status

jobs:
scale:
runs-on: ubuntu-latest
steps:
- name: Scale runners
run: |
case "${{ github.event.inputs.action }}" in
"scale-up")
echo "Scaling up runners..."
# Add logic to create new runner instances
;;
"scale-down")
echo "Scaling down runners..."
# Add logic to remove runner instances
;;
"status")
echo "Checking runner status..."
# Add logic to check runner status
;;
esac

Enterprise Features

Security and Compliance

Environment Protection Rules

# Environment configuration in GitHub
environments:
production:
protection_rules:
- type: required_reviewers
required_reviewers: ["team-leads"]
- type: wait_timer
wait_timer: 5
- type: prevent_self_review
enabled: true

Secrets Management

# Using environment secrets
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy with environment secrets
run: |
echo "Using production secrets"
# Secrets are automatically available from environment

Organization Actions

Shared Actions Repository

# Using organization action
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Use organization action
uses: your-org/shared-actions/build@v1
with:
node-version: '18'

Action Versioning

# Versioned action usage
steps:
- name: Use specific version
uses: your-org/actions/[email protected]

- name: Use major version
uses: your-org/actions/deploy@v2

- name: Use latest
uses: your-org/actions/deploy@main

Testing and Validation

Action Testing

Local Testing

# Test action locally
npm install
npm run build
npm test

# Test with act (local GitHub Actions runner)
act -j test

Integration Testing

# Test action in workflow
name: Test Custom Action
on:
push:
paths: ['action/**']

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

- name: Test custom action
uses: ./
with:
name: 'Test User'
title: 'Hello'

- name: Verify output
run: |
echo "Greeting: ${{ steps.test.outputs.greeting }}"

Action Validation

Input Validation

function validateInputs() {
const name = core.getInput('name');
const email = core.getInput('email');

if (!name || name.trim() === '') {
throw new Error('Name is required and cannot be empty');
}

if (email && !isValidEmail(email)) {
throw new Error('Invalid email format');
}
}

function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}

Output Validation

function validateOutputs() {
const result = core.getOutput('result');

if (!result) {
core.setFailed('Expected output "result" was not set');
return false;
}

return true;
}

Publishing Actions

GitHub Marketplace

Action Metadata for Marketplace

name: 'Advanced Security Scanner'
description: 'Comprehensive security scanning for applications'
author: 'Security Team'
branding:
icon: 'shield'
color: 'red'

inputs:
target:
description: 'Target to scan'
required: true
severity:
description: 'Minimum severity level'
required: false
default: 'medium'

Publishing Process

# Tag and release action
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0

# Create release on GitHub
gh release create v1.0.0 --title "Version 1.0.0" --notes "Initial release"

Action Distribution

Private Distribution

# Using private action
steps:
- name: Use private action
uses: your-org/private-actions/security-scan@v1
with:
target: ${{ github.repository }}

Public Distribution

# Using public marketplace action
steps:
- name: Use public action
uses: actions/checkout@v3

Key Takeaways

Custom Action Development

  1. Choose the Right Type: JavaScript for simple tasks, Docker for complex environments, Composite for workflow composition
  2. Follow Best Practices: Proper input validation, error handling, and documentation
  3. Test Thoroughly: Unit tests, integration tests, and local validation
  4. Version Control: Use semantic versioning and proper tagging
  5. Security: Handle secrets properly and validate inputs

Enterprise Considerations

  • Self-Hosted Runners: For sensitive workloads and private resources
  • Environment Protection: Implement approval gates and access controls
  • Organization Actions: Share common actions across teams
  • Compliance: Follow security and compliance requirements
  • Monitoring: Track action usage and performance

Best Practices

  • Reusability: Design actions for multiple use cases
  • Documentation: Provide clear usage examples and documentation
  • Error Handling: Implement proper error handling and user feedback
  • Performance: Optimize for speed and resource usage
  • Maintenance: Regular updates and security patches

Next Steps: Ready for a complete deployment project? Continue to Section 2.4: Production Deployment Project to apply all these concepts in a real-world scenario.


Building custom actions enables you to create powerful, reusable automation components that can significantly improve your team's productivity and workflow efficiency.