Skip to main content

Custom GitHub Actions

What are Custom GitHub Actions?

Custom GitHub Actions are reusable automation units that you create to encapsulate specific workflows for your organization. Instead of duplicating workflow code across repositories, you build actions that teams can consume like building blocks.

Types of Custom Actions:

TypeLanguageUse CaseExample
JavaScript/TypeScriptNode.jsFast execution, GitHub API accessValidate Azure naming conventions
DockerAny languageComplex dependencies, multi-languageRun Azure Policy scanning
CompositeYAMLCombine multiple stepsStandardized Azure deployment

Key Benefits:

  • Reusability: Write once, use across all repos
  • Consistency: Enforce organizational standards
  • Abstraction: Hide complexity from end users
  • Versioning: Control when teams upgrade
  • Security: Centralize security scanning logic

When to Build Custom Actions

Build a custom action when:

  • Logic is reused across 3+ repositories
  • You need to enforce organizational standards
  • Complex setup requires 10+ steps
  • You want to hide sensitive implementation details
  • Need to interact with internal APIs/tooling

Don't build a custom action when:

  • A marketplace action already exists
  • Logic is repo-specific and won't be reused
  • Simple 1-2 step operations
  • Rapid prototyping (use composite actions first)

Composite Actions (Simplest)

Composite actions combine multiple steps into a single action using only YAML.

Example: Standardized Azure Login

# .github/actions/azure-login/action.yml
name: 'Azure Login with Standard Config'
description: 'Login to Azure with OIDC and standard configuration'

inputs:
environment:
description: 'Environment name (dev, staging, production)'
required: true

runs:
using: 'composite'
steps:
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
shell: bash

- name: Set Azure Context
run: |
az account set --subscription ${{ env.AZURE_SUBSCRIPTION_ID }}
az configure --defaults location=eastus group=rg-${{ inputs.environment }}
shell: bash

- name: Display Azure Config
run: az account show
shell: bash

Using the composite action:

# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Login to Azure
uses: ./.github/actions/azure-login
with:
environment: production

JavaScript/TypeScript Actions

Best for actions that need to interact with GitHub APIs or perform complex logic.

Example: Azure Naming Convention Validator

Directory structure:

azure-naming-validator/
├── action.yml
├── package.json
├── index.js
├── dist/
│ └── index.js (compiled)
└── README.md

1. action.yml Metadata

# action.yml
name: 'Azure Naming Convention Validator'
description: 'Validates Azure resource names against organizational naming standards'
author: 'Platform Team'

inputs:
resource-type:
description: 'Azure resource type (rg, vnet, vm, storage, etc.)'
required: true
resource-name:
description: 'Proposed resource name to validate'
required: true
environment:
description: 'Environment (dev, staging, prod)'
required: false
default: 'dev'

outputs:
valid:
description: 'Whether the name is valid (true/false)'
suggestion:
description: 'Suggested name if invalid'

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

2. JavaScript Implementation

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

const NAMING_CONVENTIONS = {
rg: /^rg-[a-z]+-[a-z]+-(dev|staging|prod)$/,
vnet: /^vnet-[a-z]+-[a-z]+-(dev|staging|prod)$/,
vm: /^vm-[a-z]+-[a-z]+-(dev|staging|prod)-\d{2}$/,
storage: /^st[a-z]{3,20}(dev|staging|prod)$/,
kv: /^kv-[a-z]+-[a-z]+-(dev|staging|prod)$/
};

const PATTERNS = {
rg: 'rg-<workload>-<region>-<env>',
vnet: 'vnet-<workload>-<region>-<env>',
vm: 'vm-<workload>-<region>-<env>-<instance>',
storage: 'st<workload><env> (3-20 lowercase letters)',
kv: 'kv-<workload>-<region>-<env>'
};

async function run() {
try {
const resourceType = core.getInput('resource-type', { required: true });
const resourceName = core.getInput('resource-name', { required: true });
const environment = core.getInput('environment');

core.info(`Validating: ${resourceName} (type: ${resourceType})`);

const pattern = NAMING_CONVENTIONS[resourceType];
if (!pattern) {
core.setFailed(`Unsupported resource type: ${resourceType}`);
return;
}

const isValid = pattern.test(resourceName);

if (isValid) {
core.info(`✅ Valid: ${resourceName}`);
core.setOutput('valid', 'true');
} else {
const suggestion = generateSuggestion(resourceType, environment);
core.warning(`❌ Invalid: ${resourceName}`);
core.warning(`Expected pattern: ${PATTERNS[resourceType]}`);
core.warning(`Suggestion: ${suggestion}`);

core.setOutput('valid', 'false');
core.setOutput('suggestion', suggestion);

core.setFailed(`Resource name does not match naming convention`);
}
} catch (error) {
core.setFailed(error.message);
}
}

