DEV Community

nareshipme
nareshipme

Posted on

Three CI failures we fixed this week (and what each one taught us)

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'.
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

This made local typecheck pass — but would have broken CI even harder.

The actual fix

npm install  # update node_modules to match package.json
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is the installed version what package.json declares?
  2. Is the CI environment installing the same lockfile?
  3. 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/.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)