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 Type | Best For | Pros | Cons |
---|---|---|---|
JavaScript | Simple operations, API calls, file manipulation | Fast, lightweight, easy to develop | Limited to Node.js ecosystem |
Docker | Complex environments, specific tools, system dependencies | Full control, any runtime, isolated | Slower startup, larger size |
Composite | Combining multiple steps, workflow composition | Reusable, flexible, maintainable | Limited 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
- Choose the Right Type: JavaScript for simple tasks, Docker for complex environments, Composite for workflow composition
- Follow Best Practices: Proper input validation, error handling, and documentation
- Test Thoroughly: Unit tests, integration tests, and local validation
- Version Control: Use semantic versioning and proper tagging
- 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.