function generateSuggestion(resourceType, environment) {
const suggestions = {
rg: `rg-myapp-eastus-${environment}`,
vnet: `vnet-myapp-eastus-${environment}`,
vm: `vm-myapp-eastus-${environment}-01`,
storage: `stmyapp${environment}`,
kv: `kv-myapp-eastus-${environment}`
};
return suggestions[resourceType] || 'N/A';
}

run();

3. package.json

{
"name": "azure-naming-validator",
"version": "1.0.0",
"description": "Validate Azure resource naming conventions",
"main": "index.js",
"scripts": {
"build": "ncc build index.js -o dist"
},
"dependencies": {
"@actions/core": "^1.10.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
}
}

4. Build and Publish

# Install dependencies
npm install

# Compile to single file
npm run build

# Commit dist/ folder
git add dist/
git commit -m "Build action"
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0

5. Use the Action

# .github/workflows/validate-infrastructure.yml
name: Validate Infrastructure Names

on:
pull_request:
paths:
- 'terraform/**'

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

- name: Validate Resource Group Name
uses: your-org/azure-naming-validator@v1
with:
resource-type: 'rg'
resource-name: 'rg-myapp-eastus-prod'
environment: 'prod'

- name: Validate Storage Account Name
uses: your-org/azure-naming-validator@v1
with:
resource-type: 'storage'
resource-name: 'stmyappprod'
environment: 'prod'

Docker-Based Actions

Best for actions requiring specific dependencies or non-Node.js languages.

Example: Terraform Security Scanner

Directory structure:

terraform-security-scanner/
├── action.yml
├── Dockerfile
├── entrypoint.sh
└── README.md

action.yml

# action.yml
name: 'Terraform Security Scanner'
description: 'Scan Terraform code for security issues using multiple tools'

inputs:
terraform-directory:
description: 'Path to Terraform directory'
required: true
default: './terraform'
severity:
description: 'Minimum severity level (LOW, MEDIUM, HIGH, CRITICAL)'
required: false
default: 'MEDIUM'

runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.terraform-directory }}
- ${{ inputs.severity }}

Dockerfile

# Dockerfile
FROM hashicorp/terraform:1.9

# Install security scanning tools
RUN apk add --no-cache python3 py3-pip
RUN pip3 install checkov

# Install tfsec
RUN wget -O /usr/local/bin/tfsec \
https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64 && \
chmod +x /usr/local/bin/tfsec

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

ENTRYPOINT ["/entrypoint.sh"]

entrypoint.sh

#!/bin/sh

set -e

TF_DIR=$1
SEVERITY=$2

echo "🔍 Scanning Terraform code in: $TF_DIR"
echo "📊 Minimum severity: $SEVERITY"

# Run tfsec
echo "Running tfsec..."
tfsec $TF_DIR --minimum-severity $SEVERITY --format=default

# Run Checkov
echo "Running Checkov..."
checkov -d $TF_DIR --framework terraform --quiet --compact

echo "✅ Security scan complete!"

Reusable Workflows

For complex multi-job workflows, use reusable workflows instead of actions.

Example: Standard Terraform Deployment

# .github/workflows/terraform-deploy-reusable.yml
name: Reusable Terraform Deployment

on:
workflow_call:
inputs:
environment:
required: true
type: string
terraform-directory:
required: false
type: string
default: './terraform'
auto-approve:
required: false
type: boolean
default: false
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true

permissions:
id-token: write
contents: read

jobs:
terraform-plan:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
exitcode: ${{ steps.plan.outputs.exitcode }}
steps:
- uses: actions/checkout@v4

- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3

- name: Terraform Init
run: terraform init
working-directory: ${{ inputs.terraform-directory }}

- name: Terraform Plan
id: plan
run: |
terraform plan -detailed-exitcode -out=tfplan || export exitcode=$?
echo "exitcode=$exitcode" >> $GITHUB_OUTPUT
working-directory: ${{ inputs.terraform-directory }}

- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: tfplan-${{ inputs.environment }}
path: ${{ inputs.terraform-directory }}/tfplan

terraform-apply:
needs: terraform-plan
if: inputs.auto-approve && needs.terraform-plan.outputs.exitcode == 2
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4

