Questions
Multiple workflows share the same setup steps. Create a composite action for reuse.
The Scenario
You notice the same setup pattern repeated across 50+ workflows:
# Repeated in every workflow
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npm run lint
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE }}
aws-region: us-east-1
When Node version needs updating, you have to modify 50 files. When AWS region changes, another 50 edits.
The Challenge
Create a composite action that encapsulates this shared setup, making it reusable, maintainable, and customizable.
A junior engineer might copy-paste the setup into a shell script, create a Docker action for simple steps (slow), or just document the steps and hope people follow them. These approaches don't leverage GitHub Actions features, add unnecessary overhead, or lead to drift.
A senior engineer creates a composite action that combines multiple steps, accepts inputs for customization, provides outputs for downstream steps, handles errors gracefully, and is versioned for stability.
Step 1: Create Basic Composite Action
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Sets up Node.js environment with dependencies and AWS credentials'
author: 'Platform Team'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '18'
aws-role-arn:
description: 'AWS role ARN for OIDC authentication'
required: false
default: ''
aws-region:
description: 'AWS region'
required: false
default: 'us-east-1'
skip-lint:
description: 'Skip linting step'
required: false
default: 'false'
outputs:
node-version:
description: 'The Node.js version that was set up'
value: ${{ steps.setup-node.outputs.node-version }}
cache-hit:
description: 'Whether npm cache was hit'
value: ${{ steps.setup-node.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
shell: bash
run: npm ci
- name: Run linting
if: inputs.skip-lint != 'true'
shell: bash
run: npm run lint
- name: Configure AWS credentials
if: inputs.aws-role-arn != ''
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ inputs.aws-role-arn }}
aws-region: ${{ inputs.aws-region }}Step 2: Usage in Workflows
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# Simple usage with defaults
- uses: ./.github/actions/setup-project
- run: npm test
- run: npm run build
deploy:
runs-on: ubuntu-latest
steps:
# Customized usage
- uses: ./.github/actions/setup-project
with:
node-version: '20'
aws-role-arn: ${{ secrets.AWS_ROLE_ARN }}
skip-lint: 'true'
- run: npm run deployStep 3: Create Shared Action Repository
# In repo: my-org/actions
# .github/actions/setup-node-project/action.yml
name: 'Setup Node.js Project'
description: 'Complete Node.js project setup with caching and optional tools'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '18'
package-manager:
description: 'Package manager (npm, yarn, pnpm)'
required: false
default: 'npm'
install-command:
description: 'Custom install command'
required: false
default: ''
working-directory:
description: 'Working directory'
required: false
default: '.'
outputs:
cache-hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: ${{ inputs.package-manager }}
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- name: Setup pnpm
if: inputs.package-manager == 'pnpm'
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get cache directory
id: cache-dir
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
case "${{ inputs.package-manager }}" in
npm) echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT ;;
yarn) echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT ;;
pnpm) echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT ;;
esac
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.cache-dir.outputs.dir }}
key: ${{ runner.os }}-${{ inputs.package-manager }}-${{ hashFiles(format('{0}/**/package-lock.json', inputs.working-directory), format('{0}/**/yarn.lock', inputs.working-directory), format('{0}/**/pnpm-lock.yaml', inputs.working-directory)) }}
restore-keys: |
${{ runner.os }}-${{ inputs.package-manager }}-
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
if [ -n "${{ inputs.install-command }}" ]; then
${{ inputs.install-command }}
else
case "${{ inputs.package-manager }}" in
npm) npm ci ;;
yarn) yarn install --frozen-lockfile ;;
pnpm) pnpm install --frozen-lockfile ;;
esac
fiStep 4: Create Deploy Composite Action
# my-org/actions/.github/actions/deploy-to-k8s/action.yml
name: 'Deploy to Kubernetes'
description: 'Deploy application to Kubernetes cluster'
inputs:
cluster-name:
description: 'EKS cluster name'
required: true
namespace:
description: 'Kubernetes namespace'
required: true
image:
description: 'Docker image to deploy'
required: true
deployment-name:
description: 'Kubernetes deployment name'
required: true
aws-role-arn:
description: 'AWS role ARN for OIDC'
required: true
aws-region:
description: 'AWS region'
required: false
default: 'us-east-1'
timeout:
description: 'Rollout timeout'
required: false
default: '300s'
outputs:
deployment-status:
description: 'Deployment status'
value: ${{ steps.deploy.outputs.status }}
runs:
using: 'composite'
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ inputs.aws-role-arn }}
aws-region: ${{ inputs.aws-region }}
- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Update kubeconfig
shell: bash
run: |
aws eks update-kubeconfig \
--name ${{ inputs.cluster-name }} \
--region ${{ inputs.aws-region }}
- name: Deploy
id: deploy
shell: bash
run: |
echo "Deploying ${{ inputs.image }} to ${{ inputs.namespace }}"
kubectl set image deployment/${{ inputs.deployment-name }} \
app=${{ inputs.image }} \
-n ${{ inputs.namespace }}
if kubectl rollout status deployment/${{ inputs.deployment-name }} \
-n ${{ inputs.namespace }} \
--timeout=${{ inputs.timeout }}; then
echo "status=success" >> $GITHUB_OUTPUT
else
echo "status=failed" >> $GITHUB_OUTPUT
exit 1
fi
- name: Verify deployment
shell: bash
run: |
kubectl get pods -n ${{ inputs.namespace }} -l app=${{ inputs.deployment-name }}
kubectl get deployment ${{ inputs.deployment-name }} -n ${{ inputs.namespace }} -o jsonpath='{.status.availableReplicas}'Step 5: Usage from External Repository
# In any repo using the shared actions
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Use shared setup action
- uses: my-org/actions/.github/actions/setup-node-project@v2
with:
node-version: '20'
package-manager: 'pnpm'
- run: pnpm run build
# Build and push Docker image
- name: Build image
id: build
run: |
IMAGE="registry.example.com/myapp:${{ github.sha }}"
docker build -t $IMAGE .
docker push $IMAGE
echo "image=$IMAGE" >> $GITHUB_OUTPUT
# Use shared deploy action
- uses: my-org/actions/.github/actions/deploy-to-k8s@v2
with:
cluster-name: production
namespace: myapp
deployment-name: myapp
image: ${{ steps.build.outputs.image }}
aws-role-arn: ${{ secrets.AWS_ROLE_ARN }}Step 6: Version and Release Actions
# In my-org/actions repo
# .github/workflows/release.yml
name: Release Actions
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
# Update major version tag (v2 -> v2.1.0)
- name: Update major version tag
run: |
VERSION=${GITHUB_REF#refs/tags/}
MAJOR=${VERSION%%.*}
git tag -f $MAJOR
git push -f origin $MAJOR Composite vs Reusable Workflows
| Feature | Composite Action | Reusable Workflow |
|---|---|---|
| Scope | Steps within a job | Complete jobs |
| Secrets | Must be passed explicitly | Can use secrets: inherit |
| Multiple jobs | No | Yes |
| Own runners | No (uses caller’s) | Yes |
| Best for | Shared step sequences | Complete CI/CD pipelines |
Practice Question
What is a key limitation of composite actions compared to JavaScript/Docker actions?