Tutorials Ultimate Web Development Series Chapter 15

What Is CI? GitHub Actions, npm ci, and Worker Deploys Explained

WebChapter 15 of the Ultimate Web Development Series25 minMay 27, 2026Beginner

You read the words "CI passed", "CI failed", "the workflow ran", "GitHub Actions deployed it", and npm ci in README files. Every open-source repo has a .github/workflows/ folder. PRs have a little green checkmark or a red X next to them. And nobody ever sits you down and explains what any of it actually is.

This chapter does that. By the end you'll know what CI does, why projects gate the merge button on it, what a GitHub Actions workflow file is line-by-line, why npm ci is not a typo for npm install, and you'll have a complete workflow that auto-deploys a Cloudflare Worker every time you push to main.

What "CI" Actually Means

CI stands for Continuous Integration. The name is older than the modern tooling and slightly misleading — today it really means:

Every time someone pushes code, run a checklist on a clean computer in the cloud, and report back whether everything still works.

That's it. CI is a robot intern that runs your checklist on every push.

The "checklist" is whatever you put in it. The bare minimum at most projects is:

  1. Download the code
  2. Install dependencies
  3. Run the tests
  4. Lint and type-check
  5. Build the production bundle

If any step fails, the robot tells GitHub "this push is broken". GitHub puts a red X on the commit. The PR can't merge. Somebody has to fix it.

That's the whole game. Everything else — GitHub Actions, GitLab CI, CircleCI, the YAML files, the runners, the secrets — is just plumbing for this one idea.

Loading diagram…

Figure 1 — The CI loop. Push → robot wakes up → runs your checklist → reports a green check or a red X. Everything else in this chapter is the inside of those boxes.

The Bug This Solves: "Works on My Machine"

Why do we need a robot to run a checklist? Because humans forget.

Imagine three developers working on the same web app:

Every one of those is "works on my machine". CI prevents all of them by running the same checklist on the same clean environment every time, before the code is allowed to merge. No human discipline required.

That's the value: CI removes the trust that everyone remembered to run the tests, on the right OS, with the right Node version, on a fresh clone, before merging.

Why "Passing CI" Gates the Merge Button

Open any pull request on a mature GitHub repo. Scroll to the bottom. You'll see something like:

✅ All checks have passed
   ✅ test (ubuntu-latest, node 20)
   ✅ lint
   ✅ typecheck
   ✅ build
 
[Merge pull request]   ← button is enabled

Or, if the robot failed:

❌ Some checks were not successful
   ✅ lint
   ❌ test — 3 failing
   ⏭  build (skipped)
 
This branch has 1 failing check.
[Merge pull request]   ← button greyed out

The greying out is not a suggestion. It's a branch protection rule the repo owner configured in GitHub Settings → Branches. The rule says: "Pull requests targeting main must have these status checks passing before they can be merged." Without that rule the merge button stays clickable, and the green/red check is just informational.

With it, a red X literally blocks the merge. That's why open-source projects ask you to "wait for CI" before reviewing your PR. They physically cannot merge it until it's green.

GitHub Actions — The Plumbing

GitHub Actions is GitHub's built-in CI system. There are competitors (GitLab CI, CircleCI, Travis, Buildkite, Jenkins), but if your code is on GitHub, Actions is free for public repos and basically free for small private ones, and there's no separate dashboard to learn. We'll use it for the rest of the chapter.

The whole API is one file: .github/workflows/<anything>.yml. Drop a YAML file in that folder, push it, and GitHub runs it.

Here's the smallest possible workflow:

# .github/workflows/ci.yml
name: CI
 
on: [push]
 
jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello from CI"

That's a real, complete, working workflow. Push it. Open the Actions tab on your repo. You'll see a green check and the line "Hello from CI" in the logs.

Let's name the parts:

TermWhat it is
WorkflowThe whole YAML file. You can have many — one per file.
Trigger (on:)When to run. push, pull_request, schedule, workflow_dispatch (manual).
JobA unit of work that runs on its own fresh VM. Jobs run in parallel by default.
Runner (runs-on:)Which VM. ubuntu-latest, macos-latest, windows-latest. Linux is fastest + cheapest.
StepOne thing to do. Either a shell command (run:) or a reusable action (uses:).
Action (uses:)A pre-built step someone else published. Like actions/checkout@v4.

A Realistic Workflow: Install, Lint, Test, Build

Here's what 90% of JavaScript project CI files look like:

