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 testBreaking down what matters:
on— which events trigger this.pull_requesthere means every PR targetingmainruns these checks before merge.runs-on— the VM image the job executes on.ubuntu-latestcovers almost everything; usemacos-latestonly 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, notnpm install—ciinstalls exactly what's inpackage-lock.jsonand 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 }}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 testThis 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.shneeds: test blocks deploy until test succeeds; the if guard keeps
it from firing on every PR — only pushes to main.
Common gotchas
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.
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."
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.