GitWriting a Workflow

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

YAML
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: 1
Matrix 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

YAML
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 test
fail-fast
With `fail-fast: true` (default), if one matrix job fails, GitHub cancels the remaining running jobs. Set to `false` when you want a full picture across all environments even when some fail.
Caching 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

YAML
- 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 ci

The 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

YAML
- 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

YAML
# 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

YAML
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

YAML
- 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

YAML
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.sh
Uploading and downloading artifacts between jobs

Upload in one job, download in another

YAML
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:e2e
Context and expression syntax

Useful context variables

YAML
# 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

YAML
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 }}
Warning
Always use `npm ci` (not `npm install`) in CI workflows. `npm ci` installs exactly what is in the lockfile, is faster, and fails if the lockfile is out of sync with `package.json`.
Tip
Add `timeout-minutes: 15` to jobs to prevent runaway workflows from consuming all your free CI minutes. A test suite that should take 3 minutes but hangs for an hour will waste your quota.