# .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"
          cache: "npm"
 
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Read it top to bottom — it's literally a recipe:

  1. actions/checkout@v4 — git-clone the repo onto the runner. Without this the VM is empty.
  2. actions/setup-node@v4 — install Node 20 on the runner, point node and npm at it, and cache ~/.npm between runs so dependency installs are faster.
  3. npm ci — install dependencies (we'll explain why it's ci not install in the next section).
  4. npm run lint — run whatever the lint script in package.json is.
  5. npm test — run the test suite.
  6. npm run build — produce the production bundle.

If any of those four run: lines exits with a non-zero status, the job fails. Red X. The PR is blocked.

The on: block at the top says "run this on every push to main and on every pull request to any branch". The pull-request trigger is what gives you the merge gate — PRs targeting main will see the check before they merge.

npm ci Is Not a Typo for npm install

Here's the single most confusing thing about JavaScript CI. Both of these install dependencies. They look the same. They are not the same.

npm installnpm ci
Designed forDay-to-day devCI servers + reproducible builds
Readspackage.jsonpackage-lock.json
Modifies package-lock.json?Yes — updates it when versions changeNo — refuses, fails if it's out of sync
Deletes node_modules first?NoYes, always
SpeedSlower (resolves versions)Faster (no resolution, just downloads)
Fails if lockfile and package.json disagree?No — quietly reconcilesYes — hard error

The ci in npm ci literally stands for CI. The npm team built it specifically for this case. The promise is:

Give me exactly the dependency tree the lockfile pins. Don't update anything. If you can't, fail loudly. Throw away whatever's in node_modules first so a stale install can't poison the build.

That's exactly what you want on a CI server, because:

Rule of thumb:

Secrets — The One Tricky Bit

CI runs on a VM owned by GitHub. You can't put your Cloudflare API token in a YAML file checked into git — anyone with read access to the repo would see it. Same for Stripe keys, database URLs, signing keys, anything sensitive.

GitHub Actions has a Secrets feature. Open your repo → SettingsSecrets and variablesActionsNew repository secret. Add a name (uppercase by convention) and a value. The value is encrypted and never shown again.

From inside your workflow:

- run: npm run deploy
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

${{ secrets.CLOUDFLARE_API_TOKEN }} is replaced at runtime with the secret value. In the logs it's masked as *** so you can't accidentally print it.

If you ever leak a secret (commit it, paste it in a screenshot, log it by accident), rotate it immediately — go to the issuing service, delete the old token, generate a new one, update the GitHub secret. Don't try to git-rewrite-history your way out. Once a secret is on the internet, it's compromised forever.

CD: Continuous Deployment — Same Engine, One More Step

You'll see CI/CD as a phrase. The CD is Continuous Deployment (or "Delivery", same idea): after CI passes on the main branch, automatically ship the result to production.

There's no separate tool for CD. It's just another job in the same workflow that runs only on main and only if tests pass. Here's the canonical wrangler-on-push-to-main pattern:

# .github/workflows/deploy.yml
name: Deploy Worker
 
on:
  push:
    branches: [main]   # NEVER deploy from feature branches
 
jobs:
  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
 
  deploy:
    needs: test                    # only runs if `test` passed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - name: Deploy to Cloudflare
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Two jobs. The needs: test on the deploy job is the magic — it tells GitHub "don't run deploy until test finishes successfully". One red test, no deploy. The dependency forms a tiny pipeline.

Loading diagram…

Figure 2 — needs: test makes deploy a downstream job. Tests pass → deploy runs → Worker goes live worldwide. Tests fail → deploy is skipped and main stays on the previous good version. This is "continuous deployment" in five lines of YAML.

Getting the Cloudflare Token

For the workflow above to actually work, you need two secrets in GitHub. Here's where they come from.

CLOUDFLARE_ACCOUNT_ID — the easy one. Log into dash.cloudflare.com, pick any Worker, and the URL is dash.cloudflare.com/<account-id>/workers.... Or it's printed in the right-hand sidebar of the Workers overview page. Copy it.

