- Introduction
- Reusable Workflows in Depth
- Building Composite Actions
- Reusable Workflows vs Composite Actions
- Monorepo CI/CD Optimization
- Production Operations Guide
- Failure Cases and Troubleshooting
- Operational Considerations
- References

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:
- Nested calls are supported up to 10 levels (a chain where A calls B and B calls C)
- Up to 50 Reusable Workflow calls are possible from a single workflow file
- Caller and callee must belong to the same organization, or the callee repository must be public
- The
envcontext is not passed to the called workflow
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
| Item | JavaScript Action | Docker Action | Composite Action |
|---|---|---|---|
| Execution environment | Node.js runtime | Docker container | Runs directly on the runner |
| Startup time | Fast (a few seconds) | Slow (image pull) | Fast (a few seconds) |
| Platform compatibility | Linux/macOS/Windows | Linux only | Depends on the runner OS |
| Complex logic | Ideal | Workable | Shell script level |
| Reusing existing tools | Uses npm packages | Any tool can be installed | Can compose other actions |
| Maintenance difficulty | Medium (needs a build) | Low (Dockerfile) | Low (YAML only) |
| Secret access | Direct access possible | Direct access possible | Passed 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.
| Item | Reusable Workflows | Composite Actions |
|---|---|---|
| Level of operation | Job level | Step level |
| Secret access | Passed directly with the secrets keyword | Can only be passed as environment variables |
| Nested calls | Up to 10 levels | Up to 10 levels |
| Conditional execution | if at the job level | if at the step level |
| Runner selection | The callee can choose the runner | Runs on the caller's runner |
| How it is called | jobs.xxx.uses: | steps.xxx.uses: |
| Passing outputs | Workflow outputs supported | Step outputs supported |
| Environment | A separate environment can be specified | Shares the caller's environment |
| Maximum number of calls | 50 per workflow | No limit |
| Best-suited scenario | A template for a whole pipeline | A module of shared steps |
Selection guide:
- Reach for Reusable Workflows when you want to standardize an entire CI/CD pipeline. Turn the whole build-test-deploy sequence into one template and let each team pass different inputs.
- Reach for Composite Actions when you want to modularize a specific task (a Docker build, a Slack notification, a cache restore, and so on). Reuse it at the step level across many workflows.
- Combining the two is the most powerful option. Calling a Composite Action from inside a Reusable Workflow lets you manage the overall pipeline flow as a workflow while the detailed steps stay modularized as actions.
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 item | On a cache miss | On a cache hit | Reduction |
|---|---|---|---|
| npm dependencies | 45~90s | 5~10s | 80~90% |
| Turborepo build | 120~300s | 3~8s | 95%+ |
| Next.js incremental build | 60~180s | 10~30s | 70~85% |
| ESLint analysis | 30~60s | 5~15s | 60~75% |
| Jest tests | Not cacheable | transform cache | 20~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 style | Example | Upside | Downside |
|---|---|---|---|
| Branch | uses: org/repo/.github/workflows/ci.yml@main | Always the latest version | Can break without warning |
| Tag | uses: org/repo/.github/workflows/ci.yml@v2.3.0 | Stable, carries SemVer meaning | The tag can be overwritten |
| Commit SHA | uses: org/repo/.github/workflows/ci.yml@a1b2c3d4 | Safest, cannot change underneath you | Poor 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:
- Required Workflows: from Organization Settings you can force a particular Reusable Workflow to run on every repository. Use it for security scans, license checks, and the like.
- CODEOWNERS: set CODEOWNERS on the
.github/workflows/directory to force a platform team review whenever a workflow changes. - Restricting workflow permissions: at the organization level, set the default permission of
GITHUB_TOKENtoreadand make workflows declare only the permissions they need.
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:
- Check workflow permissions: does the
permissionsblock include every permission that is needed - Check the secret scope: is it an Organization secret, a Repository secret, or an Environment secret
- Check the cache key pattern: does the
hashFilespath point at files that actually exist - Reusable Workflow access rights: has Actions access been allowed in the Settings of the callee repository
- Validate the matrix value: confirm that the string handed to
fromJSONis valid JSON - Environment variable scope: remember that the caller's
envis not passed into a Reusable Workflow
Operational Considerations
Call Limits and Concurrency Management
GitHub Actions imposes the following limits:
- Concurrent jobs: 20 on the Free plan, 60 on Team, 180 on Enterprise
- Workflow run queue: up to 500 per repository
- Maximum matrix combinations: 256
- Workflow run time: up to 6 hours (unlimited on self-hosted)
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:
- Environment variables defined with the
envkeyword are not passed to a Reusable Workflow - The
varscontext (Repository/Organization variables) is accessible from a Reusable Workflow - For the
githubcontext, the caller's values are passed into the Reusable Workflow
Any value you need has to be passed explicitly through inputs.
Performance Tips for Large Monorepos
- Use sparse checkout: check out only the directories of the packages that changed to shorten checkout time.
- uses: actions/checkout@v4
with:
sparse-checkout: |
packages/api
packages/shared
package.json
package-lock.json
sparse-checkout-cone-mode: false
- Turborepo Remote Cache: using Vercel's Remote Cache or a self-hosted cache server lets you share the build cache between local development and CI.
- Run only the affected packages: use Nx's
nx affectedor Turborepo's change detection to build only the packages the dependency graph says were affected. - Spread jobs in parallel: parallelize the per-package builds with a matrix strategy, but split the shared library out into a preceding build.