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:
| Type | Language | Use Case | Example |
|---|---|---|---|
| JavaScript/TypeScript | Node.js | Fast execution, GitHub API access | Validate Azure naming conventions |
| Docker | Any language | Complex dependencies, multi-language | Run Azure Policy scanning |
| Composite | YAML | Combine multiple steps | Standardized 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
- Go to repository on GitHub
- Click Releases > Draft a new release
- Select tag:
v1.0.0 - ✅ Check Publish this Action to the GitHub Marketplace
- Select category
- 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