Writing GitHub Actions Workflows
A workflow file is a YAML recipe that tells GitHub what to run, when to run it, and on what machine. This page builds a complete, production-quality workflow for a Node.js project — from installing dependencies to deploying to Vercel — and explains every feature along the way.
Complete CI workflow for a Node.js project
.github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Lint, Test & Build
runs-on: ubuntu-latest
steps:
# 1. Check out the code
- name: Checkout repository
uses: actions/checkout@v4
# 2. Install Node.js with caching built in
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# 3. Install dependencies (clean install from lockfile)
- name: Install dependencies
run: npm ci
# 4. Run the linter
- name: Run ESLint
run: npm run lint
# 5. Run tests with coverage
- name: Run tests
run: npm test -- --coverage --coverageReporters=json-summary
# 6. Upload coverage report as artifact
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
# 7. Build the application
- name: Build
run: npm run build
# 8. Upload the build artifact
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1Matrix builds — test on multiple environments
A matrix strategy runs the same job multiple times in parallel, with different variable values. This is essential for libraries that need to work across multiple Node.js versions or operating systems.
Matrix across Node versions and OS
jobs:
test:
name: Test (Node ${{ matrix.node-version }} on ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # continue other matrix jobs even if one fails
matrix:
node-version: ['18', '20', '22']
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm testCaching dependencies
actions/setup-node@v4 has a built-in cache option that handles npm/yarn/pnpm caching automatically. For other dependency managers or custom caches, use actions/cache@v4 directly.
Manual cache with actions/cache
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ciThe key is a hash of the lockfile — if package-lock.json changes, the cache is busted. The restore-keys is a fallback prefix for partial cache hits.
Conditional steps
Use the if: key to run a step only when a condition is true. This is how you deploy only on merges to main, not on every pull request branch.
Conditional deployment step
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: npm run deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Other useful conditions
# Only on pull requests if: github.event_name == 'pull_request' # Only when a previous step succeeded if: success() # Even when a previous step failed (for cleanup) if: always() # Only when previous step failed (for notifications) if: failure() # Skip on draft pull requests if: github.event.pull_request.draft == false
Deployment — deploy to Vercel on merge to main
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy to Vercel
runs-on: ubuntu-latest
environment: production # triggers environment protection rules
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Build
run: npm run build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'Deployment — deploy to Netlify
Deploy to Netlify
- name: Deploy to Netlify
uses: netlify/actions/cli@master
with:
args: deploy --prod --dir=.next
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}Multi-job workflow with dependency
Use the needs: key to create a dependency between jobs. The deploy job below will not start until test succeeds.
Test then deploy
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
build:
needs: test # waits for test to succeed
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 build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build # waits for build to succeed
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
run: ./scripts/deploy.shUploading and downloading artifacts between jobs
Upload in one job, download in another
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-files
path: dist/
retention-days: 1 # auto-delete after 1 day
e2e-test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist-files
path: dist/
- run: npm run test:e2eContext and expression syntax
Useful context variables
# Current branch name
${{ github.ref_name }} # e.g. "main" or "feature-x"
${{ github.ref }} # e.g. "refs/heads/main"
# The event that triggered the workflow
${{ github.event_name }} # "push", "pull_request", etc.
# Repository owner and name
${{ github.repository }} # "owner/repo"
${{ github.repository_owner }} # "owner"
# The commit SHA
${{ github.sha }} # full 40-char hash
${{ github.event.pull_request.head.sha }} # PR head commit
# Pull request number
${{ github.event.pull_request.number }}
# Actor (who triggered the workflow)
${{ github.actor }}Complete production workflow template
.github/workflows/full-pipeline.yml
name: Full Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
name: Lint & Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: ['18', '20']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
build:
needs: lint-and-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 run build
- uses: actions/upload-artifact@v4
with:
name: build
path: .next/
deploy-production:
needs: build
runs-on: ubuntu-latest
environment: production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { name: build, path: .next/ }
- name: Deploy
run: npm run deploy:prod
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}