DevOpsGitHub

GitHub Actions: Reusable Workflows and Composite Actions

TT
TopicTrick Team
GitHub Actions: Reusable Workflows and Composite Actions

GitHub Actions: Reusable Workflows and Composite Actions

When you have 20 repositories that all need to build, test, and deploy in a consistent way, maintaining 20 copies of the same workflow YAML is a maintenance nightmare. One security fix, one version bump, one new requirement means editing every copy. Reusable workflows solve this: write the workflow once in a central location, call it from any repository with a single line.

This guide covers reusable workflows with workflow_call, composite actions for step-level reuse, passing inputs and secrets, setting up a centralised DevOps repository, and controlling access to private reusable workflows.


Reusable Workflows vs Composite Actions

Before writing any code, understand which tool fits your scenario:

Reusable WorkflowComposite Action
Unit of reuseEntire jobsIndividual steps within a job
Runs inA separate runnerThe caller's runner
Can useAny action, service containersActions only (no service containers)
SecretsInherit or explicit mappingMust use inputs or env
OutputsJob outputs → workflow outputsStep outputs
Best forFull deployment pipelines, CI test suitesSetup steps, repeated command sequences

Use a reusable workflow when you want to standardise an entire job or series of jobs across repos.

Use a composite action when you want to package a few repeated steps that run inside someone else's job.


Creating a Reusable Workflow

A reusable workflow is triggered by workflow_call instead of push or pull_request.

yaml
# .github/workflows/deploy.yml  (in your central devops repo: my-org/.github)
name: Deploy Application

on:
  workflow_call:
    inputs:
      environment:
        description: 'Target environment (production, staging)'
        type: string
        required: true
      image_tag:
        description: 'Docker image tag to deploy'
        type: string
        required: true
      slack_channel:
        description: 'Slack channel for deployment notifications'
        type: string
        required: false
        default: '#deployments'
    secrets:
      AWS_ROLE_ARN:
        description: 'AWS IAM role ARN to assume'
        required: true
      SLACK_WEBHOOK_URL:
        required: false
    outputs:
      deployment_url:
        description: 'URL of the deployed environment'
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}    # Links to GitHub Environment for approval rules
    outputs:
      url: ${{ steps.deploy.outputs.url }}

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Deploy to ECS
        id: deploy
        run: |
          aws ecs update-service \
            --cluster ${{ inputs.environment }}-cluster \
            --service app-service \
            --force-new-deployment
          echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"

      - name: Notify Slack
        if: always() && secrets.SLACK_WEBHOOK_URL != ''
        uses: slackapi/slack-github-action@v1
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          payload: |
            {
              "text": "Deployment to ${{ inputs.environment }}: ${{ job.status }}",
              "channel": "${{ inputs.slack_channel }}"
            }

Calling a Reusable Workflow

From any repository in your organisation, call the reusable workflow:

yaml
# .github/workflows/cd.yml  (in my-org/my-app repository)
name: Continuous Deployment

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - name: Build and push Docker image
        id: meta
        run: |
          IMAGE_TAG="${{ github.sha }}"
          docker build -t my-org/my-app:${IMAGE_TAG} .
          docker push my-org/my-app:${IMAGE_TAG}
          echo "version=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"

  deploy-staging:
    needs: build
    uses: my-org/.github/.github/workflows/deploy.yml@main
    with:
      environment: staging
      image_tag: ${{ needs.build.outputs.image_tag }}
    secrets:
      AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE_ARN }}
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

  deploy-production:
    needs: [build, deploy-staging]
    uses: my-org/.github/.github/workflows/deploy.yml@main
    with:
      environment: production
      image_tag: ${{ needs.build.outputs.image_tag }}
    secrets:
      AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Key points:

  • uses: specifies {owner}/{repo}/.github/workflows/{file}.yml@{ref}
  • The @main ref pins to the main branch — you can also pin to a tag like @v1.2.0
  • with: passes inputs, secrets: passes secrets explicitly
  • You cannot mix secrets: inherit and explicit secret mapping in the same call

Secrets: Inherit vs Explicit Mapping

secrets: inherit

The simplest approach — the called workflow automatically receives all secrets the calling workflow has access to:

yaml
deploy-production:
  uses: my-org/.github/.github/workflows/deploy.yml@main
  with:
    environment: production
    image_tag: ${{ needs.build.outputs.image_tag }}
  secrets: inherit    # All secrets are passed automatically

Trade-off: Simple but less explicit — you don't see which secrets are being passed. Use for internal workflows where you trust the template.

Explicit Secret Mapping

More secure and auditable — name every secret explicitly:

yaml
deploy-production:
  uses: my-org/.github/.github/workflows/deploy.yml@main
  with:
    environment: production
  secrets:
    AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}    # Maps caller's secret name to called workflow's expected name
    DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}

Use explicit mapping when the called workflow is in a public or shared repository where you want clarity about which secrets are exposed.


Composite Actions

A composite action packages multiple steps into a single reusable step. It lives in a repository as an action.yml file.

Example: Node.js Setup Composite Action

yaml
# actions/setup-node/action.yml  (in my-org/.github repository)
name: 'Setup Node.js with Cache'
description: 'Install Node.js, restore npm cache, and install dependencies'

