Skip to content
Back to Build Tutorials

CI/CD Skeleton

Stand up a GitHub Actions pipeline that gates every PR on build + test + lint.

Why

A CI skeleton that runs on every PR is the cheapest bug prevention you will ever install. It makes 'it works on my machine' irrelevant because the machine is now a clean runner. Without it, broken code merges silently and the first person to discover the breakage is the one deploying to prod. The skeleton also creates the deployment runway: once build+test is green in CI, adding a deploy step is a 10-line job, not a project. Teams without this skeleton end up deploying from laptops with rsync, which is how prod goes down on a Friday.

When

Right after project-init, before the first feature PR. Takes 30-45 min. Skip only if the repo will never have >1 contributor.

How

1. Create .github/workflows/ci.yml triggered on PR + push to main
   - jobs: lint, test, build — run in parallel, all must pass

2. Use a setup-node action with cache: 'pnpm' to skip reinstall
   - actions/setup-node@v4 with node-version-file: '.nvmrc'
   - run: pnpm install --frozen-lockfile

3. Make the jobs fail fast on lint, slow on test
   - lint: 30s timeout, fails on any warning
   - test: 10min timeout, runs vitest --coverage
   - build: produces artifact, uploads for deploy job

4. Add a deploy job gated on branch == main && CI passed
   - uses: needs: [lint, test, build]
   - if: github.ref == 'refs/heads/main'
   - Deploy to staging; prod is a manual approval (environment: prod)

5. Protect the main branch in repo settings
   - Require status checks: ci/lint, ci/test, ci/build
   - Require 1 review + require branches up-to-date before merge

Example

# .github/workflows/ci.yml
name: ci
on: { pull_request: {}, push: { branches: [main] } }
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm test --coverage

Common Problems

  • ⚠Not using --frozen-lockfile in CI, so a teammate's dirty lockfile silently changes versions on everyone.
  • ⚠Caching node_modules instead of the pnpm store — the cache key never invalidates correctly and you get stale deps.
  • ⚠Running all jobs in one giant job instead of parallel — PR feedback takes 12 min instead of 3.
  • ⚠Forgetting branch protection, so someone can push straight to main and bypass CI entirely.

Best Practices

  • ✓Pin every action to a major version (@v4) and use Dependabot to bump them — unpinned actions break silently when upstream ships a bad release.
  • ✓Set a timeout-minutes on every job so a hung process fails the PR instead of running for 6 hours.
  • ✓Use concurrency groups to cancel superseded runs on the same branch — saves 50%+ of CI minutes.
  • ✓Put secrets in GitHub Environments (not repo-level), so staging secrets can't leak into a PR fork.