GitHub Actions advanced expert for CI/CD workflows, reusable workflows, composite actions, OIDC authentication, security hardening, monorepo patterns, self-hosted runners, and deployment automation. Covers performance optimization, cost management, and production-grade pipeline design.
Install
npx skillscat add anton-abyzov/specweave/plugins-specweave-infrastructure-skills-github-actions Install via the SkillsCat registry.
SKILL.md
GitHub Actions Advanced Expert
Purpose
Design, implement, and optimize GitHub Actions workflows for CI/CD, automation, and deployment pipelines. Provide expert guidance on workflow architecture, security, performance, and cost optimization.
When to Use
- Creating or optimizing CI/CD pipelines
- Building reusable workflows and composite actions
- Setting up OIDC authentication with cloud providers
- Implementing monorepo CI strategies
- Configuring self-hosted runners
- Designing deployment pipelines with environment gates
- Debugging workflow failures
- Optimizing build times and costs
- Creating custom GitHub Actions (JavaScript, Docker, composite)
Workflow Syntax Fundamentals
Trigger Events
on:
# Branch push
push:
branches: [main, 'release/**']
paths-ignore: ['docs/**', '*.md']
# Pull request
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Manual dispatch with inputs
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [dev, staging, production]
dry_run:
description: 'Dry run mode'
type: boolean
default: false
# Scheduled
schedule:
- cron: '0 6 * * 1-5' # Weekdays at 6 AM UTC
# Called by another workflow
workflow_call:
inputs:
node_version:
type: string
default: '20'
secrets:
NPM_TOKEN:
required: true
# Repository dispatch (API triggered)
repository_dispatch:
types: [deploy]Matrix Strategies
jobs:
test:
strategy:
fail-fast: false # Don't cancel other jobs on failure
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: ['18', '20', '22']
exclude:
- os: windows-latest
node: '18'
include:
- os: ubuntu-latest
node: '20'
coverage: true # Extra variable for this combo
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Run tests with coverage
if: matrix.coverage
run: npm test -- --coverageJob Dependencies and Outputs
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
should_deploy: ${{ steps.check.outputs.deploy }}
steps:
- id: version
run: echo "value=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- id: check
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
fi
deploy:
needs: [build]
if: needs.build.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"Reusable Workflows
Defining a Reusable Workflow
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
image_tag:
required: true
type: string
region:
type: string
default: 'us-east-1'
secrets:
AWS_ROLE_ARN:
required: true
outputs:
deploy_url:
description: 'Deployment URL'
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ inputs.region }}
- id: deploy
run: |
# Deploy logic here
echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"Calling a Reusable Workflow
# .github/workflows/release.yml
jobs:
build:
# ... build steps
deploy-staging:
needs: build
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
image_tag: ${{ needs.build.outputs.image_tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE_ARN }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
image_tag: ${{ needs.build.outputs.image_tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}Composite Actions
Creating a Composite Action
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies with caching'
inputs:
node_version:
description: 'Node.js version'
default: '20'
working_directory:
description: 'Working directory'
default: '.'
outputs:
cache_hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: cache
with:
path: |
${{ steps.npm-cache-dir.outputs.dir }}
${{ inputs.working_directory }}/node_modules
key: ${{ runner.os }}-node-${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ inputs.node_version }}-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
working-directory: ${{ inputs.working_directory }}
run: npm ciUsing the Composite Action
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node_version: '20'
- run: npm testOIDC Authentication with Cloud Providers
AWS OIDC Setup
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: us-east-1
# No access keys needed - uses OIDC tokenAWS IAM Trust Policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:org/repo:*"
}
}
}]
}Azure OIDC Setup
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}GCP OIDC Setup
steps:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github'
service_account: 'github-actions@project.iam.gserviceaccount.com'Security Best Practices
Action Pinning by SHA
# INSECURE: Tag can be moved by attacker
- uses: actions/checkout@v4
# SECURE: Pin to exact commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1# Get SHA for a specific version
gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha'Minimal Permissions
# Set restrictive default permissions at workflow level
permissions:
contents: read
jobs:
deploy:
permissions:
contents: read
id-token: write # Only if OIDC needed
deployments: write # Only if updating deploymentsSecret Handling
steps:
# Secrets are masked in logs automatically
- run: echo "Deploying to ${{ secrets.DEPLOY_URL }}"
# NEVER echo secrets for debugging
# NEVER pass secrets as command-line arguments (visible in process list)
# Use environment files instead
- run: |
echo "API_KEY=${{ secrets.API_KEY }}" >> "$GITHUB_ENV"
# Or use step-level env
- env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: node migrate.jsEnvironment Protection Rules
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
# Requires manual approval + branch protection in GitHub settings
steps:
- run: ./deploy.shMonorepo Patterns
Path-Based Triggers
on:
push:
paths:
- 'packages/frontend/**'
- 'packages/shared/**' # Shared dependency
- 'package.json'
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test --workspace=packages/frontendChanged File Detection
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
backend: ${{ steps.changes.outputs.backend }}
infra: ${{ steps.changes.outputs.infra }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
frontend:
- 'packages/frontend/**'
- 'packages/shared/**'
backend:
- 'packages/backend/**'
- 'packages/shared/**'
infra:
- 'infrastructure/**'
build-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Building frontend..."
build-backend:
needs: detect-changes
if: needs.detect-changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Building backend..."Caching Strategies
Dependency Caching
steps:
# Node.js (built-in to setup-node)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Custom cache for build artifacts
- uses: actions/cache@v4
with:
path: |
.next/cache
dist/
key: build-${{ runner.os }}-${{ hashFiles('src/**', 'package-lock.json') }}
restore-keys: |
build-${{ runner.os }}-
# Docker layer caching
- uses: docker/build-push-action@v5
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=maxCache Key Strategy
Best practice for cache keys:
Exact: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
Restore: ${{ runner.os }}-node-
This ensures:
1. Exact match on lock file hash (fast, correct dependencies)
2. Fallback to OS+runtime prefix (faster than cold install)Concurrency Control
# Cancel redundant runs on the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Serialize deployments (never cancel)
jobs:
deploy:
concurrency:
group: deploy-production
cancel-in-progress: falseContainer Jobs and Services
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Run tests
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
run: npm testCommon Pipeline Patterns
CI Pipeline (Lint, Test, Build)
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/Release Automation
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
packages: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
CHANGES=$(git log --pretty=format:"- %s (%h)" "$PREV_TAG"..HEAD)
else
CHANGES=$(git log --pretty=format:"- %s (%h)")
fi
echo "changes<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: |
## Changes
${{ steps.changelog.outputs.changes }}
generate_release_notes: true
- name: Publish to npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}PR Automation
name: PR Automation
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
size-label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Label PR by size
uses: codelytv/pr-size-labeler@v1
with:
xs_max_size: 10
s_max_size: 100
m_max_size: 500
l_max_size: 1000Debugging Workflows
Enable Debug Logging
# Set repository secret: ACTIONS_RUNNER_DEBUG = true
# Or re-run with debug logging from the UI
# In workflow, use debug output
- run: echo "::debug::Current SHA is $GITHUB_SHA"
# Group log output for readability
- run: |
echo "::group::Install Output"
npm ci
echo "::endgroup::"Step Outputs and Conditionals
steps:
- id: check
run: |
echo "result=success" >> "$GITHUB_OUTPUT"
echo "count=42" >> "$GITHUB_OUTPUT"
- if: steps.check.outputs.result == 'success'
run: echo "Check passed with count ${{ steps.check.outputs.count }}"
- if: failure()
run: echo "Something failed in a previous step"
- if: always()
run: echo "This runs regardless of previous step status"
- if: cancelled()
run: echo "Workflow was cancelled"Cost Optimization
Minutes Usage Reduction
| Strategy | Impact |
|---|---|
| Cancel redundant runs (concurrency) | High |
| Path-based triggers | High |
| Aggressive caching | Medium-High |
| Smaller runner images | Medium |
| Parallel jobs instead of sequential | Medium |
| Skip CI for docs-only changes | Low-Medium |
Use ubuntu-latest over macOS/Windows when possible |
High (macOS = 10x) |
Skip CI for Non-Code Changes
jobs:
check-skip:
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip.outputs.should_skip }}
steps:
- id: skip
uses: fkirc/skip-duplicate-actions@v5
with:
paths_ignore: '["*.md", "docs/**", ".github/ISSUE_TEMPLATE/**"]'
build:
needs: check-skip
if: needs.check-skip.outputs.should_skip != 'true'
runs-on: ubuntu-latest
steps:
- run: npm run buildSelf-Hosted Runners
Setup and Configuration
# Download and configure runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.313.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.313.0/actions-runner-linux-x64-2.313.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.313.0.tar.gz
./config.sh --url https://github.com/org/repo --token TOKEN --labels gpu,large
./svc.sh install && ./svc.sh startUsing Self-Hosted Runners
jobs:
gpu-test:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- run: python train.py
large-build:
runs-on: [self-hosted, large]
steps:
- run: docker build -t myapp .Security Considerations
Self-hosted runner security checklist:
[ ] Never use self-hosted runners on public repos (fork PRs can run code)
[ ] Use ephemeral runners (fresh VM per job)
[ ] Restrict runner labels and groups per repository
[ ] Keep runner software updated
[ ] Monitor runner logs for unauthorized access
[ ] Use runner groups with organization-level access controlGitHub CLI in Workflows
steps:
- name: Create issue on failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue create \
--title "CI failure: ${{ github.workflow }}" \
--body "Workflow failed on commit ${{ github.sha }}" \
--label "bug,ci"
- name: Comment on PR
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.pull_request.number }} \
--body "Build succeeded. [View artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"