Overview
GitHub Actions for automated testing, building, and deployment. Covers the patterns that appear in production Next.js and Node.js projects.
Core Workflow Structure
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: "0 8 * * 1-5" # Weekdays at 8am UTC
env:
NODE_VERSION: "20"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
Security Best Practices (Non-Negotiable)
# NEVER put secrets directly in workflow files
# BAD:
# env:
# API_KEY: "sk-actual-key-here"
# GOOD: Reference secrets from repository/org settings
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Limit permissions
permissions:
contents: read
pull-requests: write # Only grant what's needed
# Pin third-party actions to SHA (not tag)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Caching Strategies
# Node modules cache (most common)
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
# Next.js build cache
- uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
${{ runner.os }}-nextjs-
Matrix Testing
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false # Don't cancel other jobs on first failure
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
Conditional Execution
steps:
# Only on main branch
- name: Deploy to Production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: vercel --prod
# Only on PRs
- name: Comment PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
# Skip if commit message contains [skip ci]
- name: Run tests
if: "!contains(github.event.head_commit.message, '[skip ci]')"
run: npm test
Complete CI/CD Pattern (Next.js + Vercel)
name: Deploy
on:
push:
branches: [main]
jobs:
ci:
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 type-check
- run: npm run build
deploy:
needs: ci
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: npm install -g vercel
- name: Pull Vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
Reusable Workflows
# .github/workflows/test.yml (reusable)
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: "20"
secrets:
DATABASE_URL:
required: true
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
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Caller workflow
jobs:
run-tests:
uses: ./.github/workflows/test.yml
with:
node-version: "20"
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Useful Cron Schedules
"0 8 * * 1-5" # 8am UTC weekdays
"0 0 * * *" # Daily midnight UTC
"0 */6 * * *" # Every 6 hours
"0 0 1 * *" # First day of month