Questions
100 repositories duplicate the same CI workflow. Design a reusable workflow architecture.
The Scenario
Your organization has 100+ repositories, each with copy-pasted CI workflows:
Problems identified:
- Security patch needed → update 100 workflows manually
- Inconsistent quality → some repos skip tests
- Tribal knowledge → each team customizes differently
- Maintenance burden → platform team can't keep up
Leadership wants standardization while allowing teams flexibility for their specific needs.
The Challenge
Design a reusable workflow architecture that provides organization-wide standards, reduces duplication, and allows customization where needed.
A junior engineer might create one giant workflow that tries to handle everything, force all repos to use identical configurations, or just write documentation hoping teams follow it. These approaches create inflexible monoliths, kill productivity, or result in drift.
A senior engineer designs a layered architecture with reusable workflows for common patterns, composite actions for shared steps, sensible defaults with override capabilities, and proper versioning for stability.
Step 1: Design the Architecture
.github repository (organization level)
├── workflow-templates/ # Starter templates for new repos
│ ├── ci-node.yml
│ ├── ci-python.yml
│ └── ci-docker.yml
│
├── actions/ # Composite actions
│ ├── setup-node-project/
│ │ └── action.yml
│ ├── deploy-to-k8s/
│ │ └── action.yml
│ └── notify-slack/
│ └── action.yml
│
└── .github/
└── workflows/ # Reusable workflows (callable)
├── ci-node.yml
├── ci-python.yml
├── deploy-staging.yml
├── deploy-production.yml
└── security-scan.ymlStep 2: Create Reusable CI Workflow
# .github/workflows/ci-node.yml (in .github repo)
name: Node.js CI (Reusable)
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version to use'
required: false
type: string
default: '18'
working-directory:
description: 'Working directory for commands'
required: false
type: string
default: '.'
run-e2e:
description: 'Run E2E tests'
required: false
type: boolean
default: false
coverage-threshold:
description: 'Minimum coverage percentage'
required: false
type: number
default: 80
secrets:
NPM_TOKEN:
required: false
CODECOV_TOKEN:
required: false
outputs:
coverage:
description: 'Test coverage percentage'
value: ${{ jobs.test.outputs.coverage }}
jobs:
lint:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
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
run: npm ci
- name: Run linting
run: npm run lint
- name: Type check
run: npm run typecheck || true
test:
runs-on: ubuntu-latest
outputs:
coverage: ${{ steps.coverage.outputs.percentage }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
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
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Extract coverage
id: coverage
run: |
coverage=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
echo "percentage=$coverage" >> $GITHUB_OUTPUT
if (( $(echo "$coverage < ${{ inputs.coverage-threshold }}" | bc -l) )); then
echo "::error::Coverage $coverage% is below threshold ${{ inputs.coverage-threshold }}%"
exit 1
fi
- name: Upload coverage
if: secrets.CODECOV_TOKEN != ''
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e:
if: inputs.run-e2e
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
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
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
build:
needs: [lint, test]
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
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
run: npm ci
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: ${{ inputs.working-directory }}/dist/
retention-days: 7Step 3: Create Reusable Deployment Workflow
# .github/workflows/deploy.yml (in .github repo)
name: Deploy (Reusable)
on:
workflow_call:
inputs:
environment:
description: 'Deployment environment'
required: true
type: string
image-tag:
description: 'Docker image tag to deploy'
required: true
type: string
app-name:
description: 'Application name'
required: true
type: string
notify-slack:
description: 'Send Slack notification'
required: false
type: boolean
default: true
secrets:
AWS_ROLE_ARN:
required: true
SLACK_WEBHOOK:
required: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy to EKS
run: |
aws eks update-kubeconfig --name ${{ inputs.environment }}-cluster
kubectl set image deployment/${{ inputs.app-name }} \
app=${{ inputs.image-tag }} \
-n ${{ inputs.app-name }}
kubectl rollout status deployment/${{ inputs.app-name }} \
-n ${{ inputs.app-name }} \
--timeout=300s
- name: Notify Slack
if: inputs.notify-slack && always()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deployment to ${{ inputs.environment }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*${{ inputs.app-name }}* deployed to *${{ inputs.environment }}*\nImage: `${{ inputs.image-tag }}`\nStatus: ${{ job.status }}"
}
}
]
}Step 4: Caller Workflow (in Application Repo)
# .github/workflows/ci.yml (in application repo)
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
ci:
uses: my-org/.github/.github/workflows/ci-node.yml@v2
with:
node-version: '20'
run-e2e: ${{ github.ref == 'refs/heads/main' }}
coverage-threshold: 85
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-image:
needs: ci
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.build.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Build and push
id: build
run: |
TAG="registry.company.com/myapp:${{ github.sha }}"
docker build -t $TAG .
docker push $TAG
echo "tag=$TAG" >> $GITHUB_OUTPUT
deploy-staging:
needs: build-image
uses: my-org/.github/.github/workflows/deploy.yml@v2
with:
environment: staging
app-name: myapp
image-tag: ${{ needs.build-image.outputs.image-tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN_STAGING }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
deploy-production:
needs: [build-image, deploy-staging]
uses: my-org/.github/.github/workflows/deploy.yml@v2
with:
environment: production
app-name: myapp
image-tag: ${{ needs.build-image.outputs.image-tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN_PROD }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}Step 5: Version and Release Reusable Workflows
# .github/workflows/release-workflows.yml (in .github repo)
name: Release Workflows
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: trueStep 6: Workflow Template for New Repos
# workflow-templates/ci-node.yml
name: Node.js CI
on:
push:
branches: [$default-branch]
pull_request:
branches: [$default-branch]
jobs:
ci:
uses: my-org/.github/.github/workflows/ci-node.yml@v2
with:
node-version: '18'
secrets: inherit# workflow-templates/ci-node.properties.json
{
"name": "Node.js CI",
"description": "Standard Node.js CI workflow with linting, testing, and building",
"iconName": "nodejs",
"categories": ["Node.js", "JavaScript", "TypeScript"],
"filePatterns": ["package.json"]
} Reusable Workflow Best Practices
| Practice | Why | How |
|---|---|---|
| Semantic versioning | Consumers can pin stable versions | Tag releases: v1, v1.2, v1.2.3 |
| Sensible defaults | Reduce configuration burden | Set reasonable default values |
| Document inputs/outputs | Teams know what’s available | Use descriptions, add README |
| Test workflows | Catch breaks before consumers | Create test repos that use workflows |
| Limit required secrets | Reduce setup friction | Make secrets optional where possible |
Practice Question
What is the key difference between reusable workflows (workflow_call) and composite actions in GitHub Actions?