inputs:
  node-version:
    description: 'Node.js version to use'
    required: false
    default: '20'
  working-directory:
    description: 'Directory containing package.json'
    required: false
    default: '.'

outputs:
  cache-hit:
    description: 'Whether the npm cache was restored'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - name: Restore npm cache
      id: cache
      uses: actions/cache@v4
      with:
        path: ${{ inputs.working-directory }}/node_modules
        key: node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}

    - name: Install dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      shell: bash
      run: npm ci
      working-directory: ${{ inputs.working-directory }}

Using a Composite Action

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

      # Use the composite action — all three steps collapse to one
      - uses: my-org/.github/actions/setup-node@main
        with:
          node-version: '20'

      - run: npm test

Versioning Reusable Workflows

Pinning reusable workflows to @main means all callers immediately get changes when you update the template. This is convenient but can cause unexpected breakage.

For stable, production-grade templates, version them with tags:

yaml
# v1 of the deploy workflow
uses: my-org/.github/.github/workflows/deploy.yml@v1

# Upgrade to v2 explicitly when ready
uses: my-org/.github/.github/workflows/deploy.yml@v2

Create semantic version tags in the .github repo when you make breaking changes:

bash
git tag -a v2.0.0 -m "Breaking: require image_tag input, remove auto-detection"
git push origin v2.0.0

Teams can upgrade at their own pace by changing the @v1 reference to @v2.


The Centralised DevOps Repository Pattern

Create a dedicated repository for shared workflows. GitHub has a special convention: a repository named .github in your organisation is accessible without specifying the full path in workflow references.

Repository structure

text
my-org/.github/
├── .github/
│   └── workflows/
│       ├── ci.yml              # Standard CI: lint, test, build
│       ├── deploy-ecs.yml      # Deploy to AWS ECS
│       ├── deploy-k8s.yml      # Deploy to Kubernetes
│       ├── security-scan.yml   # SAST and dependency scanning
│       ├── release.yml         # Semantic release automation
│       └── pr-checks.yml       # PR validation: size, labels, branch naming
├── actions/
│   ├── setup-node/
│   │   └── action.yml
│   ├── setup-python/
│   │   └── action.yml
│   └── docker-build-push/
│       └── action.yml
└── README.md                   # Documents available workflows and how to use them

Access control for private reusable workflows

Reusable workflows in a private repository are not accessible to other organisations by default. Configure access in the repository settings:

  1. Go to the .github repo → Settings → Actions → General
  2. Under "Access", select Accessible from repositories in the 'my-org' organization
  3. Optionally restrict to specific repositories

Practical: CI Template for All Node.js Services

yaml
# my-org/.github/.github/workflows/nodejs-ci.yml
name: Node.js CI

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        required: false
        default: '20'
      run-integration-tests:
        type: boolean
        required: false
        default: false
    secrets:
      DATABASE_URL:
        required: false

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: my-org/.github/actions/setup-node@main
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: my-org/.github/actions/setup-node@main
      - name: Run unit tests
        run: npm test
      - name: Run integration tests
        if: inputs.run-integration-tests
        run: npm run test:integration
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL || 'postgresql://postgres:test@postgres:5432/testdb' }}

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: my-org/.github/actions/setup-node@main
      - name: Audit npm dependencies
        run: npm audit --audit-level=high
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript
      - uses: github/codeql-action/analyze@v3

Calling this from any Node.js service:

yaml
# .github/workflows/ci.yml  (in any Node.js service repo)
name: CI
on: [push, pull_request]

jobs:
  ci:
    uses: my-org/.github/.github/workflows/nodejs-ci.yml@main
    with:
      run-integration-tests: true
    secrets:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}

Ten lines replaces 80 lines of per-repo CI configuration. When the security team adds a new scan, one update to the template propagates to all services.


Frequently Asked Questions

Q: Can a reusable workflow call another reusable workflow?

Yes, up to a nesting depth of 4 levels. A called workflow can itself call another reusable workflow. This is useful for composing complex pipelines (CI calls a test workflow which calls a code-coverage workflow).

Q: Can I use reusable workflows from public repositories in private repos?

Yes. Public reusable workflows are accessible from any repository. For private reusable workflows, the calling repository must be granted access through the source repository's settings (Settings → Actions → General → Access).

Q: What happens when I update a reusable workflow pinned to @main?

All workflows currently referencing @main will use the new version on their next run. There is no approval step. This is why production-critical workflows should be pinned to a version tag, not @main.

Q: Is there a limit to how many inputs a reusable workflow can have?

GitHub supports up to 10 inputs and 10 secrets per workflow_call definition. If you need more, consider grouping related values into a JSON string input and parsing it within the workflow.


Key Takeaway

Reusable workflows are the foundation of a platform engineering approach to CI/CD. By centralising your build, test, and deployment workflows in a single repository, you can enforce security standards, apply fixes to all services simultaneously, and give development teams a simple, reliable interface for shipping software. Use reusable workflows for full job templates and composite actions for step-level reuse. Version your templates with semantic tags so teams can upgrade deliberately rather than having updates forced on them.

Read next: GitHub Actions: Matrix Builds and Parallelism →


Part of the GitHub Mastery Course — engineering the reuse.