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.

IntegrationDeliveryDeploymentmerge often,test every pushlint · type · testbuild a releasethat is always readyartifact · stagingship to prodautomaticallyno manual step
CI verifies every change; Delivery keeps a release always ready; Deployment ships it automatically. The gate between the last two is a human decision or the absence of one.

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, a pull_request, a released tag, a manual workflow_dispatch, a schedule (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/checkout clones your repo; actions/setup-node installs Node and wires up caching. You reference one with uses: 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.

push /open PRlinttype-checktestbuilddeploymain onlyrun in parallelneeds: all three green
One push fans out into parallel checks. Build waits on all three (needs); deploy waits on build and only runs on main. A red check anywhere blocks the merge.

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.

First run (cache miss)npm cifetchregistry download~60ssavecache storedkey = hash(lockfile)Next run, lockfile unchanged (cache hit)npm cirestorecache restore~3sinstall from disk
Cold run downloads everything. On the next run the lockfile hash matches, so the cache restores in seconds and npm ci installs from disk. Change the lockfile and the key changes — a fresh download repopulates it.

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.

Node 22Node 24Node 26ubuntuwindowsmacos9 runners, all in parallel
A 3x3 matrix expands one job into nine parallel runs — every Node line crossed with every OS. fail-fast: false lets each finish so you see every failure, not just the first.
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 plain echo.
  • Secrets are not passed to workflows triggered by fork PRs (using the default pull_request event). This is deliberate — an attacker could otherwise open a PR that exfiltrates your tokens. Keep deploy/publish steps on push to protected branches, not on PRs.
  • Environments (Settings → Environments) group secrets and add protection rules: required reviewers, wait timers, and branch restrictions. A production environment 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.

PR #1 + changesetminorPR #2 + changesetpatchPR #3 + changesetpatchmerged to mainbot opensVersion Packages PRhuman mergeschangeset publish→ npmbumps versions · writes CHANGELOG · deletes consumed changesets
Contributors drop changeset files in their PRs. A bot accumulates them into one Version Packages PR that bumps versions and writes changelogs. Merging that PR publishes — a human is always the one who releases.
# .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 — hence checkout first.
  • Fan work out into parallel jobs (lint, types, test) and gate the build/deploy on them with needs. Use npm ci, and move files between jobs with artifacts.
  • Cache dependencies with setup-node’s cache: 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_TOKEN and get provenance for free.