What CI/CD is actually for
Continuous Integration isn’t about badges on your README. It’s about catching the broken thing before your teammate pulls it, and before it reaches a user. A good pipeline answers one question on every push: is main still shippable?
This guide builds that pipeline in GitHub Actions, starting minimal and adding only what earns its place.
The anatomy of a workflow
GitHub Actions runs workflows (YAML files in .github/workflows/), each containing jobs, each containing steps. Jobs run in parallel by default; steps run in sequence.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
That’s a working pipeline. Every push to main and every PR runs your tests on a fresh Ubuntu machine. But it’s slower than it needs to be and does less than it should.
Add dependency caching (this one’s free speed)
npm ci re-downloads everything on every run. setup-node can cache the npm store:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
One gotcha that trips everyone: the cache: 'npm' option requires a lockfile (package-lock.json) committed to the repo. Without it, the step fails with a confusing “Dependencies lock file is not found” error. Commit your lockfile.
On a typical project this cuts install time from ~40s to ~8s. It’s the single highest-return line in the file.
Run lint and tests as separate jobs
Combining lint and test into one job means a lint failure hides test results (and vice versa). Split them so you see both outcomes at once:
jobs:
lint:
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 lint
test:
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 test
They run in parallel, so splitting costs no wall-clock time — you just get two clear green/red signals instead of one muddy one.
Matrix builds: test across versions
If your library needs to work on multiple Node versions, a matrix runs the same job across each:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
fail-fast: false is the important part. By default, the moment one matrix cell fails, GitHub cancels the rest — so if Node 18 fails you never learn whether 20 and 22 also fail. Setting it false lets every version report independently, which is what you actually want when debugging a compatibility issue.
Gating merges on green
A pipeline nobody has to pass is decoration. In Settings → Branches → Branch protection rules, require status checks to pass before merging. Now a red pipeline physically blocks the merge button.
A subtle trap: the required check name must match the job name exactly. If you rename a job from test to unit-tests, the branch rule still waits for test — which no longer runs — and every PR hangs forever “waiting for status.” When that happens, update the branch protection rule to the new name.
Deploying safely
Here’s a deploy job that runs only after tests pass, and only on main:
deploy:
needs: [lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
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
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: npm run deploy
Two safety mechanisms:
needs: [lint, test]— the deploy waits for both to succeed. A failing test blocks the deploy.if:condition — deploy runs only on a direct push tomain, never on a PR (you don’t want every PR deploying to production).
Secrets: never in the YAML
Notice DEPLOY_TOKEN comes from secrets.DEPLOY_TOKEN, not a literal. Add secrets in Settings → Secrets and variables → Actions. They’re encrypted, masked in logs (GitHub replaces the value with *** if it ever appears in output), and never exposed to workflows triggered from forks.
A real risk to understand: a pull_request from a forked repo does not get access to your secrets, by design — otherwise anyone could open a PR that exfiltrates your tokens. If your deploy needs secrets, keep it on the push trigger to main, which only maintainers can do.
Failing fast and readably
Add these so failures are quick to diagnose:
- name: Type check
run: npm run typecheck
- name: Tests with coverage
run: npm test -- --coverage
- name: Upload coverage on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if: failure() uploads the coverage report only when something broke, so you can download and inspect it. Steps are skipped by default once a prior step fails, so you need the explicit condition to make cleanup or diagnostic steps run anyway.
The complete pipeline
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
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 lint
- run: npm run typecheck
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
needs: [lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
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
- env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: npm run deploy
Pin your action versions
actions/checkout@v4 pins to a major version. Avoid @main or @master on third-party actions — you’re executing someone else’s code on a machine that can touch your secrets, and @main means you run whatever they push next, including a compromised update. For high-security setups, pin to a full commit SHA:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
A SHA can’t be moved under you the way a tag can.
What to skip until you need it
- Self-hosted runners — only when GitHub’s runners are too slow or can’t reach private infrastructure. They add maintenance and security burden.
- Reusable workflows — worth it once you have 3+ repos sharing a pipeline, not before.
- Complex conditional matrices — most projects never need them.
Start with lint + test + gated deploy. Add complexity when a real pain point demands it, not preemptively.
The mindset that matters
A pipeline is a contract about what “done” means. If it’s green, the code met the bar. That only works if the bar is real: tests that would actually fail on a regression, a lint config the team agreed on, a deploy that can’t run on red. A pipeline full of skipped tests and continue-on-error is worse than none, because it grants false confidence.
Keep it honest, keep it fast, and it becomes the thing that lets you ship on a Friday without dread.
