Continuous Integration (CI) automatically tests your code on every push. Continuous Deployment (CD) automatically deploys it when tests pass. GitHub Actions makes this easy with workflow files that live right in your repository.
How GitHub Actions Works
Workflows are defined in YAML files inside .github/workflows/. Each workflow has:
- Triggers — events that start the workflow
- Jobs — groups of steps that run on a virtual machine
- Steps — individual commands or actions
Your First Workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm testThis workflow runs on every push to main and every pull request targeting main. It checks out your code, installs dependencies, lints, and runs tests.
Triggers
Common triggers:
on:
# Run on push to specific branches
push:
branches: [main, develop]
# Run on pull requests
pull_request:
branches: [main]
# Run on a schedule (cron syntax)
schedule:
- cron: '0 8 * * 1' # Every Monday at 8 AM UTC
# Run manually from the GitHub UI
workflow_dispatch:You can also filter by paths to avoid running the workflow when unrelated files change:
on:
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'Multiple Jobs
Jobs run in parallel by default. Use needs to create dependencies:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: [lint, test] # Waits for both to pass
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to production
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}The lint and test jobs run in parallel. The deploy job only runs after both succeed, and only on the main branch.
Environment Variables and Secrets
Never hardcode sensitive values. Use GitHub Secrets instead:
- Go to your repo on GitHub -> Settings -> Secrets and variables -> Actions
- Click "New repository secret"
- Add your secret (e.g.,
VERCEL_TOKEN)
Use them in workflows:
steps:
- name: Deploy
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
NODE_ENV: productionFor non-sensitive configuration, use environment variables directly:
env:
NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.example.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Building for $NODE_ENV"Caching Dependencies
Speed up workflows by caching node_modules:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ciThe actions/setup-node action handles caching automatically when you specify the cache parameter. For pnpm:
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfileMatrix Builds
Test across multiple versions or operating systems:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm testThis creates 6 parallel jobs — every combination of OS and Node version.
A Full Deployment Pipeline
Here's a real-world workflow for a Next.js app deployed to Vercel:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm build
deploy-preview:
runs-on: ubuntu-latest
needs: quality
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Deploy preview
run: npx vercel --token=${{ secrets.VERCEL_TOKEN }}
deploy-production:
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}This pipeline lints, tests, and builds on every push and PR. Pull requests get a preview deployment. Pushes to main deploy to production.