Every workflow lives in .github/workflows/*.yml. A complete annotated example:
# .github/workflows/ci.yml
name: CI Pipeline # Name shown in Actions tab
# ── Triggers ──────────────────────────────────────────────
on:
push:
branches: [main, 'release/**']
paths-ignore: ['docs/**', '*.md']
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6am UTC
workflow_dispatch: # Manual trigger
inputs:
environment:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options: [staging, production]
# ── Token Permissions (least privilege) ───────────────────
permissions:
contents: read
pull-requests: write
checks: write
# ── Concurrency ──────────────────────────────────────────
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# ── Workflow Environment ─────────────────────────────────
env:
CI: true
NODE_ENV: test
# ── Jobs ─────────────────────────────────────────────────
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10 # Prevent hung jobs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
test:
name: Test (${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
needs: lint # Run after lint passes
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: always() # Upload even on failure
with:
name: coverage-${{ matrix.node-version }}
path: coverage/
retention-days: 7
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires approval
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
needsjobs:
build:
runs-on: ubuntu-latest
steps: [...]
test:
needs: build # Waits for build
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: [build, test] # Waits for both
runs-on: ubuntu-latest
steps: [...]
ifjobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
notify:
needs: deploy
if: always() # Run even if deploy fails
runs-on: ubuntu-latest
release:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- run: echo "Only on failure"
if: failure()
- run: echo "Only on success"
if: success()
- run: echo "Always run (cleanup)"
if: always()
- run: echo "Skip on forks"
if: github.repository == 'owner/repo'
- run: echo "Only for specific actor"
if: github.actor == 'dependabot[bot]'
- run: echo "Check PR label"
if: contains(github.event.pull_request.labels.*.name, 'deploy')
needsjobs:
check:
runs-on: ubuntu-latest
outputs:
should-deploy: ${{ steps.decision.outputs.deploy }}
steps:
- id: decision
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
fi
deploy:
needs: check
if: needs.check.outputs.should-deploy == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version'
required: false
default: '20'
type: string
working-directory:
description: 'Directory to run tests in'
required: false
default: '.'
type: string
secrets:
NPM_TOKEN:
required: false
description: 'NPM auth token'
outputs:
coverage-percent:
description: 'Test coverage percentage'
value: ${{ jobs.test.outputs.coverage }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
coverage: ${{ steps.cov.outputs.percent }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm test -- --coverage
- id: cov
run: |
PERCENT=$(jq '.total.lines.pct' coverage/coverage-summary.json)
echo "percent=$PERCENT" >> "$GITHUB_OUTPUT"
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# Or inherit all secrets
test-inherit:
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
# Call from another repo
test-external:
uses: org/shared-workflows/.github/workflows/test.yml@main
with:
node-version: '20'
report:
needs: test
runs-on: ubuntu-latest
steps:
- run: echo "Coverage was ${{ needs.test.outputs.coverage-percent }}%"
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '20'
install-command:
description: 'Install command'
required: false
default: 'npm ci'
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 }}
- id: cache
uses: actions/cache@v4
with:
path: node_modules
key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: ${{ inputs.install-command }}
shell: bash
- run: npm run build
shell: bash # shell: is REQUIRED in composite
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '22'
- run: npm test
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
# Creates 3 x 3 = 9 jobs
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
# Add a job with extra variables
- os: ubuntu-latest
node: 22
experimental: true
# Add variables to existing combo
- os: windows-latest
node: 20
npm-version: 10
exclude:
# Remove a specific combo
- os: windows-latest
node: 18
continue-on-errorstrategy:
fail-fast: false
matrix:
node: [18, 20, 22]
include:
- node: 22
experimental: true
jobs:
test:
continue-on-error: ${{ matrix.experimental || false }}
strategy:
matrix:
include:
- name: Unit Tests
command: npm run test:unit
- name: Integration Tests
command: npm run test:integration
timeout: 30
- name: E2E Tests
command: npm run test:e2e
timeout: 60
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1 # Short-lived build artifacts
if-no-files-found: error # Fail if nothing to upload
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ls -la dist/ # Verify download
# Upload with unique names per matrix
- uses: actions/upload-artifact@v4
with:
name: results-${{ matrix.os }}-${{ matrix.node }}
path: test-results/
# Download all in a later job
- uses: actions/download-artifact@v4
with:
pattern: results-*
merge-multiple: true
path: all-results/
Environments provide deployment gates and scoped secrets.
Environments are configured in Settings > Environments on GitHub. Options:
| Setting | Purpose |
|---|---|
| Required reviewers | Manual approval before deployment (up to 6 reviewers) |
| Wait timer | Delay in minutes before deployment proceeds |
| Deployment branches | Restrict which branches can deploy (e.g., only main) |
| Environment secrets | Secrets scoped to this environment only |
| Environment variables | Variables scoped to this environment |
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com # Shown in deployment status
steps:
- run: deploy --env staging
env:
API_KEY: ${{ secrets.API_KEY }} # Environment-scoped secret
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- run: deploy --env production
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
concurrency:
group: deploy-production
cancel-in-progress: false # Queue instead of cancel
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
runs-on: [self-hosted, linux, x64, gpu] # Match all labels
jobs:
build:
runs-on:
group: production-runners
labels: [linux, x64]
strategy:
matrix:
runner: [ubuntu-latest, self-hosted]
jobs:
test:
runs-on: ${{ matrix.runner }}
OIDC eliminates stored cloud credentials. GitHub issues a short-lived JWT that your cloud provider trusts.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
aws-region: us-east-1
# No access keys needed
- run: aws s3 sync dist/ s3://my-bucket
permissions:
id-token: write
contents: read
steps:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/my-repo'
service_account: 'deploy@my-project.iam.gserviceaccount.com'
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud run deploy my-service --image gcr.io/my-project/app
permissions:
id-token: write
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az webapp deploy --name my-app --src-path dist/
# Standard checkout
- uses: actions/checkout@v4
# Full history (for changelogs, git describe)
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Checkout PR head (for pull_request_target)
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Checkout with submodules
- uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.PAT }} # For private submodules
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Or pnpm, yarn
registry-url: https://npm.pkg.github.com
- uses: actions/setup-go@v5
with:
go-version-file: go.mod # Read from go.mod
cache: true # Cache go modules
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip # Or pipenv, poetry
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
Set repository secret ACTIONS_STEP_DEBUG to true for verbose step output.
Or re-run a failed job with "Enable debug logging" checkbox.
- run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "SHA: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
echo "Matrix: ${{ toJson(matrix) }}"
echo "Env: ${{ toJson(env) }}"
# Dump full event payload
- run: cat "$GITHUB_EVENT_PATH" | jq .
act# Install act (https://github.com/nektos/act)
brew install act # macOS
choco install act-cli # Windows
# Run default event (push)
act
# Run specific workflow
act -W .github/workflows/ci.yml
# Run specific job
act -j test
# Run with specific event
act pull_request
# Pass secrets
act -s GITHUB_TOKEN="$(gh auth token)"
# Use specific runner image
act -P ubuntu-latest=catthehacker/ubuntu:act-latest
# Dry run (show what would run)
act -n
# Temporarily add to any step
- run: |
echo "::group::Debug Info"
env | sort
echo "::endgroup::"
# Check file existence
- run: |
echo "::group::Workspace Contents"
find . -maxdepth 3 -type f | head -50
echo "::endgroup::"
# Conditional debug step
- if: runner.debug == '1'
run: |
echo "Debug mode enabled"
cat package.json | jq '.scripts'
# Warning annotation
- run: echo "::warning file=app.js,line=1::Missing error handling"
# Error annotation
- run: echo "::error file=app.js,line=10,col=5::Syntax error"
# Notice annotation
- run: echo "::notice::Deployment complete"
# Group log lines
- run: |
echo "::group::Install Dependencies"
npm ci
echo "::endgroup::"