DeployU
Interviews / DevOps & Cloud Infrastructure / Multiple workflows share the same setup steps. Create a composite action for reuse.

Multiple workflows share the same setup steps. Create a composite action for reuse.

practical Custom Actions Interactive Quiz Code Examples

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.

Wrong Approach

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.

Right Approach

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 deploy

Step 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
        fi

Step 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

FeatureComposite ActionReusable Workflow
ScopeSteps within a jobComplete jobs
SecretsMust be passed explicitlyCan use secrets: inherit
Multiple jobsNoYes
Own runnersNo (uses caller’s)Yes
Best forShared step sequencesComplete CI/CD pipelines

Practice Question

What is a key limitation of composite actions compared to JavaScript/Docker actions?