A green CI pipeline is a contract. When it breaks in ways that differ from your local environment, something in the contract is wrong — not the code. Here are three failures we debugged this week on ClipCrafter and the underlying problems each exposed.
Failure 1: CI fails typecheck, local passes
What we saw
error TS2345: Argument of type '{ quality: number; }' is not assignable
to parameter of type 'ExportOptions'.
Types of property 'quality' are incompatible.
Type 'number' is not assignable to type 'QualityPreset | undefined'.
Passed locally. Failed in CI. Classic.
The investigation
First instinct: the code changed the type. But quality was already a string in our code — we were passing "medium" or "original". So why was CI complaining about number?
The error message says QualityPreset — a type that didn't exist in our local type definitions at all.
$ cat node_modules/framewebworker/package.json | grep version
"version": "0.4.0"
$ cat package.json | grep framewebworker
"framewebworker": "^0.5.5"
Local node_modules was 0.4.0. CI installs fresh from npm every run and got 0.5.5. The library changed quality between versions:
// 0.4.0 — quality is a number (0-1)
quality?: number;
// 0.5.5 — quality is a named preset
type QualityPreset = 'medium' | 'original';
quality?: QualityPreset;
We had already written quality: "medium" — which is correct for 0.5.5. But with 0.4.0 installed locally, the compiler accepted number too loosely.
The wrong fix we almost made
We initially mapped the string to a number:
// ❌ wrong — fighting the type instead of fixing the environment
const quality = quality === "original" ? 1 : 0.8;
This made local typecheck pass — but would have broken CI even harder.
The actual fix
npm install # update node_modules to match package.json
With 0.5.5 installed locally, our original string values were already correct. No code change needed.
The lesson
When CI typecheck fails but local passes, suspect the environment before the code. Check node_modules version vs package.json declared version. A stale install is invisible until CI runs fresh.
Add this to your debugging checklist before changing code to satisfy a type error:
- Is the installed version what
package.jsondeclares? - Is the CI environment installing the same lockfile?
- Did a transitive dependency change?
Failure 2: Worker sync check fails after billing changes
What we saw
❌ Worker files are out of sync with src/.
Run: bash scripts/sync-worker.sh
Then commit the changes in worker/src/.
The context
The project runs a separate worker process alongside the Next.js app. Certain lib files are shared between them, but the worker uses relative imports instead of path aliases (@/lib/billing → ./billing). Rather than maintaining two copies by hand, a sync script rewrites imports and copies the files:
# scripts/sync-worker.sh (simplified)
for f in supabase r2 billing transcribe highlights; do
sed 's|from "@/lib/\([^"]*\)"|from "./\1"|g' \
src/lib/$f.ts > worker/src/lib/$f.ts
done
CI diffs the expected output against the committed worker/src/ files. Any divergence fails the build.
What happened
We'd updated src/lib/billing.ts as part of the billing hardening work and didn't run the sync script before pushing. The worker copy was one commit behind.
The fix
bash scripts/sync-worker.sh
git add worker/src/lib/billing.ts
git commit -m "chore: sync worker billing"
The lesson
Automated sync checks are worth the setup cost. Without the CI check, the worker would have silently run old billing code while the app used the new atomic RPC call — a subtle, hard-to-debug divergence. The check makes the implicit dependency explicit and enforced.
If you have files that need to stay in sync across boundaries (monorepo packages, generated types, worker copies), automate both the sync and the verification.
Failure 3: Linter flags complexity after a refactor
What we saw
error Async function 'POST' has a complexity of 13. Maximum allowed is 10 complexity
error Async function 'POST' has a complexity of 11. Maximum allowed is 10 complexity
The context
The project enforces complexity: ["error", 10] in ESLint — cyclomatic complexity measures the number of independent paths through a function. Each if, else if, ||, &&, try/catch, and ternary adds one.
A refactor had added idempotency checking (a try/catch + an if) and plan ID mapping (a three-way condition) directly into two POST route handlers. Both tipped over 10.
The fix: extract the decision logic
Webhook handler — moved idempotency check into its own function:
// Before: all inline in POST, complexity = 13
// After: extracted, POST complexity drops to 8
async function isDuplicate(payload: Record<string, unknown>): Promise<boolean> {
const eventId = payload.id as string | undefined;
if (!eventId) return false;
const { error } = await supabaseAdmin
.from("webhook_events")
.insert({ id: eventId, event: (payload.event as string) ?? "unknown" });
return error?.code === "23505";
}
Upgrade handler — extracted the plan validation guard:
// Before: three-way comparison inline, adds 2 complexity points
if (plan !== "starter" && plan !== "pro" && plan !== "unlimited") { ... }
// After: extracted to a type guard
function isValidPlan(plan: string): plan is ValidPlan {
return plan === "starter" || plan === "pro" || plan === "unlimited";
}
The lesson
Complexity limits force good decomposition. The isDuplicate function is now:
- Named (self-documenting)
- Independently testable
- Reusable if a second handler needs the same check
None of that would have happened without the linter pushing back. Cyclomatic complexity rules feel annoying until you're reading someone else's 200-line function with 15 nested conditions.
If your project doesn't have one, consider adding:
// .eslintrc or eslint.config.mjs
"complexity": ["error", 10]
Start with 15 if 10 feels too aggressive — the discipline it enforces is worth it.
The common thread
All three failures had the same shape: a local environment that hid a real problem. Stale node_modules hid a version mismatch. No sync check would have hidden a worker divergence. No complexity rule would have hidden spaghetti accumulating in route handlers.
CI's job is to be stricter than your local setup. When it catches something your machine didn't, that's it working correctly.
We build ClipCrafter — AI-powered video clip extraction. If CI war stories resonate, follow along.
Top comments (0)