- name: Download Plan
uses: actions/download-artifact@v4
with:
name: tfplan-${{ inputs.environment }}
path: ${{ inputs.terraform-directory }}

- name: Terraform Apply
run: terraform apply -auto-approve tfplan
working-directory: ${{ inputs.terraform-directory }}

Call the reusable workflow:

# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
push:
branches:
- main

jobs:
deploy:
uses: ./.github/workflows/terraform-deploy-reusable.yml
with:
environment: production
terraform-directory: './terraform'
auto-approve: true
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Publishing Actions to GitHub Marketplace

1. Prepare Repository

# action.yml - Include branding
name: 'Your Action Name'
description: 'Clear, concise description under 125 characters'
author: 'Your Organization'

branding:
icon: 'check-circle' # Feather icon name
color: 'blue' # blue, green, orange, red, purple, yellow, gray-dark, white

inputs:
# ... your inputs

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

2. Add README.md

# Action Name

## Description
Clear description of what the action does.

## Usage

\`\`\`yaml
- uses: your-org/action-name@v1
with:
input-name: 'value'
\`\`\`

## Inputs

| Name | Required | Default | Description |
|------|----------|---------|-------------|
| input-name | Yes | - | What this input does |

## Outputs

| Name | Description |
|------|-------------|
| output-name | What this output contains |

## Examples

### Example 1
\`\`\`yaml
- uses: your-org/action-name@v1
with:
input-name: 'example-value'
\`\`\`

## License
MIT

3. Create Release

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

# Create major version tag (enables users to use @v1)
git tag -fa v1 -m "Update v1 tag"
git push origin v1 --force

4. Publish to Marketplace

  1. Go to repository on GitHub
  2. Click Releases > Draft a new release
  3. Select tag: v1.0.0
  4. ✅ Check Publish this Action to the GitHub Marketplace
  5. Select category
  6. Click Publish release

Best Practices

1. Version Your Actions

Do:

# Use specific version
- uses: your-org/action@v1.2.3

# Use major version (gets patches automatically)
- uses: your-org/action@v1

# Use SHA (most secure)
- uses: your-org/action@a1b2c3d4

Don't:

# Never use @main in production
- uses: your-org/action@main

2. Handle Errors Gracefully

// Good error handling
try {
const result = await someOperation();
core.setOutput('result', result);
} catch (error) {
core.setFailed(`Operation failed: ${error.message}`);
core.debug(error.stack); // Only visible with debug logging
}

3. Use Semantic Versioning

  • Major (v1 → v2): Breaking changes
  • Minor (v1.0 → v1.1): New features, backwards compatible
  • Patch (v1.0.0 → v1.0.1): Bug fixes

4. Secure Inputs

// Mark sensitive inputs
const apiKey = core.getInput('api-key');
core.setSecret(apiKey); // Mask in logs

5. Test Actions Locally

# Use act to test locally
brew install act

# Run workflow locally
act push -j test-action

Common Use Cases

1. Azure Policy Compliance Checker

name: 'Azure Policy Compliance Checker'
description: 'Check if Terraform will violate Azure Policies'

runs:
using: 'composite'
steps:
- name: Export Terraform Plan JSON
run: terraform show -json tfplan > plan.json
shell: bash

- name: Check Against Azure Policy
run: |
az policy remediation create-scan \
--resource-group ${{ inputs.resource-group }} \
--name compliance-scan
shell: bash

2. Cost Estimation Action

name: 'Azure Cost Estimator'
description: 'Estimate Azure costs from Terraform plan'

runs:
using: 'docker'
image: 'Dockerfile'
# Use Infracost or Azure Pricing API

3. Tagging Enforcement

name: 'Enforce Azure Resource Tags'
description: 'Ensure all resources have required tags'

inputs:
required-tags:
description: 'Comma-separated list of required tags'
required: true
default: 'Environment,Owner,CostCenter'

Troubleshooting

Action Not Found

Error: Error: Unable to resolve action

Fix:

  • Ensure repository is public or action is in same organization
  • Check repository path: owner/repo@version
  • Verify release/tag exists

Inputs Not Working

Error: Input value is undefined

Fix:

# Correct syntax
with:
input-name: 'value'

# Not:
with:
input_name: 'value' # Wrong: underscores don't work

Docker Action Fails

Error: Process completed with exit code 125

Fix:

  • Test Dockerfile builds locally: docker build -t test .
  • Check entrypoint script has execute permission
  • Ensure all dependencies are installed in Dockerfile