GitHub Actions: A Practical Guide to CI/CD Workflows
GitHub Actions: A Practical Guide to CI/CD Workflows
GitHub Actions is the CI/CD platform built into GitHub. It's free for public repos and offers 2,000 minutes/month for private repos on the free plan. For most projects, it eliminates the need for external CI services like CircleCI or Travis CI.
This guide covers the workflow syntax in depth, common patterns, and the security practices that matter.
Workflow Basics
Workflows are YAML files in .github/workflows/. Each workflow is triggered by events (push, pull request, schedule, manual dispatch) and runs one or more jobs on GitHub-hosted or self-hosted runners.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
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
- run: npm run build
Key concepts:
- on: defines triggers.
pushandpull_requestare the most common. - jobs: run in parallel by default. Use
needs:to create dependencies. - steps: run sequentially within a job. Each step is either a
uses:(action) orrun:(shell command). - runs-on: specifies the runner.
ubuntu-latestis the default choice for most workloads.
Matrix Builds
Test across multiple versions, operating systems, or configurations with a single job definition:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
fail-fast: false # Don't cancel other jobs if one fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
This creates 9 jobs (3 OSes x 3 Node versions). Set fail-fast: false if you want all combinations to run even when one fails -- useful for understanding the full scope of failures.
Exclude specific combinations:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
Caching Dependencies
Caching cuts build times dramatically. Most setup actions support caching built-in:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # Automatically caches ~/.npm
For more control, use the cache action directly:
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
The key determines cache hits. Use hashFiles() on lockfiles so the cache busts when dependencies change. restore-keys provides fallback -- a partial match is better than no cache.
Bun caching example:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
- run: bun install --frozen-lockfile
Artifacts
Upload build outputs, test results, or coverage reports as artifacts:
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 30
Download artifacts in a later job:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: coverage-report
path: coverage/
Reusable Workflows and Composite Actions
Reusable Workflows
Extract common workflow patterns into reusable workflows that other workflows call:
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: true
type: string
secrets:
NPM_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Call it from another workflow:
# .github/workflows/ci.yml
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets: inherit # Pass all secrets
Composite Actions
For reusable steps (not full jobs), create composite actions:
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build tools'
inputs:
node-version:
description: 'Node.js version'
default: '20'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bash
Use it in a workflow:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '20'
- run: npm test
Self-Hosted Runners
GitHub-hosted runners are convenient but limited: 2 vCPU, 7 GB RAM, and you pay for minutes on private repos. Self-hosted runners run on your own hardware.
jobs:
build:
runs-on: self-hosted # or a custom label
steps:
- uses: actions/checkout@v4
- run: ./build.sh
When self-hosted runners make sense:
- Builds need more CPU/RAM than GitHub provides
- You need access to internal network resources
- Build minutes costs are high (large teams with many private repos)
- Builds need specialized hardware (GPUs, ARM)
Security warning: Never use self-hosted runners on public repos. Anyone who opens a PR can execute arbitrary code on your runner.
Security Best Practices
Permissions
Restrict the default GITHUB_TOKEN permissions to the minimum needed:
permissions:
contents: read
pull-requests: write # Only if needed
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # Job-level override
Set minimal defaults at the repository level: Settings > Actions > General > Workflow permissions > "Read repository contents and packages permissions."
Pin Actions by SHA
Don't use floating tags for third-party actions:
# Bad -- a compromised tag can execute arbitrary code
- uses: some-org/some-action@v2
# Good -- pinned to exact commit
- uses: some-org/some-action@a1b2c3d4e5f6789012345678901234567890abcd
For first-party actions (actions/checkout, actions/setup-node), tags are acceptable since GitHub controls them.
Secrets Management
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- Never echo secrets in logs (
echo $SECRETwill be masked, but patterns likebase64encoding can leak them) - Use environment-scoped secrets for production deployments
- Rotate secrets when team members leave
OIDC for Cloud Deployments
Instead of storing cloud credentials as secrets, use OIDC (OpenID Connect) to get short-lived tokens:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-arn: arn:aws:iam::123456789012:role/github-actions
aws-region: us-west-2
- run: aws s3 sync ./dist s3://my-bucket
This eliminates long-lived AWS/GCP/Azure credentials from your repository secrets.
Common Patterns
Test on PR, Deploy on Merge
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh
Path-Based Triggers
Only run workflows when relevant files change:
on:
push:
paths:
- 'apps/web/**'
- 'packages/shared/**'
- 'package.json'
paths-ignore:
- '**.md'
- 'docs/**'
Concurrency Control
Cancel redundant runs when new commits push to the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This ensures only the latest commit on a branch runs, saving minutes and avoiding deployment races.
The Bottom Line
GitHub Actions is powerful enough for most CI/CD needs. Start with a simple test-on-PR, deploy-on-merge workflow. Add caching early (it's the easiest performance win). Pin third-party actions by SHA and restrict token permissions. Use reusable workflows when you find yourself copying YAML across repos. And use OIDC instead of stored credentials for cloud deployments -- it's more secure and eliminates secret rotation.