Advanced Workflows and Automation
Learn complex GitHub Actions patterns including matrix builds, conditional logic, and workflow orchestration for sophisticated automation.
Simple workflows are great for getting started, but real projects often need more sophisticated automation. You might need to test across multiple operating systems, coordinate complex deployment sequences, or build different versions of your application for different platforms. As your projects grow, your automation needs to grow with them.
The challenge is building complex workflows that remain maintainable and debuggable. It's easy to create workflows that work but are impossible to understand or modify later. Understanding advanced GitHub Actions patterns helps you build powerful automation that your team can actually maintain.
Matrix Builds for Multi-Platform Testing
Matrix builds let you run the same workflow across multiple configurations simultaneously. Instead of creating separate workflows for each platform or version, you define a matrix of variables and GitHub Actions runs your workflow for each combination.
Here's how matrix builds work:
Single Configuration (Traditional):
┌─────────────────────────────────┐
│ One Job │
│ OS: ubuntu-latest │
│ Node: 18 │
│ │
│ ┌─────────────────────────┐ │
│ │ 1. Checkout │ │
│ │ 2. Setup Node.js 18 │ │
│ │ 3. Install deps │ │
│ │ 4. Run tests │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
Matrix Configuration (Parallel):
Matrix: { os: [ubuntu-latest, windows-latest, macos-latest],
node: [16, 18, 20] }
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Job 1-1 │ │ Job 1-2 │ │ Job 1-3 │
│ ubuntu + node16 │ │ ubuntu + node18 │ │ ubuntu + node20 │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │1. Checkout │ │ │ │1. Checkout │ │ │ │1. Checkout │ │
│ │2. Setup N16 │ │ │ │2. Setup N18 │ │ │ │2. Setup N20 │ │
│ │3. Install │ │ │ │3. Install │ │ │ │3. Install │ │
│ │4. Test │ │ │ │4. Test │ │ │ │4. Test │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Job 2-1 │ │ Job 2-2 │ │ Job 2-3 │
│ windows + node16│ │ windows + node18│ │ windows + node20│
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │1. Checkout │ │ │ │1. Checkout │ │ │ │1. Checkout │ │
│ │2. Setup N16 │ │ │ │2. Setup N18 │ │ │ │2. Setup N20 │ │
│ │3. Install │ │ │ │3. Install │ │ │ │3. Install │ │
│ │4. Test │ │ │ │4. Test │ │ │ │4. Test │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Job 3-1 │ │ Job 3-2 │ │ Job 3-3 │
│ macos + node16 │ │ macos + node18 │ │ macos + node20 │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │1. Checkout │ │ │ │1. Checkout │ │ │ │1. Checkout │ │
│ │2. Setup N16 │ │ │ │2. Setup N18 │ │ │ │2. Setup N20 │ │
│ │3. Install │ │ │ │3. Install │ │ │ │3. Install │ │
│ │4. Test │ │ │ │4. Test │ │ │ │4. Test │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Result: 9 parallel jobs test all combinations!
Here's a practical example that tests a Node.js application across multiple versions and operating systems:
name: Cross-Platform Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
# Don't cancel other matrix jobs if one fails
fail-fast: false
matrix:
# Test on multiple operating systems
os: [ubuntu-latest, windows-latest, macos-latest]
# Test multiple Node.js versions
node-version: [16, 18, 20]
# Exclude problematic combinations
exclude:
# Node 16 has issues on Windows in our specific setup
- os: windows-latest
node-version: 16
# Add specific configurations
include:
# Test latest Node.js on Ubuntu with additional checks
- os: ubuntu-latest
node-version: 20
run-integration-tests: true
run-performance-tests: true
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run integration tests
if: matrix.run-integration-tests
run: npm run test:integration
- name: Run performance tests
if: matrix.run-performance-tests
run: npm run test:performance
- name: Build application
run: npm run build
# Upload test results with matrix info in the name
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.os }}-node${{ matrix.node-version }}
path: |
coverage/
test-results.xml
The matrix strategy creates separate jobs for each combination of OS and Node.js version. The fail-fast: false
setting ensures that if one combination fails, the others continue running so you can see the full picture of what works and what doesn't.
Dynamic Matrix Generation
Sometimes you need to generate matrix configurations dynamically based on your repository contents or external data:
name: Dynamic Multi-Service Build
on:
push:
branches: [main]
jobs:
# Generate the matrix based on directories in the repository
generate-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.generate.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Generate service matrix
id: generate
run: |
# Find all directories that contain a Dockerfile
services=$(find . -name "Dockerfile" -type f | \
sed 's|/Dockerfile||' | \
sed 's|^\./||' | \
jq -R -s -c 'split("\n")[:-1]')
echo "Found services: $services"
echo "matrix={\"service\":$services}" >> $GITHUB_OUTPUT
# Build each service found in the previous job
build-services:
runs-on: ubuntu-latest
needs: generate-matrix
if: needs.generate-matrix.outputs.matrix != '{"service":[]}'
strategy:
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Build ${{ matrix.service }}
run: |
echo "Building service: ${{ matrix.service }}"
cd ${{ matrix.service }}
# Build Docker image
docker build -t ${{ matrix.service }}:${{ github.sha }} .
# Run service-specific tests if they exist
if [ -f "test.sh" ]; then
echo "Running tests for ${{ matrix.service }}"
chmod +x test.sh
./test.sh
fi
- name: Upload service artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.service }}-build
path: ${{ matrix.service }}/dist/
This pattern is useful for monorepos where you want to automatically build and test all services without manually maintaining a list of what exists.
Conditional Workflows and Smart Triggers
Not every workflow needs to run on every change. Smart conditional logic helps you run only the workflows that are actually needed:
name: Smart Conditional Builds
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# Detect what changed to decide what to build
detect-changes:
runs-on: ubuntu-latest
outputs:
frontend-changed: ${{ steps.changes.outputs.frontend }}
backend-changed: ${{ steps.changes.outputs.backend }}
docs-changed: ${{ steps.changes.outputs.docs }}
docker-changed: ${{ steps.changes.outputs.docker }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed files
id: changes
run: |
# Compare with the merge base for PRs, or previous commit for pushes
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
# Check if frontend files changed
if git diff --name-only $BASE HEAD | grep -E '^(frontend/|src/.*\.(js|jsx|ts|tsx|css|scss))'; then
echo "frontend=true" >> $GITHUB_OUTPUT
else
echo "frontend=false" >> $GITHUB_OUTPUT
fi
# Check if backend files changed
if git diff --name-only $BASE HEAD | grep -E '^(backend/|api/|server/)'; then
echo "backend=true" >> $GITHUB_OUTPUT
else
echo "backend=false" >> $GITHUB_OUTPUT
fi
# Check if documentation changed
if git diff --name-only $BASE HEAD | grep -E '\.(md|rst|txt)$'; then
echo "docs=true" >> $GITHUB_OUTPUT
else
echo "docs=false" >> $GITHUB_OUTPUT
fi
# Check if Docker files changed
if git diff --name-only $BASE HEAD | grep -E '(Dockerfile|docker-compose)'; then
echo "docker=true" >> $GITHUB_OUTPUT
else
echo "docker=false" >> $GITHUB_OUTPUT
fi
# Only build frontend if frontend files changed
build-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend-changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
- name: Upload frontend build
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: frontend/dist/
# Only build backend if backend files changed
build-backend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.backend-changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build backend
run: |
cd backend
go mod download
go build -o app ./cmd/server
- name: Upload backend build
uses: actions/upload-artifact@v4
with:
name: backend-build
path: backend/app
# Only build Docker images if Docker files or application code changed
build-docker:
runs-on: ubuntu-latest
needs: detect-changes
if: |
needs.detect-changes.outputs.docker-changed == 'true' ||
needs.detect-changes.outputs.frontend-changed == 'true' ||
needs.detect-changes.outputs.backend-changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Build Docker images
run: |
echo "Building Docker images..."
docker build -t myapp:${{ github.sha }} .
# Deploy only if application code changed and we're on main branch
deploy:
runs-on: ubuntu-latest
needs: [detect-changes, build-frontend, build-backend]
if: |
always() &&
github.ref == 'refs/heads/main' &&
(needs.detect-changes.outputs.frontend-changed == 'true' ||
needs.detect-changes.outputs.backend-changed == 'true') &&
(needs.build-frontend.result == 'success' || needs.build-frontend.result == 'skipped') &&
(needs.build-backend.result == 'success' || needs.build-backend.result == 'skipped')
steps:
- name: Deploy application
run: echo "Deploying application..."
This approach saves significant CI/CD time and resources by only running builds when they're actually needed.
Workflow Orchestration with Reusable Workflows
As your automation grows, you'll find yourself repeating similar patterns across multiple repositories. Reusable workflows let you share common automation patterns:
# .github/workflows/reusable-node-build.yml
name: Reusable Node.js Build
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '18'
type: string
working-directory:
description: 'Working directory for Node.js app'
required: false
default: '.'
type: string
run-tests:
description: 'Whether to run tests'
required: false
default: true
type: boolean
secrets:
NPM_TOKEN:
description: 'NPM authentication token'
required: false
outputs:
build-version:
description: 'Version of the built application'
value: ${{ jobs.build.outputs.version }}
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: Configure npm authentication
if: secrets.NPM_TOKEN != ''
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Run tests
if: inputs.run-tests
working-directory: ${{ inputs.working-directory }}
run: npm test
- name: Build application
working-directory: ${{ inputs.working-directory }}
run: npm run build
- name: Get version
id: version
working-directory: ${{ inputs.working-directory }}
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts-${{ steps.version.outputs.version }}
path: ${{ inputs.working-directory }}/dist/
Now you can use this reusable workflow in multiple repositories:
# In any repository
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-frontend:
uses: ./.github/workflows/reusable-node-build.yml
with:
working-directory: frontend
node-version: '18'
run-tests: true
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
build-backend:
uses: ./.github/workflows/reusable-node-build.yml
with:
working-directory: backend
node-version: '20'
run-tests: false
deploy:
needs: [build-frontend, build-backend]
runs-on: ubuntu-latest
steps:
- name: Deploy application
run: |
echo "Frontend version: ${{ needs.build-frontend.outputs.build-version }}"
echo "Backend version: ${{ needs.build-backend.outputs.build-version }}"
# Deploy logic here
Complex Conditional Logic
Sometimes you need more sophisticated conditional logic than simple if statements can provide:
name: Advanced Conditional Logic
on:
push:
branches: [main, develop, 'feature/*', 'hotfix/*']
pull_request:
branches: [main, develop]
jobs:
determine-strategy:
runs-on: ubuntu-latest
outputs:
deploy-environment: ${{ steps.strategy.outputs.environment }}
run-integration-tests: ${{ steps.strategy.outputs.integration-tests }}
notify-team: ${{ steps.strategy.outputs.notify }}
deployment-strategy: ${{ steps.strategy.outputs.strategy }}
steps:
- name: Determine deployment strategy
id: strategy
run: |
# Complex logic to determine what to do based on branch and event
BRANCH="${{ github.ref_name }}"
EVENT="${{ github.event_name }}"
echo "Branch: $BRANCH, Event: $EVENT"
# Determine environment
if [[ "$BRANCH" == "main" && "$EVENT" == "push" ]]; then
echo "environment=production" >> $GITHUB_OUTPUT
echo "integration-tests=true" >> $GITHUB_OUTPUT
echo "notify=true" >> $GITHUB_OUTPUT
echo "strategy=blue-green" >> $GITHUB_OUTPUT
elif [[ "$BRANCH" == "develop" && "$EVENT" == "push" ]]; then
echo "environment=staging" >> $GITHUB_OUTPUT
echo "integration-tests=true" >> $GITHUB_OUTPUT
echo "notify=false" >> $GITHUB_OUTPUT
echo "strategy=rolling" >> $GITHUB_OUTPUT
elif [[ "$BRANCH" =~ ^feature/ ]]; then
echo "environment=review" >> $GITHUB_OUTPUT
echo "integration-tests=false" >> $GITHUB_OUTPUT
echo "notify=false" >> $GITHUB_OUTPUT
echo "strategy=direct" >> $GITHUB_OUTPUT
elif [[ "$BRANCH" =~ ^hotfix/ ]]; then
echo "environment=hotfix" >> $GITHUB_OUTPUT
echo "integration-tests=true" >> $GITHUB_OUTPUT
echo "notify=true" >> $GITHUB_OUTPUT
echo "strategy=direct" >> $GITHUB_OUTPUT
elif [[ "$EVENT" == "pull_request" ]]; then
echo "environment=preview" >> $GITHUB_OUTPUT
echo "integration-tests=false" >> $GITHUB_OUTPUT
echo "notify=false" >> $GITHUB_OUTPUT
echo "strategy=direct" >> $GITHUB_OUTPUT
else
echo "environment=none" >> $GITHUB_OUTPUT
echo "integration-tests=false" >> $GITHUB_OUTPUT
echo "notify=false" >> $GITHUB_OUTPUT
echo "strategy=none" >> $GITHUB_OUTPUT
fi
deploy:
needs: determine-strategy
runs-on: ubuntu-latest
if: needs.determine-strategy.outputs.deploy-environment != 'none'
environment: ${{ needs.determine-strategy.outputs.deploy-environment }}
steps:
- name: Deploy using ${{ needs.determine-strategy.outputs.deployment-strategy }} strategy
run: |
echo "Deploying to ${{ needs.determine-strategy.outputs.deploy-environment }}"
echo "Using ${{ needs.determine-strategy.outputs.deployment-strategy }} strategy"
# Execute deployment based on strategy
case "${{ needs.determine-strategy.outputs.deployment-strategy }}" in
"blue-green")
echo "Executing blue-green deployment..."
;;
"rolling")
echo "Executing rolling deployment..."
;;
"direct")
echo "Executing direct deployment..."
;;
esac
integration-tests:
needs: [determine-strategy, deploy]
runs-on: ubuntu-latest
if: needs.determine-strategy.outputs.run-integration-tests == 'true'
steps:
- name: Run integration tests
run: echo "Running integration tests for ${{ needs.determine-strategy.outputs.deploy-environment }}"
notify:
needs: [determine-strategy, deploy, integration-tests]
runs-on: ubuntu-latest
if: always() && needs.determine-strategy.outputs.notify-team == 'true'
steps:
- name: Send notification
run: |
if [[ "${{ needs.deploy.result }}" == "success" && "${{ needs.integration-tests.result }}" == "success" ]]; then
echo "✅ Deployment successful!"
else
echo "❌ Deployment failed!"
fi
Parallel Workflows with Coordination
Sometimes you need multiple workflows to coordinate their activities:
# Primary build workflow
name: Build Application
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build application
run: echo "Building..."
- name: Trigger deployment workflow
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'deploy.yml',
ref: 'main',
inputs: {
build_sha: context.sha,
environment: 'staging'
}
});
# Separate deployment workflow
name: Deploy Application
on:
workflow_dispatch:
inputs:
build_sha:
description: 'Build SHA to deploy'
required: true
environment:
description: 'Environment to deploy to'
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy build ${{ github.event.inputs.build_sha }}
run: |
echo "Deploying ${{ github.event.inputs.build_sha }} to ${{ github.event.inputs.environment }}"
This separation allows you to trigger deployments independently of builds, which is useful for promoting the same build artifact through different environments.
Error Handling and Recovery
Advanced workflows need sophisticated error handling:
name: Robust Workflow with Error Handling
jobs:
deploy-with-retry:
runs-on: ubuntu-latest
steps:
- name: Deploy with automatic retry
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: |
echo "Attempting deployment..."
# Your deployment command here
if [ $RANDOM -lt 16384 ]; then
echo "Deployment failed, will retry..."
exit 1
else
echo "Deployment successful!"
fi
- name: Handle deployment failure
if: failure()
run: |
echo "Deployment failed after all retries"
# Send alert, create incident, etc.
- name: Cleanup on failure
if: failure()
run: |
echo "Running cleanup after failure..."
# Cleanup partially deployed resources
- name: Success notification
if: success()
run: |
echo "Deployment completed successfully"
Advanced workflows give you the power to handle complex scenarios, but they require careful planning and testing. Start with simpler patterns and gradually add complexity as your needs grow and your confidence with GitHub Actions increases.
In the next section, we'll explore monitoring, debugging, and optimization techniques that help you maintain reliable automation as your workflows become more sophisticated.
Found an issue?