CI/CD with GitHub Actions
Someone opens a pull request. Twenty minutes later a green check appears next to it, the reviewer merges, and ninety seconds after that the new version is live in production — or a new release of the package is on npm, signed and traceable. Nobody ran a test by hand. Nobody typed npm publish. Nobody SSH’d into a server.
That whole invisible machine is CI/CD, and for JavaScript projects the default engine that runs it is GitHub Actions. This article walks the entire path: what CI and CD actually mean, how an Actions workflow is wired together, a realistic pipeline from git push to a deployed app, and then the two release systems — Changesets and semantic-release — that turn a merged PR into a published version.
It builds on tools you have already met: the linters and formatters you will run in CI, the tests that gate a merge, the git hooks that catch problems even earlier, and publishing to npm that a release job automates.
CI and CD: two words, three ideas
The letters get run together, but they name distinct stages.
Continuous Integration (CI) is the discipline of merging everyone’s work into a shared branch often — many times a day — and verifying each merge automatically. Every push runs the same checks: install, lint, type-check, test, build. The payoff is that integration problems surface while they are small. You broke one function an hour ago, not a tangle of forty commits last month.
Continuous Delivery (CD) takes every change that passes CI and gets it ready to release — built, packaged, and deployed to a staging environment — so that shipping is a business decision (click a button) rather than an engineering scramble.
Continuous Deployment goes one step further: every change that passes CI ships to production automatically, no button. Same abbreviation, stronger promise.
Most teams do continuous delivery and reserve automatic deployment for services they trust deeply. The mechanics are identical; only the last gate differs.
Anatomy of a GitHub Actions workflow
A workflow is a YAML file in .github/workflows/. GitHub watches your repository for events and, when one matches a workflow’s triggers, spins up fresh virtual machines called runners to execute the work. Here is the smallest useful example, annotated:
# .github/workflows/ci.yml
name: CI # shown in the Actions tab
on: # events that trigger this workflow
push:
branches: [main]
pull_request: # every PR targeting the repo
jobs: # a workflow has one or more jobs
test: # job id
runs-on: ubuntu-latest # the runner OS
steps: # ordered list of things to do
- uses: actions/checkout@v5 # a reusable action
- uses: actions/setup-node@v5
with:
node-version: 24
- run: npm ci # a shell command
- run: npm test
Five nouns carry the whole model. Learn these and the rest is detail.
- Event — what happened: a
push, apull_request, a released tag, a manualworkflow_dispatch, aschedule(cron). It answers when does this run. - Job — a unit of work that runs on its own fresh runner. Jobs run in parallel by default; you make one wait with
needs. - Step — one action or one shell command inside a job. Steps share the runner’s filesystem and run top to bottom.
- Runner — the machine. GitHub hosts Ubuntu, Windows, and macOS runners; you can also self-host.
- Action — a packaged, reusable step published to the Marketplace.
actions/checkoutclones your repo;actions/setup-nodeinstalls Node and wires up caching. You reference one withuses: owner/name@ref.
A key mental model: each job starts from nothing. A clean VM, no repo, no node_modules, no leftovers from the last run. That is why the first step is almost always checkout — the runner doesn’t have your code until you ask for it. It is also why caching matters so much, which is the next stop.
A realistic pipeline, end to end
Toy examples run one job. Real pipelines fan out. You want lint, type-check, and tests to run at the same time so a slow test suite doesn’t delay a fast linter, and you want the build and deploy to run only after everything passes.
Here is that pipeline as a full workflow. Read it top to bottom; the comments call out each decision.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel an in-progress run when you push again to the same PR —
# no point testing a commit you already replaced.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm # cache the npm download cache — see below
- run: npm ci # clean, lockfile-exact install
- run: npm run lint
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run type-check # e.g. tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm test -- --coverage
build:
needs: [lint, types, test] # gate: only after all three pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4 # hand the output to the next job
with:
name: dist
path: dist/
Two details that separate a real pipeline from a tutorial one:
Use npm ci, not npm install, in CI. npm ci deletes node_modules and installs exactly what the lockfile pins — no silent version drift, and it errors if package.json and the lockfile disagree. It is what the “ci” in the name is for. (The same logic applies to pnpm install --frozen-lockfile and yarn install --immutable; see package managers.)
Artifacts move files between jobs. Remember, each job is a fresh machine — the build job’s dist/ folder does not exist in the deploy job. upload-artifact / download-artifact are how you pass build output across that gap.
Caching: turn a slow install into an instant restore
Installing dependencies is usually the slowest part of a JavaScript pipeline. On a cold runner, npm ci downloads every package from the registry — that is network round-trips for hundreds of tarballs, easily 30–90 seconds. Multiply by every job and every push and it adds up fast.
The fix is caching. setup-node’s cache: npm option stores npm’s download cache keyed on the hash of your lockfile. As long as package-lock.json hasn’t changed, the next run restores the cache and skips the network entirely.
A subtle but important point: cache: npm caches npm’s global download cache, not your node_modules. npm ci still runs and links packages — but it reads them from a local folder instead of the network, which is where nearly all the time went. This is the right default; caching node_modules directly is fragile because it can carry platform-specific binaries across runners.
For caches beyond what setup-node handles automatically, actions/cache@v4 gives you the raw primitive: a path to store and a key to store it under, with restore-keys as fallbacks.
- uses: actions/cache@v4
with:
path: .next/cache # e.g. Next.js build cache
key: next-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
next-${{ hashFiles('package-lock.json') }}-
Matrix builds: one job, many combinations
If you publish a library, “works on my machine” isn’t enough — it needs to work on the Node versions and operating systems your users actually run. A matrix expands one job definition into a grid of parallel runs, one per combination.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # let every cell finish, not just up to the first red
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [22, 24, 26] # Maintenance LTS, Active LTS, Current
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
You can also include extra one-off combinations (say, an experimental Node nightly allowed to fail) or exclude cells that don’t make sense. A matrix is the cheapest way to buy confidence that your package is portable.
Secrets, environments, and permissions
Pipelines need credentials — a deploy token, an npm token, an API key. You never commit these. GitHub stores them encrypted as secrets (repository or organization level) and injects them into a run as expressions.
- run: npx vercel deploy --prod --token "$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
A few rules that keep you out of trouble:
- Secrets are masked in logs. If a secret value appears in output, Actions replaces it with
***. Don’t defeat this by base64-decoding it into a plainecho. - Secrets are not passed to workflows triggered by fork PRs (using the default
pull_requestevent). This is deliberate — an attacker could otherwise open a PR that exfiltrates your tokens. Keep deploy/publish steps onpushto protected branches, not on PRs. - Environments (Settings → Environments) group secrets and add protection rules: required reviewers, wait timers, and branch restrictions. A
productionenvironment can demand a human approval before the deploy job runs.
deploy:
needs: build
runs-on: ubuntu-latest
environment: production # applies that environment's rules + secrets
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist }
- run: ./scripts/deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Also scope the workflow’s own token. By default the GITHUB_TOKEN has broad permissions; declare only what you need at the top of the file. Least privilege here means a compromised action can do less.
permissions:
contents: read # tighten the default; grant more per-job when required
Branch protection and required checks
CI only enforces quality if a red check actually blocks a merge. That link is branch protection (or the newer repository rulesets), configured on the repo, not in YAML. You mark specific jobs — lint, types, test — as required status checks on main. Now the merge button is disabled until they’re green, and if main moved you must be up to date with it first.
Pair that with preview deployments: hosts like Vercel, Netlify, and Cloudflare Pages build every PR to a unique URL and post it as a check. A reviewer clicks the link, sees the change running for real, and approves against the actual thing rather than a diff. Required checks plus a preview URL is the everyday shape of modern web CI.
Automated releases
CI proves the code is good. Releasing is the separate job of deciding the next version number, writing a changelog, tagging, and publishing to npm. Two tools dominate the JavaScript world, and they embody opposite philosophies.
Changesets: intent captured per PR
Changesets asks each contributor to declare intent alongside their change. When you make a change worth releasing, you run npx changeset and answer two questions: which packages changed, and is it a patch, minor, or major? That writes a small Markdown file into .changeset/ describing the bump and a human summary. It rides along in your PR and gets reviewed like any other file.
---
"@acme/ui": minor
---
Add a `size` prop to Button with sm/md/lg variants.
The magic is the second phase, run by changesets/action in CI. When changesets land on main, the bot opens (and keeps updating) a Version Packages PR that consumes every pending changeset: it bumps versions, updates each CHANGELOG.md, and deletes the consumed files. Merging that PR is what triggers publish.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: write # push the Version PR + tags
pull-requests: write # open/update the Version PR
id-token: write # OIDC for provenance (see below)
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- uses: changesets/action@v1
with:
publish: npm run release # your "changeset publish" script
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Changesets shines in monorepos: each package gets the version it deserves, dependent packages are bumped together, and the changelog is written from human summaries rather than raw commit subjects. The two-phase flow is also a safety feature — versioning and publishing are separate steps, and a person always approves the actual release by merging the Version PR.
semantic-release: versions derived from commits
semantic-release takes the opposite bet: don’t ask humans to declare intent at all — infer it from commit messages. It requires Conventional Commits (feat:, fix:, feat!: for a breaking change), and on every push to a release branch it analyzes the commits since the last tag, computes the next semantic version, generates release notes, tags, and publishes — all in one non-interactive CI run. There is no Version PR and no human gate; the merge is the release.
# semantic-release job — publish is automatic, no Version PR
release:
runs-on: ubuntu-latest
permissions:
contents: write # tags + GitHub Release
id-token: write # provenance via OIDC
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # full history — it needs every commit + tag
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Publishing with provenance over OIDC
Both tools end at the same place — npm publish — and both benefit from the biggest recent shift in npm security: trusted publishing with OIDC, generally available since mid-2025. Instead of storing a long-lived NPM_TOKEN secret that can leak, you configure your package on npmjs.com to trust a specific GitHub Actions workflow. At publish time, the runner mints a short-lived OIDC identity token, npm verifies it came from exactly that workflow, and issues a one-time credential.
Two payoffs. First, no secret to steal — nothing long-lived sits in your repo. Second, npm automatically attaches a provenance attestation: a signed, public record linking the published tarball back to the exact commit and workflow run that built it. Consumers can verify a package was built where it claims. The id-token: write permission you saw in both jobs above is what makes this work.
permissions:
id-token: write # let the runner mint the OIDC token
contents: write
# with trusted publishing configured, no NPM_TOKEN secret is needed,
# and provenance is generated automatically on publish
Trusted publishing needs npm CLI 11.5.1 or newer (bundled with recent Node LTS lines) and only generates provenance for packages in public repositories. If you can adopt it, it retires the single most-leaked credential in the npm ecosystem. See publishing to npm for the package-side configuration.
Summary
- CI verifies every change automatically (install → lint → type-check → test → build); CD keeps a release always ready or ships it, the only difference being whether a human presses the last button.
- A GitHub Actions workflow is YAML in
.github/workflows/: events trigger jobs that run steps on fresh runners, using reusable actions from the Marketplace. Every job starts from an empty machine — hencecheckoutfirst. - Fan work out into parallel jobs (lint, types, test) and gate the build/deploy on them with
needs. Usenpm ci, and move files between jobs with artifacts. - Cache dependencies with
setup-node’scache: npm(keyed on the lockfile) to skip the network; cache build outputs with Turborepo remote cache in a monorepo. - A matrix runs one job across Node versions × operating systems — test the LTS lines your users run (22, 24, with 26 as Current).
- Keep credentials in secrets, gate risky steps behind environments with required reviewers, scope the workflow token with permissions, and never expose secrets to fork PRs.
- Enforce quality with branch protection / required checks and review against preview deployments.
- Automate releases with Changesets (per-PR intent → a Version PR you merge, ideal for monorepos) or semantic-release (versions inferred from Conventional Commits, fully automatic). Publish with OIDC trusted publishing to drop the
NPM_TOKENand get provenance for free.