DeployU
Interviews / DevOps & Cloud Infrastructure / 100 repositories duplicate the same CI workflow. Design a reusable workflow architecture.

100 repositories duplicate the same CI workflow. Design a reusable workflow architecture.

architecture Reusable Workflows Interactive Quiz Code Examples

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.

Wrong Approach

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.

Right Approach

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.yml

Step 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: 7

Step 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: true

Step 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

PracticeWhyHow
Semantic versioningConsumers can pin stable versionsTag releases: v1, v1.2, v1.2.3
Sensible defaultsReduce configuration burdenSet reasonable default values
Document inputs/outputsTeams know what’s availableUse descriptions, add README
Test workflowsCatch breaks before consumersCreate test repos that use workflows
Limit required secretsReduce setup frictionMake secrets optional where possible

Practice Question

What is the key difference between reusable workflows (workflow_call) and composite actions in GitHub Actions?