← all articles
devops

GitHub Actions: CI/CD Basics

Get a real test-on-every-push workflow running, then layer on secrets, caching, and matrix builds without the usual trial and error.

4 min read·July 17, 2026

GitHub Actions runs a workflow — a sequence of steps on a fresh VM — in response to events in your repo: a push, a pull request, a schedule. This covers the workflow shape you'll reuse for almost every project, plus the handful of things (secrets, caching, matrices) that turn "it runs" into "it runs well."

Where workflows live

.github/
  workflows/
    ci.yml

Any .yml file in that directory is a workflow. GitHub picks it up automatically on push — no separate registration step.

A first real workflow

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"
 
      - run: npm ci
      - run: npm run lint
      - run: npm test

Breaking down what matters:

  • on — which events trigger this. pull_request here means every PR targeting main runs these checks before merge.
  • runs-on — the VM image the job executes on. ubuntu-latest covers almost everything; use macos-latest only if you specifically need it (it's slower to schedule and costs more compute minutes).
  • actions/checkout@v4 — without this, the job has no access to your repo's files at all. It's the first step in nearly every job you'll write.
  • npm ci, not npm installci installs exactly what's in package-lock.json and fails if it's out of sync, which is what you want in a reproducible CI environment.

Secrets

Never put credentials directly in a workflow file — they're visible to anyone who can read the repo. Add them under Settings → Secrets and variables → Actions, then reference them:

- run: ./deploy.sh
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
warning

Secrets aren't available to workflows triggered by pull requests from forks, by design — a fork's maintainer shouldn't be able to exfiltrate your secrets just by opening a PR. If a workflow needs secrets and needs to run on fork PRs, look into the pull_request_target event, carefully — it comes with its own security tradeoffs the GitHub docs cover in detail.

Caching dependencies

The cache: "npm" line on setup-node above already handles the common case. For anything not covered by a setup-* action's built-in caching, actions/cache does it generically:

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

The key including a hash of your lockfile means the cache invalidates automatically when dependencies change — you get a fresh install exactly when you need one, and a fast restore every other time.

Matrix builds

Testing against multiple versions (Node 20 and 22, say) without duplicating the whole job:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci && npm test

This runs the entire job once per entry in the matrix, in parallel.

A deploy job that only runs after tests pass

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
 
  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

needs: test blocks deploy until test succeeds; the if guard keeps it from firing on every PR — only pushes to main.

Common gotchas

warning

Workflow doesn't trigger at all. Check the on.push.branches / on.pull_request.branches filters — a workflow scoped to main won't run on a feature branch push, only on the PR event once one's opened against main.

warning

A step passes locally but fails in CI. Almost always an environment difference: a global tool installed on your machine but not declared as a dependency, a different Node/Python version, or a file path assumption (case-sensitivity differs between macOS and the Ubuntu runners). Pin versions explicitly rather than relying on "whatever's installed."

tip

Click into any run's Actions tab and re-run a single failed job before re-running the whole workflow — saves time and CI minutes when only one flaky step failed.

Once this shape is habitual, adding CI to a new project is copying this file and swapping the setup-* action and test command — not starting from a blank page each time.