Ch 15 taught you what CI is and how to write a workflow. Ch 21 priced it out and proved most of it is free. Ch 24 did the buy-vs-rent math for the one expensive case — macOS builds — and landed on a headless Mac mini.
All three of those chapters kept brushing past the same two words without ever stopping to define them properly:
ci:local — run the exact same checklist a server would run, but on the computer already in front of you, before you push.
This chapter stops and looks at it head-on. What "local CI" actually is, the one rule that keeps it honest, a real pros-and-cons (not a sales pitch), every place you can run the checklist — on your own machine and in the cloud — and how the two layers stop competing and start covering for each other. We'll use this project's own setup as the worked example throughout, because it ships a real one.
First, What "ci:local" Actually Means
There's nothing mystical about CI. Strip away the dashboards and the green checkmarks and it is one sentence: run a checklist of commands on a clean copy of the code, and if any of them fail, something is broken. Ch 15 called that checklist a workflow; the commands are usually lint, type-check, test, build.
"Online CI" runs that checklist on a rented computer in a data centre after you push. "Local CI" runs the very same checklist on your laptop, before you push. That's the whole distinction. Same commands, different machine, different moment.
The name comes straight from how npm scripts are written (see Ch 20). The colon in ci:local isn't an operator — it's just a naming convention for grouping related scripts. Plenty of teams add a script literally called this:
// package.json — the convention some teams use
{
"scripts": {
"ci:local": "npm run lint && npm run typecheck && npm test && npm run build"
}
}So npm run ci:local becomes "run the whole CI checklist, here, now." This project doesn't spell it ci:local — it calls the same idea check — but the concept is identical, and we'll get to the real one in a moment.
The Same Checklist, Two Places It Can Run
Here is the mental model the rest of the chapter hangs on. One checklist. Two places it can execute. Two very different feedback speeds.
Figure 1 — The same checklist feeds two lanes. Local is fast, free, and private (you fix things before anyone sees them). Online is slower and metered but enforced and shared. They are not rivals; they're the same checklist run twice for two different reasons.
The reason both lanes are worth having comes down to feedback speed. The earlier a mistake is caught, the cheaper it is to fix — an idea the industry calls shifting left (catching problems further "left" on the timeline, nearer the keyboard). Your editor catches typos in milliseconds. ci:local catches a failing test in seconds. Online CI catches it in minutes — after you've pushed, switched tasks, and started thinking about something else.
Figure 2 — The cost of a bug grows as it moves right. ci:local exists to catch things one stop earlier than online CI, so the slow, metered lane almost never has to say "no."
This Project's Real ci:local
Enough theory — this project ships a working local-CI gate, and reading it teaches more than any invented example. Both the website and the backend define a single check script that is their local CI.
The website (website-next/package.json):
// website-next/package.json
{
"scripts": {
"check": "npm run lint && npm run typecheck && npm run check:stripe-links && npm run check:krea-credentials && npm run check:content-coverage",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"check:stripe-links": "node scripts/check-stripe-links.mjs",
"check:krea-credentials": "node scripts/check-no-hardcoded-krea-key.mjs",
"check:content-coverage": "node scripts/check-content-coverage.mjs"
}
}The backend (saas/package.json) does the same thing with its own rules:
// saas/package.json (abbreviated)
{
"scripts": {
"check": "npm run check:json-readers && npm run check:json-responses && npm run check:endpoints && npm run check:sql-select-star && npm run check:migrations && …"
}
}Notice what those check:* scripts are. They aren't generic — each one is a project rule turned into code that fails loudly:
| Guard | The rule it enforces |
|---|---|
check:stripe-links | Every Stripe Payment Link in the code matches the approved set — no stray or mistyped checkout URLs. |
check:krea-credentials | No hard-coded Krea API key ever lands in the repo (the provider is retired; the secret must stay dormant). |
check:content-coverage | Every MDX tutorial is registered in the route/slug maps the sitemap reads — so a new chapter can't be invisible to Google. |
check:endpoints (saas) | Every API route is registered where it should be — no orphan handlers. |
check:sql-select-star (saas) | No SELECT * in D1 queries — columns are listed explicitly. |
So for this project, ci:local isn't aspirational — it's cd website-next && npm run check and cd saas && npm run check, run by a human before deploying. (If you prefer the literal name, "ci:local": "npm run check" is a one-line alias.)
Mini-exercise
Open any project you have with a package.json. Is there a single script that runs all the checks in one go? If not, that's the gap this chapter is about. Jot down the commands you'd want it to run — that list is your ci:local.
The One Rule That Makes It Work: One Script, Two Callers
Here is the single most important idea in the chapter, and the thing most beginners get wrong. Local CI and online CI must run the same script — not "roughly the same steps," the same named command.
The failure mode is drift. If your laptop runs npm run lint && npm test but the GitHub Actions workflow runs eslint . && vitest run --coverage, the two will slowly diverge. One adds a type-check; the other doesn't. Green locally, red online — or worse, green in both while actually checking different things. Now CI is a source of confusion instead of confidence.
The fix is mechanical: define the checklist once, as a named script, and have both callers invoke that one name.
# .github/workflows/ci.yml — the online caller
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run check # ← the SAME command you run locally# Your terminal — the local caller
npm run check # ← identicalNow there is exactly one definition of "passing." The server and your laptop can never disagree about what they check, only about where they run it. When you fix the checklist, you fix it in one place (package.json) and both callers pick up the change for free.
Figure 3 — One script, two callers. This is the "source of truth" idea from Ch 23 applied to your checklist: the check script is authoritative, and both your laptop and the server merely call it. No drift is possible because there's only one definition.
Local CI: The Honest Pros and Cons
Local CI is wonderful, and it is not a complete safety net. Selling it as either extreme is how people end up burned. Here's the real ledger.
| ✅ Pros of running the checklist locally | ⚠️ Cons of relying on local only |
|---|---|
| Fast. Seconds. No push, no queue, no VM boot, no log-fetching round trip. | Not enforced. Nothing stops you pushing without running it. Discipline is the only gate. |
| Free. Your hardware, zero metered minutes (the whole point of Ch 21). | "Works on my machine." Your laptop isn't a clean room — a stray global install or different Node version can hide a break. |
| Offline. Works on a plane, on bad hotel Wi-Fi, anywhere. | No shared record. There's no green check on a PR, no log a teammate or reviewer can point to. |
| Tight loop. You fix the problem before anyone — human or server — ever sees it. Maximum "shift left." | Can't vouch for others. It only proves your machine is happy, never a contributor's. |
| Full power. Your real toolchain, GUI debuggers, and the slow macOS build with no 10x runner tax (Ch 24). | Easy to skip under pressure. "It's a tiny change, I'll skip the checks" is exactly when things break. |
The cons all share one root: local CI is advice, not a law. It can be ignored, and it speaks only for one machine. That's precisely the gap online CI fills.
Online CI: The Honest Pros and Cons
The same ledger from the other side.
| ✅ Pros of running the checklist online | ⚠️ Cons of relying on online only |
|---|---|
| Enforced. It's the merge gate — you literally cannot merge a red PR (when branch protection is on). | Costs minutes. Metered, and macOS bills at 10x (Ch 24). |
| Clean room. A fresh VM every run catches the "works on my machine" bugs your laptop hides. | Slow feedback. Push → queue → boot → run → fetch logs. Minutes, after you've moved on. |
| Shared truth. A visible green check, full logs, and an audit trail everyone can see. | Needs the network up. Provider outage or no connection = no CI. |
| Safe for strangers' code. Runs untrusted PRs in a throwaway sandbox, not on your machine. | Secrets overhead. Tokens and keys must be stored and scoped in the provider. |
| Always-on. Runs automatically on every push, even while you sleep. | Setup + maintenance. A YAML file to write, debug, and keep current. |
Read the two tables together and the punchline writes itself: the pros of one are the cons of the other. Local is fast/free/private but unenforced and single-machine. Online is enforced/clean/shared but slow, metered, and networked. You don't pick a winner — you use each for what it's good at.
Where You Can Run It — Local-Host Options
"Run it locally" isn't a single thing. There's a ladder from "type the command yourself" to "automate it so it's impossible to skip." From simplest to most powerful:
| Option | What it is | Best for |
|---|---|---|
| Run it by hand | npm run check before you push. The honest baseline. | Solo, just starting. Costs nothing, requires you to remember. |
| Native git hook | A pre-push script in .git/hooks/ that runs the checklist and aborts the push if it fails. Not committed to the repo. | Solo automation with zero dependencies. |
| Husky | Stores hooks in a committed .husky/ folder so the whole team shares the same gate. The popular default in JS projects. | Teams that want everyone to get the hook on clone. |
| lefthook | A single fast binary that runs hooks in parallel; config in one lefthook.yml. Language-agnostic. | Bigger hook setups where speed and parallelism matter. |
| pre-commit (framework) | The pre-commit.com tool — a YAML-defined registry of hooks across many languages. | Polyglot repos (Python + JS + shell) wanting one hook manager. |
| Makefile / just / Task | A task runner that names your commands (make check, just check) independent of npm. | Repos that aren't only JavaScript, or want one entry point across tools. |
| act | Runs your actual GitHub Actions workflow file locally in Docker. Linux runners only. | Debugging a broken workflow without the push → wait → red loop. |
| Docker (same image) | Run the checklist inside the same container the server uses, so "clean room" happens locally too. | Killing "works on my machine" without leaving your laptop. |
| Self-hosted runner | Your machine registered with GitHub Actions: their dashboard and gate, your hardware, no metered minutes. The Mac-mini move from Ch 24. | Heavy or macOS builds, dodging the 10x tax while keeping the online gate. |
| Watch mode | vitest --watch, tsc --watch — re-runs the relevant check the instant you save. | The innermost loop, while you're actively writing the code. |
Notice the self-hosted runner sits on the seam. It's "local hardware" but "online orchestration" — your Mac mini does the compute, GitHub provides the dashboard and the gate. That hybrid is the bridge to the next section.
Where You Can Run It — Online Options
Every online CI sells the same thing — minutes on a VM it boots and destroys for you (Ch 21). They differ in free tier and what they pair best with:
| Service | What it's best at |
|---|---|
| GitHub Actions | The default if your code is on GitHub. Free + unlimited on public repos; generous free tier on private. The gate most of this series assumes. |
| GitLab CI | The built-in option if your code lives on GitLab. Same model, tightly integrated. |
| Cloudflare Workers Builds | Build + deploy on push for Workers / Next-on-OpenNext — this project's stack. Push-to-deploy with no YAML, but it's a deploy pipeline, not a merge gate (Ch 24). |
| Vercel / Netlify | Host + CI in one for frontend apps: preview deploy per PR, build on push. Great for Next.js / JAMstack. |
| CircleCI | Standalone CI with a generous free credit pool; common in teams not centred on GitHub Actions. |
| Bitrise / Codemagic | Mobile-first CI with managed macOS runners, code signing, and TestFlight upload baked in — the iOS angle (see the Ship iOS CI chapter). |
| Xcode Cloud | Apple's own CI, wired into Xcode and App Store Connect. The path of least resistance for Apple-platform apps. |
| Jenkins | The self-managed classic: you run the server. Maximum control, maximum maintenance. Mostly enterprise now. |
For this project's web stack the answer is small: GitHub Actions for the gate, Cloudflare Workers Builds for the deploy. The mobile services matter only because SimpleAppShipper also ships a macOS app — which is exactly why its one workflow file is about macOS.
Local vs Online, Side by Side
The decision table, distilled. This is the "pros and cons" of the two locations in one view:
| Dimension | 💻 Local (ci:local) | ☁️ Online CI |
|---|---|---|
| Speed of feedback | Seconds | Minutes (push → queue → boot → run) |
| Cost | Free (your hardware) | Metered minutes (macOS = 10x) |
| Enforced? | No — you can skip it | Yes — the merge gate |
| Clean-room (catches "works on my machine") | No (unless Docker) | Yes — fresh VM each run |
| Works offline | Yes | No |
| Safe for strangers' code | No | Yes — throwaway sandbox |
| Shared record / audit trail | No | Yes — visible check + logs |
| Setup effort | Minimal (one script) | A YAML file + secrets |
| Runs automatically | Only via a git hook | Yes — every push |
Look down the two columns: there is no row where one wins on everything. Local owns speed, cost, offline; online owns enforcement, clean room, trust, shared record. That shape is the argument for using both.
How They Fit Together — Not Either/Or
The trap is treating this as a choice. It isn't. The strongest, cheapest setup runs the same checklist as concentric layers, each catching what slipped past the one before:
Figure 4 — Belt and suspenders. ci:local (green) catches the overwhelming majority in seconds, so the metered online gate (blue) rarely has to fail — but it's there to enforce the rule and catch the clean-room bugs your laptop hid. Same npm run check at every stage.
The division of labour is clean:
- Local CI is for you — speed. Catch it in seconds so you fix it before it costs anyone anything.
- Online CI is for everyone — trust. Enforce the rule, run it on a clean machine, post a green check the whole team (and outside contributors) can rely on.
When local does its job, online almost never goes red — which keeps your metered minutes near zero and the feedback loop near instant. That's the win.
What simpleappshipper.com Actually Does
Keeping with the honesty of Ch 21 and Ch 24 — here's this project's real arrangement, which happens to use every layer in this chapter:
- Local CI:
cd website-next && npm run checkandcd saas && npm run check, run by hand before any deploy. This is the day-to-day gate, and it's free. - Deploy:
npm run cf:build && npm run cf:deploy— also by hand. Cloudflare Workers Builds could do push-to-deploy, but a one-person project deploys when the human is ready. - Online orchestration on local hardware: the repo's one workflow,
.github/workflows/macos-build.yml, compiles the macOS app on a self-hosted Mac mini — GitHub's dashboard and trigger, the mini's compute, no metered macOS minutes. It's deliberately constrained:workflow_dispatchonly (nothing builds automatically), owner-gated, a read-only token, and no secrets — because the runner is a personal machine, and a self-hosted runner must never execute untrusted code.
So the honest summary: the deployer, developer, and tester are the same person, so local CI does the real work and costs $0. The setup graduates to an automatic online gate the day a second person can push to main, or the day the repo opens to outside contributors — at which point "enforced, clean room, shared check" stops being optional. Until then, local-first isn't a compromise; it's the right amount of machinery.
Mental Model — Three Sentences
- CI is one checklist; "local" and "online" are just where and when you run it — your machine before the push, or a rented VM after — so the smart move is to run the same checklist in both places.
- Define the checklist once as a named script (
npm run check) and have both your laptop and the workflow file call that one name — same words, no drift, ever. - Local CI buys you speed and costs nothing but can't enforce anything; online CI enforces the rule and catches clean-room bugs but costs minutes — they're complements, not competitors, and a good git hook means online almost never goes red.
Try It Yourself (10 Minutes)
- In any project with a
package.json, add a single script that chains your checks:"check": "npm run lint && npm test && npm run build". Runnpm run check. Congratulations — that'sci:local. - Turn it into a
pre-pushgit hook (the script is in Ch 21),chmod +xit, then deliberately break a test and try to push. Watch git abort the push. Fix it; push goes through. - If you have a
.github/workflows/file, make its run step saynpm run check— the exact command from step 1. Now local and online physically cannot disagree about what "passing" means. - (Optional) Install
act, runactin that repo, and watch your real workflow execute locally in Docker — no push, no minutes, no waiting.
You now know not just how CI works, but the two places it can run, what each is good and bad at, and how to wire them so they cover for each other.
Where This Lands in the Series
That closes the operational-workflow track at full strength: you can write a CI pipeline, lint and test inside it, open and review PRs against it, read its file types, price it, host it cheaply on a Mac mini, and run the whole thing locally for instant, free feedback — with one script that keeps local and online honest.
Everything so far has been about the plumbing around your code. Part 3 finally turns to the code itself: Ch 26 — Why JavaScript Needs a Framework. What plain <script> tags couldn't do, what React actually solved, and why those frameworks are exactly where build steps get heavy enough that the local-vs-online CI tradeoff in this chapter starts to really bite.
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