LabHub

Blog

GitHub Actions Advanced Patterns: Reusable Workflows, Composite Actions, and Monorepo CI/CD Optimization

한국어English日本語

GitHub Actions Advanced Patterns

Introduction

Once an organization passes 10 repositories, duplication across the CI/CD pipelines starts to stand out. The build-test-deploy YAML has been copy-pasted into every repository, and applying a single security patch means editing dozens of workflows one by one. You have probably lived through a change as small as bumping Node.js from 18 to 20 turning into PRs spread across 30 repositories.

GitHub Actions offers two reuse mechanisms to solve this. Reusable Workflows turn an entire workflow into a template that can be called, and Composite Actions bundle several steps into a single action so they can be modularized.

Layer a monorepo on top of that and the complexity climbs sharply. You have to build only the packages that changed, track the dependency graph, and isolate the cache per package. This article treats all three topics in depth, with working code.

Reusable Workflows in Depth

The Structure of the workflow_call Trigger

A Reusable Workflow begins by defining the on: workflow_call trigger. The caller references the workflow with the uses keyword, and data moves back and forth through inputs, secrets, and outputs.

The main constraints as of the November 2025 update are as follows:

The Centralized Workflow Management Pattern

A large organization gathers its Reusable Workflows in the .github repository or in a separate platform-workflows repository and has every team reference them from there. The heart of this pattern is version tag management.

org-platform/
  .github/
    workflows/
      build-node.yml        # Node.js build pipeline
      build-python.yml      # Python build pipeline
      deploy-k8s.yml        # Kubernetes deploy pipeline
      security-scan.yml     # Shared security scan pipeline

An Example Reusable Workflow Definition

Below is a Reusable Workflow that turns the build, test, and deploy of a Node.js application into a template.

