Every GitHub Actions workflow lives in .github/workflows/ and settles three things: the trigger, the machine, and the order of steps. The YAML below runs lint, typecheck, test, and build for a Node project on pnpm.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test --coverage
      - run: pnpm build

Four steps cover what usually breaks before merge: lint catches formatting, typecheck catches bad types, the test suite catches regressions, and the build catches a broken bundle. The rest of the file handles team workflow and infrastructure.

Dependency cache

Without cache, every run installs all dependencies from scratch and loses 2 to 3 minutes doing it. You switch the cache on in setup-node, which uses the lockfile hash as the key:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'  # key: lockfile hash

Change the lockfile and the runner installs again. An npm project sets cache: 'npm' and a yarn project sets cache: 'yarn'. The GitHub Actions caching guide documents the key, the cache path, and how restores behave.

CI and deploy in separate jobs

Verification runs on every pull request. Deploy runs when code reaches main. Two jobs tied by needs and gated by if hold that line:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      # lint, typecheck, test, build

  deploy:
    needs: ci          # waits for the CI job
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    environment: production  # manual approval when configured

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: ./scripts/deploy.sh

environment: production ties the deploy to repository environments. You list required reviewers there, and the deploy waits for someone to approve before it starts. The environments documentation covers environment rules and branch rules.

Secrets outside the YAML

A credential written into the YAML lands in Git history and in every execution log. You store the value in Settings > Secrets and variables > Actions and inject it only in the step that needs it:

- name: Deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: ./scripts/deploy.sh

Environment secrets split values per environment and inherit the approval rules of the environment. The secrets guide defines the scope of each value and what happens when you echo a secret into a log.

Matrix builds across Node versions

A library that promises support for Node 18, 20, and 22 has to prove it on every PR. The matrix runs the same job in parallel, one run per version:

jobs:
  ci:
    strategy:
      matrix:
        node-version: [18, 20, 22]

    runs-on: ubuntu-latest
    name: Test on Node ${{ matrix.node-version }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

The matrix documentation covers fail-fast, which cancels the remaining runs once one fails, and the list of excluded combinations.

Artifacts between jobs

The deploy job consumes the dist/ folder that CI produced. Upload and download move that folder across:

# in the ci job
- uses: actions/upload-artifact@v4
  with:
    name: dist
    path: dist/
    retention-days: 7

# in the deploy job
- uses: actions/download-artifact@v4
  with:
    name: dist
    path: dist/

Coverage reports take the same path: one upload with name: coverage-report keeps the HTML report alive after the run ends.

The complete pipeline

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test --coverage
      - run: pnpm build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

The file is 88 lines and covers caching, the split between verification and deploy, artifacts across jobs, and environment protection. Most projects stop the pipeline here.

References