Skip to content
All notes

Jan 8, 2026 · 2 min read

A CI/CD pipeline that actually saves time

A minimal GitHub Actions setup that runs typecheck, lint, tests, and deploys — without becoming a YAML maintenance project.

devops
github-actions
ci-cd

CI is most useful when it's boring. The pipeline should fail fast, cache aggressively, and stay out of your way. Here's the shape I keep landing on.

The skeleton

name: ci
on:
  push:
    branches: [main]
  pull_request:
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  verify:
    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 typecheck
      - run: npm run lint
      - run: npm test -- --reporter=dot
 
  build:
    needs: verify
    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

Why each piece earns its spot

  • concurrency cancels stale runs when you push twice in 30 seconds. Free time savings, no downside.
  • cache: npm uses the lockfile hash as the cache key. No manual cache step required.
  • needs: verify keeps the build from running if typecheck or tests failed. Fast feedback wins.

Don't do these things

  • Don't use actions/cache for node_modules. The setup-node cache caches npm's content-addressable store, which is the right layer.
  • Don't run npm install instead of npm ci. CI must be deterministic.
  • Don't deploy from the same job that runs tests. Split delivery into a separate job that runs only on main and only if verify passed.

Adding deploy

For Vercel, the platform's GitHub integration handles deploys; you don't need a deploy job at all. For self-hosted, the simplest thing that works is SSH into the box and run a script:

deploy:
  needs: build
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  environment: production
  steps:
    - uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.HOST }}
        username: deploy
        key: ${{ secrets.SSH_KEY }}
        script: cd /srv/app && ./deploy.sh

A real deploy script does atomic swaps and a healthcheck before flipping the symlink. That's a separate post.

The principle

Every minute you save in CI compounds. A 4-minute pipeline run 30 times a day is two hours; a 10-minute one is five. Treat CI time as a budget, audit it quarterly, and you'll keep the tax low.