GitHub Actions Workflows
Overview¶
This is a complete, working example of a GitHub Actions workflow library called acme-workflows - a collection of reusable workflows and composite actions for standardized CI/CD pipelines. It demonstrates best practices from the GitHub Actions Style Guide, including reusable workflow design, composite action encapsulation, matrix testing, environment-gated deployments, and security scanning integration.
Project Purpose: A centralized workflow library that teams reference from their own repositories, ensuring consistent CI/CD practices across the organization without duplicating workflow definitions.
Repository Structure¶
acme-workflows/
├── .github/
│ ├── workflows/
│ │ ├── reusable-build-test.yml
│ │ ├── reusable-docker-build.yml
│ │ ├── reusable-deploy.yml
│ │ ├── reusable-security-scan.yml
│ │ └── self-test.yml
│ ├── actions/
│ │ ├── setup-env/
│ │ │ └── action.yml
│ │ └── notify/
│ │ └── action.yml
│ ├── dependabot.yml
│ ├── CODEOWNERS
│ └── pull_request_template.md
├── examples/
│ ├── caller-python.yml
│ ├── caller-node.yml
│ └── caller-fullstack.yml
├── docs/
│ ├── usage.md
│ └── migration.md
└── README.md
.github/workflows/reusable-build-test.yml¶
name: Reusable Build and Test
on:
workflow_call:
inputs:
language:
description: "Programming language (python, node)"
required: true
type: string
language-version:
description: "Language version to use"
required: true
type: string
working-directory:
description: "Directory containing the project"
required: false
type: string
default: "."
test-command:
description: "Command to run tests"
required: false
type: string
default: ""
lint-command:
description: "Command to run linters"
required: false
type: string
default: ""
artifact-name:
description: "Name for the build artifact"
required: false
type: string
default: ""
artifact-path:
description: "Path to upload as artifact"
required: false
type: string
default: ""
outputs:
test-result:
description: "Test execution result (pass/fail)"
value: ${{ jobs.test.outputs.result }}
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
if: inputs.lint-command != ''
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup-env
with:
language: ${{ inputs.language }}
language-version: ${{ inputs.language-version }}
working-directory: ${{ inputs.working-directory }}
- name: Run linters
run: ${{ inputs.lint-command }}
test:
runs-on: ubuntu-latest
if: inputs.test-command != ''
defaults:
run:
working-directory: ${{ inputs.working-directory }}
outputs:
result: ${{ steps.test.outcome }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup-env
with:
language: ${{ inputs.language }}
language-version: ${{ inputs.language-version }}
working-directory: ${{ inputs.working-directory }}
- name: Run tests
id: test
run: ${{ inputs.test-command }}
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ inputs.language }}-${{ inputs.language-version }}
path: |
${{ inputs.working-directory }}/coverage/
${{ inputs.working-directory }}/coverage.xml
${{ inputs.working-directory }}/htmlcov/
if-no-files-found: ignore
build:
runs-on: ubuntu-latest
needs: [lint, test]
if: |
always() &&
(needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup-env
with:
language: ${{ inputs.language }}
language-version: ${{ inputs.language-version }}
working-directory: ${{ inputs.working-directory }}
- name: Build
run: |
if [ "${{ inputs.language }}" = "python" ]; then
pip install build
python -m build
elif [ "${{ inputs.language }}" = "node" ]; then
npm run build
fi
- name: Upload artifact
if: inputs.artifact-name != ''
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ inputs.artifact-path }}
retention-days: 7
.github/workflows/reusable-docker-build.yml¶
name: Reusable Docker Build
on:
workflow_call:
inputs:
image-name:
description: "Docker image name (e.g., ghcr.io/org/app)"
required: true
type: string
context:
description: "Docker build context path"
required: false
type: string
default: "."
dockerfile:
description: "Path to Dockerfile"
required: false
type: string
default: "Dockerfile"
platforms:
description: "Target platforms (comma-separated)"
required: false
type: string
default: "linux/amd64"
push:
description: "Whether to push the image"
required: false
type: boolean
default: false
build-args:
description: "Docker build arguments (newline-separated KEY=VALUE)"
required: false
type: string
default: ""
outputs:
image-digest:
description: "Image digest of the built image"
value: ${{ jobs.build.outputs.digest }}
image-tag:
description: "Primary image tag"
value: ${{ jobs.build.outputs.tag }}
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
if: contains(inputs.platforms, ',')
uses: docker/setup-qemu-action@v3
- name: Log in to GitHub Container Registry
if: inputs.push
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image-name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
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
- name: Generate SBOM
if: inputs.push
uses: anchore/sbom-action@v0
with:
image: ${{ inputs.image-name }}@${{ steps.build.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM
if: inputs.push
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.spdx.json
.github/workflows/reusable-deploy.yml¶
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
description: "Target deployment environment"
required: true
type: string
image:
description: "Container image to deploy (with tag or digest)"
required: true
type: string
service-name:
description: "Service name for the deployment target"
required: true
type: string
region:
description: "Cloud provider region"
required: false
type: string
default: "us-east-1"
health-check-url:
description: "URL to check after deployment"
required: false
type: string
default: ""
rollback-on-failure:
description: "Whether to rollback on health check failure"
required: false
type: boolean
default: true
secrets:
AWS_ROLE_ARN:
description: "AWS IAM role ARN for OIDC authentication"
required: true
permissions:
contents: read
id-token: write
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: ${{ inputs.region }}
- name: Deploy to ECS
id: deploy
run: |
echo "Deploying ${{ inputs.image }} to ${{ inputs.service-name }}"
echo "Environment: ${{ inputs.environment }}"
echo "Region: ${{ inputs.region }}"
# Update ECS service with new image
aws ecs update-service \
--cluster "${{ inputs.service-name }}-cluster" \
--service "${{ inputs.service-name }}" \
--force-new-deployment \
--region "${{ inputs.region }}"
# Wait for deployment to stabilize
aws ecs wait services-stable \
--cluster "${{ inputs.service-name }}-cluster" \
--services "${{ inputs.service-name }}" \
--region "${{ inputs.region }}"
- name: Health check
if: inputs.health-check-url != ''
run: |
echo "Checking health at ${{ inputs.health-check-url }}"
for i in $(seq 1 10); do
status=$(curl -s -o /dev/null -w "%{http_code}" "${{ inputs.health-check-url }}" || true)
if [ "$status" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: status=$status, retrying in 10s..."
sleep 10
done
echo "Health check failed after 10 attempts"
exit 1
- name: Rollback on failure
if: failure() && inputs.rollback-on-failure
run: |
echo "Deployment failed, rolling back ${{ inputs.service-name }}"
aws ecs update-service \
--cluster "${{ inputs.service-name }}-cluster" \
--service "${{ inputs.service-name }}" \
--force-new-deployment \
--region "${{ inputs.region }}"
.github/workflows/reusable-security-scan.yml¶
name: Reusable Security Scan
on:
workflow_call:
inputs:
language:
description: "Programming language for SAST analysis"
required: true
type: string
working-directory:
description: "Directory containing the project"
required: false
type: string
default: "."
scan-dependencies:
description: "Whether to scan dependencies for vulnerabilities"
required: false
type: boolean
default: true
scan-secrets:
description: "Whether to scan for leaked secrets"
required: false
type: boolean
default: true
scan-sast:
description: "Whether to run static analysis"
required: false
type: boolean
default: true
severity-threshold:
description: "Minimum severity to report (low, medium, high, critical)"
required: false
type: string
default: "medium"
permissions:
contents: read
security-events: write
jobs:
dependency-scan:
runs-on: ubuntu-latest
if: inputs.scan-dependencies
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.34.1
with:
scan-type: fs
scan-ref: ${{ inputs.working-directory }}
severity: ${{ inputs.severity-threshold }}
format: sarif
output: trivy-results.sarif
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
secret-scan:
runs-on: ubuntu-latest
if: inputs.scan-secrets
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
sast:
runs-on: ubuntu-latest
if: inputs.scan-sast
steps:
- uses: actions/checkout@v4
- name: Run CodeQL analysis
uses: github/codeql-action/init@v3
with:
languages: ${{ inputs.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform analysis
uses: github/codeql-action/analyze@v3
.github/actions/setup-env/action.yml¶
name: "Setup Environment"
description: "Set up language runtime and install dependencies"
inputs:
language:
description: "Programming language (python, node)"
required: true
language-version:
description: "Language version"
required: true
working-directory:
description: "Project directory"
required: false
default: "."
runs:
using: composite
steps:
- name: Set up Python
if: inputs.language == 'python'
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.language-version }}
- name: Install Python dependencies
if: inputs.language == 'python'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
python -m pip install --upgrade pip
if [ -f pyproject.toml ]; then
pip install -e ".[dev]"
elif [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
if [ -f requirements-dev.txt ]; then
pip install -r requirements-dev.txt
fi
- name: Set up Node.js
if: inputs.language == 'node'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.language-version }}
cache: npm
cache-dependency-path: "${{ inputs.working-directory }}/package-lock.json"
- name: Install Node.js dependencies
if: inputs.language == 'node'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npm ci
.github/actions/notify/action.yml¶
name: "Send Notification"
description: "Send deployment or CI notification to Slack"
inputs:
status:
description: "Notification status (success, failure, cancelled)"
required: true
title:
description: "Notification title"
required: true
message:
description: "Notification message body"
required: false
default: ""
slack-webhook-url:
description: "Slack incoming webhook URL"
required: true
environment:
description: "Deployment environment (if applicable)"
required: false
default: ""
runs:
using: composite
steps:
- name: Set status emoji
id: emoji
shell: bash
run: |
case "${{ inputs.status }}" in
success) echo "emoji=✅" >> "$GITHUB_OUTPUT" ;;
failure) echo "emoji=❌" >> "$GITHUB_OUTPUT" ;;
*) echo "emoji=⚠️" >> "$GITHUB_OUTPUT" ;;
esac
- name: Send Slack notification
shell: bash
env:
SLACK_WEBHOOK: ${{ inputs.slack-webhook-url }}
run: |
env_text=""
if [ -n "${{ inputs.environment }}" ]; then
env_text=" | Environment: \`${{ inputs.environment }}\`"
fi
curl -s -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{
\"blocks\": [
{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"${{ steps.emoji.outputs.emoji }} *${{ inputs.title }}*${env_text}\n${{ inputs.message }}\"
}
},
{
\"type\": \"context\",
\"elements\": [
{
\"type\": \"mrkdwn\",
\"text\": \"<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run> | Branch: \`${{ github.ref_name }}\` | Actor: ${{ github.actor }}\"
}
]
}
]
}"
.github/workflows/self-test.yml¶
name: Self Test
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test-python-workflow:
uses: ./.github/workflows/reusable-build-test.yml
with:
language: python
language-version: "3.12"
working-directory: "examples/python-sample"
lint-command: "black --check . && flake8"
test-command: "pytest -v"
test-node-workflow:
uses: ./.github/workflows/reusable-build-test.yml
with:
language: node
language-version: "22"
working-directory: "examples/node-sample"
lint-command: "npm run lint"
test-command: "npm test"
test-docker-workflow:
uses: ./.github/workflows/reusable-docker-build.yml
with:
image-name: ghcr.io/${{ github.repository }}/test
context: "examples/python-sample"
push: false
test-security-workflow:
uses: ./.github/workflows/reusable-security-scan.yml
with:
language: python
working-directory: "examples/python-sample"
scan-sast: false
.github/dependabot.yml¶
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
labels:
- dependencies
- github-actions
commit-message:
prefix: "chore(deps):"
groups:
github-actions:
patterns:
- "*"
.github/CODEOWNERS¶
# Workflow definitions require platform team review
.github/workflows/ @acme-org/platform-team
.github/actions/ @acme-org/platform-team
# Documentation can be reviewed by any maintainer
docs/ @acme-org/maintainers
README.md @acme-org/maintainers
# Examples can be reviewed by contributors
examples/ @acme-org/contributors
.github/pull_request_template.md¶
## Summary
<!-- Brief description of changes -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature causing existing functionality to change)
- [ ] Documentation update
## Checklist
- [ ] Self-test workflow passes
- [ ] Documentation updated (if applicable)
- [ ] Example caller workflows updated (if applicable)
- [ ] Backward compatible with existing callers
## Testing
<!-- How were these changes tested? -->
examples/caller-python.yml¶
# Example: Using reusable workflows for a Python project
# Copy this to your repository as .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-test:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@main
with:
language: python
language-version: "3.12"
lint-command: "black --check . && flake8 src/ tests/"
test-command: "pytest --cov --cov-report=xml"
artifact-name: dist
artifact-path: dist/
docker:
needs: build-test
uses: acme-org/acme-workflows/.github/workflows/reusable-docker-build.yml@main
with:
image-name: ghcr.io/${{ github.repository }}
push: ${{ github.ref == 'refs/heads/main' }}
security:
uses: acme-org/acme-workflows/.github/workflows/reusable-security-scan.yml@main
with:
language: python
severity-threshold: high
deploy-dev:
needs: [docker, security]
if: github.ref == 'refs/heads/main'
uses: acme-org/acme-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: dev
image: ghcr.io/${{ github.repository }}@${{ needs.docker.outputs.image-digest }}
service-name: my-python-app
health-check-url: https://dev.example.com/health
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
examples/caller-node.yml¶
# Example: Using reusable workflows for a Node.js project
# Copy this to your repository as .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-test:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@main
with:
language: node
language-version: "22"
lint-command: "npm run lint && npm run format:check"
test-command: "npm run test:coverage"
artifact-name: dist
artifact-path: dist/
docker:
needs: build-test
uses: acme-org/acme-workflows/.github/workflows/reusable-docker-build.yml@main
with:
image-name: ghcr.io/${{ github.repository }}
platforms: linux/amd64,linux/arm64
push: ${{ github.ref == 'refs/heads/main' }}
security:
uses: acme-org/acme-workflows/.github/workflows/reusable-security-scan.yml@main
with:
language: javascript
examples/caller-fullstack.yml¶
# Example: Using reusable workflows for a monorepo with backend + frontend
# Copy this to your repository as .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@main
with:
language: python
language-version: "3.12"
working-directory: backend
lint-command: "black --check . && flake8 src/"
test-command: "pytest"
frontend:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@main
with:
language: node
language-version: "22"
working-directory: frontend
lint-command: "npm run lint"
test-command: "npm test"
artifact-name: frontend-dist
artifact-path: frontend/dist/
backend-docker:
needs: backend
uses: acme-org/acme-workflows/.github/workflows/reusable-docker-build.yml@main
with:
image-name: ghcr.io/${{ github.repository }}/backend
context: backend
push: ${{ github.ref == 'refs/heads/main' }}
frontend-docker:
needs: frontend
uses: acme-org/acme-workflows/.github/workflows/reusable-docker-build.yml@main
with:
image-name: ghcr.io/${{ github.repository }}/frontend
context: frontend
push: ${{ github.ref == 'refs/heads/main' }}
security:
uses: acme-org/acme-workflows/.github/workflows/reusable-security-scan.yml@main
with:
language: python
working-directory: backend
deploy:
needs: [backend-docker, frontend-docker, security]
if: github.ref == 'refs/heads/main'
uses: acme-org/acme-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: dev
image: ghcr.io/${{ github.repository }}/backend@${{ needs.backend-docker.outputs.image-digest }}
service-name: acme-platform
health-check-url: https://dev.acme.example.com/health
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
docs/usage.md¶
# Usage Guide
## Quick Start
Reference a reusable workflow from your repository:
```yaml
jobs:
build:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@main
with:
language: python
language-version: "3.12"
test-command: "pytest"
```
## Available Workflows
| Workflow | Purpose | Required Inputs |
|----------|---------|-----------------|
| `reusable-build-test.yml` | Build, lint, and test | `language`, `language-version` |
| `reusable-docker-build.yml` | Build container images | `image-name` |
| `reusable-deploy.yml` | Deploy to cloud environment | `environment`, `image`, `service-name` |
| `reusable-security-scan.yml` | Security scanning | `language` |
## Available Composite Actions
| Action | Purpose |
|--------|---------|
| `setup-env` | Set up language runtime and install dependencies |
| `notify` | Send Slack notifications for CI/CD events |
## Pinning Versions
For production use, pin to a specific tag or commit SHA:
```yaml
# Pin to a release tag (recommended)
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@v1.0.0
# Pin to a commit SHA (most secure)
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@abc123def456
```
docs/migration.md¶
# Migration Guide
## Migrating from Inline Workflows
### Before (duplicated across repositories)
```yaml
# .github/workflows/ci.yml (copy-pasted in every repo)
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: black --check .
- run: flake8
- run: pytest
```
### After (referencing shared workflow)
```yaml
# .github/workflows/ci.yml (3 lines instead of 12)
jobs:
test:
uses: acme-org/acme-workflows/.github/workflows/reusable-build-test.yml@v1.0.0
with:
language: python
language-version: "3.12"
lint-command: "black --check . && flake8"
test-command: "pytest"
```
## Migration Checklist
- [ ] Identify duplicated workflow steps across repositories
- [ ] Map existing steps to reusable workflow inputs
- [ ] Replace inline steps with `uses:` references
- [ ] Test with a pull request before merging
- [ ] Remove old workflow files after migration
- [ ] Pin to a specific version tag
Key Takeaways¶
This complete GitHub Actions workflows example demonstrates:
- Reusable Workflow Design: Parameterized workflows with
workflow_callthat accept language, version, and command inputs - Composite Actions: Encapsulated multi-step setup logic in
setup-envand notification logic innotify - Multi-platform Docker Builds: Buildx with QEMU for cross-architecture container images
- SBOM Generation: Automatic software bill of materials with Anchore for supply chain security
- Environment-gated Deployments: GitHub Environments with required reviewers for production
- Security Scanning Pipeline: Trivy for dependencies, Gitleaks for secrets, CodeQL for SAST
- Health Check with Rollback: Post-deployment verification with automatic rollback on failure
- Self-testing Workflows: The repository tests its own reusable workflows on every push
- Caller Examples: Ready-to-copy workflow files for Python, Node.js, and monorepo projects
- Dependabot Integration: Automated action version updates grouped into weekly PRs
The workflow library is production-ready and demonstrates how to build a centralized CI/CD platform that teams reference rather than duplicate.
Status: Active