# org-platform/.github/workflows/build-node.yml
name: Reusable Node.js Build

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version'
        required: false
        type: string
        default: '20'
      working-directory:
        description: 'Working directory'
        required: false
        type: string
        default: '.'
      run-e2e:
        description: 'Whether to run E2E tests'
        required: false
        type: boolean
        default: false
      artifact-name:
        description: 'Build artifact name'
        required: false
        type: string
        default: 'build-output'
    secrets:
      NPM_TOKEN:
        description: 'npm registry token'
        required: false
      SONAR_TOKEN:
        description: 'SonarQube analysis token'
        required: false
    outputs:
      build-version:
        description: 'The version that was built'
        value: ${{ jobs.build.outputs.version }}
      test-coverage:
        description: 'Test coverage'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Build
        working-directory: ${{ inputs.working-directory }}
        run: npm run build

      - name: Extract version
        id: version
        working-directory: ${{ inputs.working-directory }}
        run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/
          retention-days: 7

  test:
    runs-on: ubuntu-latest
    needs: build
    outputs:
      coverage: ${{ steps.coverage.outputs.percentage }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Run unit tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test -- --coverage

      - name: Extract coverage
        id: coverage
        working-directory: ${{ inputs.working-directory }}
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"

      - name: SonarQube analysis
        if: secrets.SONAR_TOKEN != ''
        uses: SonarSource/sonarqube-scan-action@v3
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  e2e:
    if: inputs.run-e2e
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4

      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/

      - name: Run E2E tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test:e2e

An Example Caller Workflow

A caller workflow that invokes the Reusable Workflow above looks like this.

# my-service/.github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    uses: org-platform/.github/workflows/build-node.yml@v2.3.0
    with:
      node-version: '20'
      working-directory: '.'
      run-e2e: ${{ github.ref == 'refs/heads/main' }}
      artifact-name: 'my-service-build'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  deploy-staging:
    needs: build-and-test
    if: github.ref == 'refs/heads/develop'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: staging
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

  deploy-production:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: production
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

With secrets: inherit, every secret held by the caller is passed along automatically. In an environment where security matters, it is better to pass only the secrets that are actually needed, explicitly.

Building Composite Actions

The Structure of action.yml and the Core Concepts

A Composite Action bundles several steps into one reusable action. Unlike a Reusable Workflow it operates at the step level rather than the job level, and a single job can hold several Composite Actions.

JavaScript vs Docker vs Composite Actions

ItemJavaScript ActionDocker ActionComposite Action
Execution environmentNode.js runtimeDocker containerRuns directly on the runner
Startup timeFast (a few seconds)Slow (image pull)Fast (a few seconds)
Platform compatibilityLinux/macOS/WindowsLinux onlyDepends on the runner OS
Complex logicIdealWorkableShell script level
Reusing existing toolsUses npm packagesAny tool can be installedCan compose other actions
Maintenance difficultyMedium (needs a build)Low (Dockerfile)Low (YAML only)
Secret accessDirect access possibleDirect access possiblePassed only as environment variables

An Example Composite Action Definition

Below is a Composite Action that modularizes building and pushing a Docker image.

# .github/actions/docker-build-push/action.yml
name: 'Docker Build and Push'
description: 'Builds a Docker image and pushes it to a registry'

inputs:
  registry:
    description: 'Container registry URL'
    required: true
  image-name:
    description: 'Image name'
    required: true
  dockerfile:
    description: 'Dockerfile path'
    required: false
    default: './Dockerfile'
  context:
    description: 'Build context path'
    required: false
    default: '.'
  build-args:
    description: 'Build arguments (newline separated)'
    required: false
    default: ''
  push:
    description: 'Whether to push the image'
    required: false
    default: 'true'

outputs:
  image-digest:
    description: 'Digest of the built image'
    value: ${{ steps.build.outputs.digest }}
  image-tag:
    description: 'Image tag'
    value: ${{ steps.meta.outputs.tags }}

runs:
  using: 'composite'
  steps:
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ inputs.registry }}/${{ inputs.image-name }}
        tags: |
          type=sha,prefix=
          type=ref,event=branch
          type=ref,event=tag
          type=semver,pattern=v{{version}}
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Build and push
      id: build
      uses: docker/build-push-action@v6
      with:
        context: ${{ inputs.context }}
        file: ${{ inputs.dockerfile }}
        push: ${{ inputs.push }}
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        build-args: ${{ inputs.build-args }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        provenance: true
        sbom: true

    - name: Print image info
      shell: bash
      run: |
        echo "Image digest: ${{ steps.build.outputs.digest }}"
        echo "Image tags: ${{ steps.meta.outputs.tags }}"

Using the Composite Action

# my-service/.github/workflows/build-image.yml
name: Build Docker Image

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'Dockerfile'

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push
        id: docker
        uses: ./.github/actions/docker-build-push
        with:
          registry: ghcr.io
          image-name: ${{ github.repository }}
          build-args: |
            APP_VERSION=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

      - name: Deploy notification
        run: |
          echo "Deployed image with digest: ${{ steps.docker.outputs.image-digest }}"

Reusable Workflows vs Composite Actions

The two mechanisms serve different purposes. The pipeline architecture only stays clean when the selection criteria are clear.

ItemReusable WorkflowsComposite Actions
Level of operationJob levelStep level
Secret accessPassed directly with the secrets keywordCan only be passed as environment variables
Nested callsUp to 10 levelsUp to 10 levels
Conditional executionif at the job levelif at the step level
Runner selectionThe callee can choose the runnerRuns on the caller's runner
How it is calledjobs.xxx.uses:steps.xxx.uses:
Passing outputsWorkflow outputs supportedStep outputs supported
EnvironmentA separate environment can be specifiedShares the caller's environment
Maximum number of calls50 per workflowNo limit
Best-suited scenarioA template for a whole pipelineA module of shared steps

Selection guide:

Monorepo CI/CD Optimization

The most important principle in a monorepo is building only the packages that changed. Building the entire repository on every change makes CI time grow exponentially.

Change Detection with dorny/paths-filter

dorny/paths-filter produces boolean filters from the file paths changed in a PR or a push. Use those filters as the condition on downstream jobs and you can selectively build only the packages that changed.

The Dynamic matrix Strategy and the fromJSON Pattern

A static matrix always runs every combination. In a monorepo, generating the list of changed packages dynamically and injecting it into the matrix is an essential pattern. The fromJSON function converts a JSON string into a matrix value.

Monorepo path-filter + dynamic matrix YAML

Below is a working workflow that combines change detection with a dynamic matrix in a monorepo.

# .github/workflows/monorepo-ci.yml
name: Monorepo CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.changes }}
      api-changed: ${{ steps.filter.outputs.api }}
      web-changed: ${{ steps.filter.outputs.web }}
      shared-changed: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v4

      - name: Detect changed packages
        uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
              - 'packages/shared/**'
            web:
              - 'packages/web/**'
              - 'packages/shared/**'
            shared:
              - 'packages/shared/**'
            docs:
              - 'packages/docs/**'

  build-matrix:
    needs: detect-changes
    if: needs.detect-changes.outputs.packages != '[]'
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - name: Build dynamic matrix
        id: set-matrix
        run: |
          CHANGES='${{ needs.detect-changes.outputs.packages }}'
          MATRIX=$(echo "$CHANGES" | jq -c '{package: .}')
          echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"

  build:
    needs: build-matrix
    if: needs.build-matrix.outputs.matrix != ''
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.build-matrix.outputs.matrix) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build package
        run: npm run build --workspace=packages/${{ matrix.package }}

      - name: Test package
        run: npm run test --workspace=packages/${{ matrix.package }}

  integration-test:
    needs: [detect-changes, build]
    if: >-
      needs.detect-changes.outputs.api-changed == 'true' ||
      needs.detect-changes.outputs.web-changed == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install all dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

  deploy:
    needs: [detect-changes, build, integration-test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - package: api
            changed: ${{ needs.detect-changes.outputs.api-changed }}
          - package: web
            changed: ${{ needs.detect-changes.outputs.web-changed }}
      fail-fast: false
    steps:
      - name: Skip if not changed
        if: matrix.changed != 'true'
        run: echo "Skipping deploy for ${{ matrix.package }} (no changes)"

      - uses: actions/checkout@v4
        if: matrix.changed == 'true'

      - name: Deploy
        if: matrix.changed == 'true'
        run: |
          echo "Deploying ${{ matrix.package }}..."
          # Actual deployment logic

Caching Optimization YAML

In a monorepo, the key to a caching strategy is isolation per package. Manage the global cache and the per-package caches in layers.

# .github/workflows/cache-optimized.yml
name: Cache Optimized Build

on:
  push:
    branches: [main]
  pull_request:

env:
  TURBO_CACHE_DIR: .turbo

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      # Step 1: npm dependency cache (based on package-lock.json)
      - name: Setup Node.js with dependency cache
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Step 2: use a local cache instead of the Turborepo remote cache
      - name: Cache Turborepo
        uses: actions/cache@v4
        with:
          path: .turbo
          key: turbo-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            turbo-${{ runner.os }}-

      # Step 3: Next.js build cache (incremental build per page)
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: packages/web/.next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('packages/web/**/*.ts', 'packages/web/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-

      # Step 4: ESLint cache
      - name: Cache ESLint
        uses: actions/cache@v4
        with:
          path: .eslintcache
          key: eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-${{ github.sha }}
          restore-keys: |
            eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-
            eslint-${{ runner.os }}-

      # Step 5: Jest cache
      - name: Cache Jest
        uses: actions/cache@v4
        with:
          path: /tmp/jest_rs
          key: jest-${{ runner.os }}-${{ hashFiles('**/jest.config.*') }}
          restore-keys: |
            jest-${{ runner.os }}-

      - name: Install dependencies
        run: npm ci

      - name: Build with Turborepo
        run: npx turbo run build --cache-dir=.turbo

      - name: Test with Turborepo
        run: npx turbo run test --cache-dir=.turbo

      - name: Lint with cache
        run: npx turbo run lint --cache-dir=.turbo

Applying the cache strategy above produces the following effect on a typical monorepo:

Cached itemOn a cache missOn a cache hitReduction
npm dependencies45~90s5~10s80~90%
Turborepo build120~300s3~8s95%+
Next.js incremental build60~180s10~30s70~85%
ESLint analysis30~60s5~15s60~75%
Jest testsNot cacheabletransform cache20~40%

Production Operations Guide

Workflow Versioning Strategy

How a Reusable Workflow or a Composite Action is referenced changes stability and security a great deal.

Reference styleExampleUpsideDownside
Branchuses: org/repo/.github/workflows/ci.yml@mainAlways the latest versionCan break without warning
Taguses: org/repo/.github/workflows/ci.yml@v2.3.0Stable, carries SemVer meaningThe tag can be overwritten
Commit SHAuses: org/repo/.github/workflows/ci.yml@a1b2c3d4Safest, cannot change underneath youPoor readability

Recommended strategy: use a branch reference while developing and a SHA reference in production. Set up Dependabot or Renovate and it opens a PR automatically whenever the SHA is updated.

Organization-Wide Governance and Standardization

Enforce the following at the organization level:

Security: OIDC and Least Privilege

For cloud deployments, instead of storing long-lived credentials (access keys and the like) in secrets, use OIDC (OpenID Connect) to obtain a temporary token.

# Example AWS deployment using OIDC
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: ap-northeast-2
          role-session-name: github-actions-${{ github.run_id }}

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-api \
            --force-new-deployment

This approach removes the risk of a secret leaking at the root, and the trust policy on the IAM role can allow only a specific repository and branch.

Failure Cases and Troubleshooting

Case 1: A Deployment Halted by a Failed Secret Handoff

Symptom: the AWS deployment inside the Reusable Workflow fails. The error message is "credentials not found".

Cause: the caller workflow used explicit secret passing instead of secrets: inherit, and the newly added AWS_ROLE_ARN secret was never passed.

Lesson: secrets: inherit is convenient, but it makes it hard to trace which secrets are actually being passed. Prefer explicit passing in production, and add a validation step in CI so that updating the caller is not forgotten when a new secret is added.

Fix pattern:

# Caller workflow that validates the secret handoff
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Validate required secrets
        run: |
          MISSING=""
          if [ -z "$AWS_ROLE" ]; then MISSING="$MISSING AWS_ROLE_ARN"; fi
          if [ -z "$NPM_TK" ]; then MISSING="$MISSING NPM_TOKEN"; fi
          if [ -n "$MISSING" ]; then
            echo "::error::Missing required secrets:$MISSING"
            exit 1
          fi
        env:
          AWS_ROLE: ${{ secrets.AWS_ROLE_ARN }}
          NPM_TK: ${{ secrets.NPM_TOKEN }}

  deploy:
    needs: validate
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Case 2: CI Time Exploding Because of Cache Misses

Symptom: every Monday morning the monorepo CI time rises from the usual 5 minutes to 25 minutes.

Cause: a GitHub Actions cache is deleted automatically after 7 days without use. The cache expired over the weekend, so every cache came back as a miss on Monday's first build.

Fix: refresh the cache over the weekend with a scheduled workflow, and set the restore-keys pattern up in layers.

Debugging Checklist

When something goes wrong, check in this order:

  1. Check workflow permissions: does the permissions block include every permission that is needed
  2. Check the secret scope: is it an Organization secret, a Repository secret, or an Environment secret
  3. Check the cache key pattern: does the hashFiles path point at files that actually exist
  4. Reusable Workflow access rights: has Actions access been allowed in the Settings of the callee repository
  5. Validate the matrix value: confirm that the string handed to fromJSON is valid JSON
  6. Environment variable scope: remember that the caller's env is not passed into a Reusable Workflow

Operational Considerations

Call Limits and Concurrency Management

GitHub Actions imposes the following limits:

To head off concurrency problems, configure a concurrency group:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

This keeps deployments for the same branch from running at the same time, and cancel-in-progress: false means a deployment already in flight is not cancelled.

Watch the Environment Variable Scope

The most common mistake with Reusable Workflows is confusing the environment variable scope:

Any value you need has to be passed explicitly through inputs.

Performance Tips for Large Monorepos

- uses: actions/checkout@v4
  with:
    sparse-checkout: |
      packages/api
      packages/shared
      package.json
      package-lock.json
    sparse-checkout-cone-mode: false

References

Comments

No comments yet.

Sign in to leave a comment