CLOUDFLARE_API_TOKEN — Cloudflare dash → My Profile (top right) → API TokensCreate Token → use the "Edit Cloudflare Workers" template. That gives the token permission to deploy Workers and update bindings, but not to touch billing or DNS. Click through, copy the token (it's shown once), and paste it into GitHub as CLOUDFLARE_API_TOKEN.

Now npx wrangler deploy in your workflow can authenticate without anyone typing anything.

The Local-vs-CI Equivalence

A useful mental model: CI is exactly what you'd do on your laptop, just on a fresh VM every time.

Your package.json already has scriptslint, test, build. CI just runs those. Nothing in your workflow file should be magic. If npm test works on your laptop, it works in CI. If it doesn't, the fix is to make it work on your laptop first (often on a fresh clone — cd /tmp && git clone <repo> && cd <repo> && npm ci && npm test). CI failures almost always reproduce locally on a clean checkout.

This is also why "CI uses Linux" matters. If your test suite relies on a Mac-only command, case-insensitive filesystems, or a binary you installed via Homebrew, it'll work on your MacBook and fail in CI. Catching that drift early is half the value.

Other CI Systems You'll See Mentioned

GitHub Actions dominates GitHub-hosted code, but you'll bump into others. The mental model transfers — they're all "run a YAML checklist on a runner".

SystemWhen you'll see it
GitHub ActionsDefault for anything on GitHub. What this chapter shows.
GitLab CI.gitlab-ci.yml. Identical idea, different YAML. Built into GitLab.
CircleCIOlder alternative. Was big before Actions. Still common at larger companies.
Travis CIThe original. Mostly historical now — many OSS repos still have a .travis.yml.
Vercel / Netlify / Cloudflare Pages"Push to deploy" is CI/CD with the YAML hidden. They run their own build pipeline on every push and deploy if it succeeds.
Bitrise / CodemagiciOS/Android-focused CI. Pre-installed Xcode, code-signing, simulator. SimpleAppShipper integrates with both in its CI Builds module.
Xcode CloudApple's own. Triggered from Xcode, runs on Apple's macOS runners.

Does simpleappshipper.com Use CI?

Honest answer: only for the public-facing release pipeline, not for the day-to-day commits you see in git log. The Worker and the Next.js site are currently deployed by hand from a laptop with npm run cf:deploy. That's fine for a one-person project where the deployer is the developer is the tester.

When CI starts paying for itself:

For your first side project: ship without CI. Add it the first time you ship a broken build because you forgot to test something. That moment usually arrives in week two.

Mental Model — Three Sentences

  1. CI is a robot that re-runs your checklist on a fresh VM every push, so nobody has to trust that humans remembered to run it.
  2. GitHub Actions is the way to spell that checklist on GitHub — one YAML file in .github/workflows/ that lists steps to run.
  3. npm ci is the install command designed for that robot: it refuses to update the lockfile, deletes node_modules first, and fails loudly if anything is out of sync — exactly the behavior you want for reproducible builds.

If those three sit in your head, the rest is just YAML syntax you can copy-paste.

Try It Yourself (10 Minutes)

The fastest way to internalise this: add CI to a repo you already have.

  1. In any of your GitHub repos, create the file .github/workflows/ci.yml with the realistic workflow from earlier in this chapter.
  2. Commit and push.
  3. Open the repo on github.com → Actions tab. Watch your first run go yellow → green (or red).
  4. Make a deliberate typo in a test, push, watch CI go red. Read the logs. Fix it, push, watch it go green.
  5. (Optional) Repo → SettingsBranchesAdd branch protection rule for main → require the test job to pass before merging. Now open a PR with a broken test — the merge button is greyed out.

You now have the same pipeline that ships every serious web project. Everything more advanced — matrix builds across Node versions, separate staging/production deploys, preview environments per PR, parallel test sharding, caching strategies — is variations on the same five-step recipe.

Where This Lands in the Series

Part 1 (Chs 1–6) taught you the static-HTML web. Part 2 (Chs 7–14) taught you a deployable Cloudflare Worker backend. This chapter is the operational layer that wraps both: how to keep what you've built actually shipping reliably as you and (eventually) other people change it.

Part 3 starts next chapter, with the topic the project-study chapter teed up: why JavaScript needs a framework, and what changed about frontend development between "throw a <script> tag in an HTML file" (Ch 5) and "run a 200 MB node_modules tree to render a button". Modern frameworks are also where build steps get heavy enough that not having CI starts to hurt — so this chapter is the natural bridge.

Ch 14: 📖 Project Study — Dissecting saas/src/index.jsCh 16: ESLint and Prettier — Why Your Code Has Opinions
Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.Astro + Next.jsAstro & Next.js SeriesStatic and hybrid web app patterns with Astro, Next.js, MDX, dynamic routes, and Cloudflare deploys.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access