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:
- Download the code
- Install dependencies
- Run the tests
- Lint and type-check
- 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.
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:
- You push a fix on a Friday afternoon. Tests pass locally. You merge.
- Alice pulls Monday morning. Her tests fail. Turns out you forgot to commit a new file.
- Bob is on Windows. The thing you fixed broke on his case-insensitive filesystem.
- The deploy fails on Tuesday because production runs Node 20 and you accidentally used a Node 22 feature.
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 enabledOr, 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 outThe 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:
| Term | What it is |
|---|---|
| Workflow | The whole YAML file. You can have many — one per file. |
Trigger (on:) | When to run. push, pull_request, schedule, workflow_dispatch (manual). |
| Job | A 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. |
| Step | One 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 buildRead it top to bottom — it's literally a recipe:
actions/checkout@v4— git-clone the repo onto the runner. Without this the VM is empty.actions/setup-node@v4— install Node 20 on the runner, pointnodeandnpmat it, and cache~/.npmbetween runs so dependency installs are faster.npm ci— install dependencies (we'll explain why it'scinotinstallin the next section).npm run lint— run whatever thelintscript inpackage.jsonis.npm test— run the test suite.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 install | npm ci | |
|---|---|---|
| Designed for | Day-to-day dev | CI servers + reproducible builds |
| Reads | package.json | package-lock.json |
Modifies package-lock.json? | Yes — updates it when versions change | No — refuses, fails if it's out of sync |
Deletes node_modules first? | No | Yes, always |
| Speed | Slower (resolves versions) | Faster (no resolution, just downloads) |
| Fails if lockfile and package.json disagree? | No — quietly reconciles | Yes — 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_modulesfirst so a stale install can't poison the build.
That's exactly what you want on a CI server, because:
- Reproducibility. Every run gets the same
node_modulesyour laptop got, byte-for-byte. - Speed. No version resolution, no "is this newer version compatible". Just download and unzip.
- Drift detection. If you forgot to commit an updated
package-lock.json,npm cifails immediately. That's the good failure — it surfaces the bug at install time instead of at deploy time three days later.
Rule of thumb:
- On your laptop:
npm install(when adding/updating packages) or justnpm installonce aftergit pull. - On CI: always
npm ci. pnpmandyarnhave equivalents:pnpm install --frozen-lockfileandyarn install --frozen-lockfile. Same idea — refuse to update the lockfile.
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 → Settings → Secrets and variables → Actions → New 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.
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 Tokens → Create 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 scripts — lint, 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".
| System | When you'll see it |
|---|---|
| GitHub Actions | Default for anything on GitHub. What this chapter shows. |
| GitLab CI | .gitlab-ci.yml. Identical idea, different YAML. Built into GitLab. |
| CircleCI | Older alternative. Was big before Actions. Still common at larger companies. |
| Travis CI | The 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 / Codemagic | iOS/Android-focused CI. Pre-installed Xcode, code-signing, simulator. SimpleAppShipper integrates with both in its CI Builds module. |
| Xcode Cloud | Apple'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:
- More than one developer can push to
main. - You accept external contributions (open source). PR authors can't run your full suite, so the robot has to.
- Production has uptime requirements that make manual deploys risky on a Friday night.
- You ship multiple times a day and forgetting to run
npm testbecomes a when, not an if. - The build is slow enough that you'd rather have it happen on a server while you keep coding.
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
- 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.
- GitHub Actions is the way to spell that checklist on GitHub — one YAML file in
.github/workflows/that lists steps to run. npm ciis the install command designed for that robot: it refuses to update the lockfile, deletesnode_modulesfirst, 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.
- In any of your GitHub repos, create the file
.github/workflows/ci.ymlwith the realistic workflow from earlier in this chapter. - Commit and push.
- Open the repo on github.com → Actions tab. Watch your first run go yellow → green (or red).
- Make a deliberate typo in a test, push, watch CI go red. Read the logs. Fix it, push, watch it go green.
- (Optional) Repo → Settings → Branches → Add branch protection rule for
main→ require thetestjob 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.
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