When you have five repos all running similar CI pipelines, you have five places to update every time you change a Node version or add a security scan. Reusable workflows and composite actions fix this.
Prerequisites
- GitHub organization with multiple repos
- Basic YAML and GitHub Actions knowledge
- Organization admin or repo admin access
Reusable Workflows: Call One Workflow from Another
A reusable workflow is a workflow file that other workflows can call like a function. It lives in any repo (typically a shared platform or .github repo).
Defining a reusable workflow:
# .github/workflows/deploy-node.yml (in your platform repo)
name: Deploy Node App
on:
workflow_call:
inputs:
environment:
required: true
type: string
node-version:
required: false
type: string
default: '20'
secrets:
DEPLOY_TOKEN:
required: true
AWS_ROLE_ARN:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm test
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: eu-west-1
- name: Deploy
run: |
aws s3 sync ./dist s3://my-app-${{ inputs.environment }} --deleteCalling it from another repo:
# Any repo's .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main]
jobs:
deploy-staging:
uses: my-org/platform/.github/workflows/deploy-node.yml@main
with:
environment: staging
node-version: '20'
secrets:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE }}
deploy-prod:
needs: deploy-staging
uses: my-org/platform/.github/workflows/deploy-node.yml@main
with:
environment: production
secrets: inherit # Pass all secrets from calling workflowComposite Actions: Reusable Steps
Composite actions bundle multiple steps into a single uses: reference. Unlike reusable workflows, they run in the same job context.
# my-org/actions/setup-node-app/action.yml
name: 'Setup Node App'
description: 'Install deps, cache, and lint'
inputs:
node-version:
description: 'Node version'
default: '20'
run-lint:
description: 'Run ESLint'
default: 'true'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
shell: bash
- name: Lint
if: inputs.run-lint == 'true'
run: npm run lint
shell: bash# Any workflow using the composite action
steps:
- uses: actions/checkout@v4
- uses: my-org/actions/setup-node-app@v1
with:
node-version: '22'
run-lint: 'false'
- run: npm testSharing Secrets Across Repos
Option 1: Organization secrets (simplest)
GitHub Org → Settings → Secrets and variables → Actions → New organization secret
Access: Select repositories → pick which repos can use it
Option 2: OIDC with cloud providers (no long-lived secrets)
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-role
aws-region: eu-west-1OIDC is the gold standard: no secret rotation, no credential exposure — GitHub exchanges a signed JWT for a short-lived cloud token at runtime.
Matrix Strategy for Multi-Environment Testing
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18 # Skip Node 18 on Windows
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm testCaching for Speed
- name: Cache node_modules
uses: actions/cache@v4
id: cache-npm
with:
path: node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install (only if cache miss)
if: steps.cache-npm.outputs.cache-hit != 'true'
run: npm ciKey insight: hash the lockfile, not package.json. The lockfile changes when exact versions change; package.json changes when ranges change (which doesn't always mean new installs).
Common Pitfalls
- No
needs:on production job: withoutneeds: [staging], both staging and production deploy simultaneously on every push - Long-lived secrets in environment variables: use OIDC — secrets that never expire are a security liability
- Reusable workflow in the same repo only: reusable workflows called with
uses:must be in a public repo or the same org; use Actions for cross-org - Missing
shell: bashin composite actions: each step in a composite action needs an explicitshell:declaration