GitHub Actions has become the default CI/CD for many teams. Here are patterns I’ve seen work well in production, and a few anti-patterns to avoid.
The Foundation: A Reusable Test Workflow#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
|
Key details:
actions/checkout@v4 — always pin to major versioncache: 'npm' — built-in caching, no separate action needednpm ci — faster and stricter than npm install
Matrix Builds Done Right#
Test across multiple versions without copy-paste:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
|
fail-fast: false lets all combinations finish — useful for knowing the full picture.
Conditional Deployment#
Deploy only on main, only after tests pass:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| jobs:
test:
# ... test job
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./scripts/deploy.sh
|
The needs keyword creates the dependency. The if controls when it runs.
Caching Dependencies Properly#
For complex caching beyond the built-in:
1
2
3
4
5
6
7
| - name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
|
The restore-keys fallback means a close-enough cache beats a cold start.
Docker Build and Push#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
|
The cache-from/cache-to with type=gha uses GitHub’s cache for Docker layers.
Reusable Workflows#
Stop duplicating workflows across repos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # .github/workflows/reusable-test.yml
name: Reusable Test
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci && npm test
|
Call it from another workflow:
1
2
3
4
5
| jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '22'
|
Or from another repo:
1
2
3
| jobs:
test:
uses: myorg/shared-workflows/.github/workflows/test.yml@main
|
Environment Protection#
For production deployments with approval:
1
2
3
4
5
6
7
8
| jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to production
run: ./deploy.sh prod
|
Configure the production environment in repo settings with required reviewers.
Secrets Management Pattern#
Pass secrets explicitly, not through env inheritance:
1
2
3
4
5
| - name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: aws s3 sync ./dist s3://my-bucket
|
For OIDC-based auth (no static secrets):
1
2
3
4
5
| - name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
|
Anti-Patterns to Avoid#
1. Running everything on every push
1
2
3
4
5
6
7
8
9
| # Bad: runs on every file change
on: push
# Better: filter by path
on:
push:
paths:
- 'src/**'
- 'package.json'
|
2. Not canceling redundant runs
1
2
3
4
| # Add this to cancel previous runs on same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
|
3. Hardcoding versions everywhere
Use organization variables or a .tool-versions file:
1
2
3
| - uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
|
Debugging Failed Workflows#
When things go wrong:
1
2
3
4
5
6
| - name: Debug info
run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "SHA: ${{ github.sha }}"
env | sort
|
Or enable debug logging by setting ACTIONS_STEP_DEBUG secret to true.
The 80/20 Rule#
Most projects need just three workflows:
- Test on PR — catch issues before merge
- Build and deploy on main — ship automatically
- Scheduled security scan — catch vulnerabilities
Everything else is optimization. Start simple, add complexity only when needed.