GitHub Custom Actions Explained: Master Composite Actions
GitHub Actions provides a rich ecosystem of official actions (such as actions/checkout or actions/upload-artifact) and community/vendor marketplace actions (such as Docker, AWS, Azure, and Terraform). However, real-world enterprise deployments frequently require custom internal logic, multi-step orchestration, and standardized workflows across dozens of repositories.
Custom Actions solve this challenge by packaging reusable workflow logic into centralized, manageable units. This chapter focuses on Composite Actions—the primary custom action type used by DevOps engineers to chain existing actions and shell scripts into reusable CI/CD building blocks.
1. The Need for Custom Actions: Ecosystem Gaps & Enterprise Realities
While pre-built Marketplace actions cover standard utility tasks, they fall short when handling proprietary enterprise workflows and standardizing logic across multiple teams or services.
Typical Enterprise Deployment Flow
An enterprise CI/CD pipeline rarely consists of just building code and pushing a container. A typical production deployment flow requires specialized sequence execution:
- Authentication: Authenticate with internal platform proxies or identity providers.
- Policy Validation: Validate organization compliance, security rules, and linting standards.
- Build & Package: Compile code and build application artifacts/images.
- Deployment: Deploy artifacts to designated target environments (Dev, Staging, Prod).
- CMDB Update: Register deployment metadata inside an internal Configuration Management Database (CMDB).
- Internal Notifications: Notify enterprise audit logs, Slack channels, or ticketing systems.
Because this custom orchestration does not exist as a single Marketplace action, teams resort to writing custom Python or Bash scripts invoked via run: steps across their workflows.
2. Key Challenges in Workflow Scaling
When scaling CI/CD across multiple applications or microservices, relying strictly on inline shell scripts and duplicated Marketplace actions introduces severe operational friction.
Challenge 1: Repetitive Workflow Logic
If an application consists of 10 microservices, each service repository typically runs identical setup, authentication, build, scanning, and deployment steps. Copy-pasting 50–100 lines of YAML across 10 distinct workflow files violates DRY (Don't Repeat Yourself) engineering principles.
Challenge 2: Maintenance and Version Drift Overhead
If a vendor action releases a critical security patch or an internal script API endpoint changes:
- Engineers must manually create Pull Requests across all repositories to update the step syntax or version tags.
- Inconsistent updates cause version drift, where some microservices run outdated, non-compliant CI configurations.
Challenge 3: Verbose and Unmaintainable Workflows
Inline step definitions with extensive with: parameter configurations expand workflow files significantly. A workflow containing 5 custom steps with 10 parameters each becomes hundreds of lines long, obscuring the core pipeline pipeline logic.
3. GitHub Custom Actions: Mental Model & Core Definition
A GitHub Custom Action packages reusable CI/CD steps, shell commands, or existing GitHub actions into a single reusable component that workflows invoke using the uses: keyword.
Real-Life Analogy
Analogy: Think of standard workflow steps as individual cooking ingredients (raw shell commands, checkout actions, docker logins). If 10 restaurants (microservices) use the exact same secret sauce recipe made of 5 ingredients, instead of delivering 5 raw ingredients to every kitchen and making them mix it from scratch every time, you pre-package the sauce into a single bottle (Custom Action). The kitchens simply call for the sauce bottle (
uses: organization/sauce-action@v1) and pass custom parameters like spiciness level (with: spicy: true).
The Three Types of Custom Actions
- Composite Actions: Combine existing GitHub actions, shell scripts, or commands into a unified sequence. Written directly in YAML.
- JavaScript Actions: Run natively on node runners without container overhead. Ideal for advanced programmatic logic.
- Docker Actions: Encapsulate execution environments inside a custom Docker container. Guarantees OS tool dependencies at the cost of container build spin-up time.
4. Reusable Workflows vs. Custom Composite Actions
Engineers frequently confuse Reusable Workflows with Composite Actions. While both promote centralization, they operate at different structural levels inside GitHub Actions.
5. Composite Actions Architecture: Local vs. Remote Distribution
Composite actions can be stored locally within the application repository or remotely in a centralized enterprise action repository.
Local vs. Remote Structural Layout
Path Resolution Syntax
When referencing actions via the uses: keyword:
- Local Composite Action:
uses: ./.github/actions/<subdirectory-name> - Remote Composite Action:
uses: <owner>/<repository>/<path>@<ref>(e.g.,uses: cwvj-org/build-push-action@v1oruses: cwvj-org/shared-actions/docker-build@main).
6. Composite Action File Anatomy (action.yml)
Every composite action requires a root configuration file named action.yml (or action.yaml).
name: 'Composite Action Name'
description: 'High-level description of what this composite action performs'
# 1. Inputs Block: Define values expected from the calling workflow
inputs:
input_variable_name:
description: 'Description of the input parameter'
required: true # true or false
default: 'fallback-value'
# 2. Outputs Block: Pass values generated inside the action back to the workflow
outputs:
output_variable_name:
description: 'Description of the step output'
value: ${{ steps.<step_id>.outputs.<key_name> }}
# 3. Execution Block: Tell GitHub to process steps sequentially using the composite engine
runs:
using: 'composite'
steps:
- name: Step 1 Title
run: echo "Executing step 1"
shell: bash # CRITICAL: shell MUST be explicitly defined for composite run steps!
Note: Unlike standard workflow files where
defaults.run.shellautomatically defaults tobashon Ubuntu runners, everyrun:step inside a Composite Action MUST explicitly state itsshell:implementation (e.g.,shell: bash). Failing to supply theshellparameter causes workflow compilation errors.
7. Demo 1: Chaining Existing GitHub Actions (Docker Build & Push)
In this scenario, a standard workflow requires three sequential Marketplace actions: checking out code, logging into Docker Hub, and building/pushing a container image. We will abstract all three into a single local composite action.
Workflow Evolution Flow
8. Step-by-Step Code Walkthrough: Original vs. Refactored
Original Un-factored Workflow (original-ci.yml)
Notice how repetitive and verbose the setup is when directly embedded inside the calling workflow:
name: Docker Build Original
on:
workflow_dispatch:
jobs:
docker-build-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ vars.DOCKERHUB_USERNAME }}/python-demo:${{ github.run_number }}
The Central Composite Action (.github/actions/docker-build-push/action.yml)
We encapsulate the login and build-push steps into an action parameterizing all repository-specific variables.
name: 'Docker Build and Push'
description: 'Reusable composite action for logging in, building, and pushing Docker images'
inputs:
dockerhub-username:
description: 'Docker Hub Account Username'
required: true
dockerhub-token:
description: 'Docker Hub Authentication Token or Password'
required: true
image-name:
description: 'Name of the Target Docker Repository Image'
required: true
image-tag:
description: 'Tag applied to the built Docker Image'
required: false
default: 'latest'
runs:
using: 'composite'
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-token }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ inputs.image-name }}:${{ inputs.image-tag }}
Refactored Workflow (refactored-ci.yml)
The main workflow is now drastically simplified, passing parameters through the single custom composite entry point:
name: Docker Build Refactored
on:
workflow_dispatch:
jobs:
docker-build-push:
runs-on: ubuntu-latest
env:
IMAGE_NAME: ${{ vars.DOCKER_IMAGE_NAME }}
IMAGE_TAG: ${{ github.run_number }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build and Push Docker Image via Composite Action
uses: ./.github/actions/docker-build-push
with:
dockerhub-username: ${{ vars.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ env.IMAGE_TAG }}
9. Demo 2: Chaining Shell Commands with Metadata & Action Outputs
Composite actions are equally effective at aggregating multiple complex shell commands, formatting metadata reports, and passing calculated outputs back to the calling workflow.
Scenario Requirements
Generate standardized build metadata (Git SHA, Run ID, Triggering User, Timestamp) into a structured text file (buildinfo.txt), format outputs, and make the generated file path accessible to downstream workflow steps (such as artifact upload actions).
The Shell Composite Action (.github/actions/generate-build-info/action.yml)
name: 'Generate Build Information'
description: 'Generates standardized pipeline build metadata and outputs file path'
inputs:
application-name:
description: 'Name of the application being built'
required: true
environment:
description: 'Target Deployment Environment (dev, staging, prod)'
required: true
outputs:
build-info-path:
description: 'Path location of generated build info text file'
value: ${{ steps.gen-info.outputs.file-path }}
runs:
using: 'composite'
steps:
- name: Generate Build Info Step
id: gen-info
shell: bash
run: |
# Fail immediately if any command returns a non-zero exit status
set -e
# Create output directory
mkdir -p output
BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
FILE_PATH="output/buildinfo.txt"
# Construct structured metadata content
echo "==========================================" > $FILE_PATH
echo " BUILD METADATA REPORT " >> $FILE_PATH
echo "==========================================" >> $FILE_PATH
echo "Application : ${{ inputs.application-name }}" >> $FILE_PATH
echo "Environment : ${{ inputs.environment }}" >> $FILE_PATH
echo "Repository : ${{ github.repository }}" >> $FILE_PATH
echo "Branch : ${{ github.ref_name }}" >> $FILE_PATH
echo "Commit SHA : ${{ github.sha }}" >> $FILE_PATH
echo "Run Number : ${{ github.run_number }}" >> $FILE_PATH
echo "Triggered By : ${{ github.actor }}" >> $FILE_PATH
echo "Build Timestamp : $BUILD_TIME" >> $FILE_PATH
echo "==========================================" >> $FILE_PATH
# Output report log to standard runner console
cat $FILE_PATH
# Register Action Output variable using GITHUB_OUTPUT environment file
echo "file-path=$FILE_PATH" >> $GITHUB_OUTPUT
10. Handling Inputs, Outputs, and Contexts in Composite Actions
Passing dynamic data between workflows and custom actions relies on specific variable binding patterns.
Context Contextual Reference Rules
- Inputs Reference: Read values supplied via
with:blocks using${{ inputs.<input-id> }}. - Context Access: Composite actions natively access the standard
githubcontext object directly (e.g.,${{ github.sha }},${{ github.repository }}). - Exporting Step Outputs: Multi-line shell scripts compute values and pipe key-value strings into
$GITHUB_OUTPUT(echo "key=value" >> $GITHUB_OUTPUT). - Exporting Action Outputs: The
outputs:section maps step outputs to action outputs via syntax:value: ${{ steps.<step-id>.outputs.<step-output-key> }}
Consuming the Output in the Workflow (build-info-demo.yml)
name: Build Information Demo
on:
workflow_dispatch:
jobs:
build-information:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Generate Build Info File
id: build-info-step
uses: ./.github/actions/generate-build-info
with:
application-name: 'CloudWithVarun App'
environment: 'development'
- name: Upload Generated Report as Artifact
uses: actions/upload-artifact@v4
with:
name: build-info-report
path: ${{ steps.build-info-step.outputs.build-info-path }}
11. Practical Pitfalls & Engineering Best Practices
1. Mandatory shell: Declaration
- Pitfall: Omitting the
shell:property inside a composite action step causes workflow execution to fail prior to runtime. - Solution: Always append
shell: bash(orpowershell/python) to every singlerun:step withinaction.yml.
2. Error Handling in Inline Shell Scripts
- Pitfall: Multiline
run:statements may fail mid-script while downstream lines continue to execute, resulting in corrupted outputs or silent failures. - Solution: Explicitly prefix shell script blocks with
set -eso the action terminates instantly upon encountering a non-zero exit status code.
3. Handling Sensitive Data & Secrets
- Pitfall: Attempting to reference
${{ secrets.MY_SECRET }}directly inside a generic composite action limits portability across external repositories. - Solution: Treat secrets as standard required inputs within the action's
inputs:block (e.g.,inputs.dockerhub-token). Pass secrets from calling workflows using${{ secrets.SECRET_NAME }}inside thewith:section.
4. Directory Conventions & Naming
- Convention: Maintain a standard repository structure (
.github/actions/<action-name>/action.yml). Keep directory names descriptive usingkebab-case(e.g.,docker-build-push).
12. Summary and Key Takeaways
- Centralized Logic: GitHub Custom Actions encapsulate workflow steps, shell scripts, and existing Marketplace actions into single callable components to eliminate YAML duplication across repositories.
- Composite Actions Engine: Composite actions are created purely using YAML configurations marked by
runs: using: 'composite'. They execute defined steps sequentially. - No
jobs:Block in Custom Actions: Custom actions operate strictly at the step level and must never contain ajobs:key insideaction.yml. - Explicit Shell Requirement: Every
run:block in a composite action must explicitly define itsshell:execution context (e.g.,shell: bash). - Flexible Scope: Composite actions can reside locally (
./.github/actions/<dir>) for single-repo use or remotely (org/repo/path@v1) across enterprise repositories. - Strict Data Pipeline: Parameterize behavior using the root
inputs:block, bind calling parameters using the workflow'swith:block, and bubble computed values back using$GITHUB_OUTPUTlinked to the rootoutputs:section. - Error Safeguards: Enforce robust error handling in composite shell execution using script-level defensive commands such as
set -e.