Dependency Management¶
How to triage and merge the Dependabot pull requests on Sowel without breaking CI, the runtime, or your afternoon. This is a maintainer playbook, distilled from a full pass across the Sowel and passepartout backlogs.
The single most useful habit: read why a Dependabot PR is red before touching it. Almost every red is one of a few known shapes (peer conflict, engine mismatch, new lint rule), not a code bug. Diagnosing the shape tells you the fix.
Principles¶
- A green CI does not prove a runtime is safe. CI runs typecheck, lint and unit tests. It does not click through the app. Runtime-state libraries can pass every test and still change behavior (see Verify in-app).
- A red CI is usually not a code bug. On the last full pass, every red was a peer-dependency conflict or a Node engine mismatch, never a defect in Sowel's code. Look at which step failed:
- Install (ERESOLVE) -> peer conflict. The bump is coupled to another package that must move with it. See Coupled clusters.
- Worker exited unexpectedly / native crash -> a native module engine mismatch (Node version). See The engines trap.
- Typecheck / lint -> a stricter compiler or a new lint rule. Real work, but scoped and predictable.
- Bump coupled majors together, in one PR. Dependabot opens one PR per package. For peer-coupled toolchains, each PR fails alone and only the set installs. Consolidate them (or group them in config) and close the redundant individual PRs.
- Never let a dependency bump change the production runtime silently. A
dependency can force a Node upgrade through its
enginesfield. That is an infrastructure decision (Dockerfiles + CI), not a library bump. Do it deliberately, on its own PR, and remember that merging is not deploying.
Dependabot configuration¶
.github/dependabot.yml groups ordinary minor/patch bumps into one PR per
ecosystem (backend-minor-patch, ui-minor-patch, github-actions).
Linters, formatters and the TypeScript compiler are excluded from those groups on purpose: a bump to any of them can fail CI on unchanged code (a new lint rule, a reformat, a stricter typecheck), which would poison the whole group and block every unrelated library. They ship as individual PRs so their upgrade can be reviewed on its own.
Recommended addition: a build-tooling group. The web build chain
(vite, @vitejs/*, vite-plugin-*, vitest) is peer-coupled: vitest's
major tracks vite's, and @vitejs/plugin-react pins a vite peer range.
Ungrouped, Dependabot fragments them into individual majors that cannot merge
alone (you will see both a grouped PR and duplicate singletons for the same
deps). Group them so they travel as one reviewable unit:
groups:
ui-build-tooling:
patterns:
- "vite"
- "@vitejs/*"
- "vite-plugin-*"
- "vitest"
- "@vitest/*"
update-types:
- "minor"
- "patch"
- "major"
Triage workflow¶
Sort the open PRs into tiers before merging anything.
| Tier | What | Action |
|---|---|---|
| 1 | CI/release workflow bumps (github-actions), no app code | Merge (watch the next release for release.yml changes). |
| 2 | CI-green library bumps, including runtime-state libs | Merge one by one. For runtime-state libs, a quick in-app check first. |
| 3 | CI-red: peer conflicts, engine mismatches, new lint rules | Deliberate work. Consolidate coupled clusters, or hold with a documented reason. |
For each PR before merge:
- Read the failing step (Section Principles, point 2).
- Check for overlap. Bumps that touch the same
package.json/ lockfile supersede each other. Merging one rebases or obsoletes the rest. Keep the highest version, close the stale ones with a one-line reason. - Merge, rebase the next, regenerate the lockfile. See Merge mechanics.
Coupled clusters¶
These sets fail individually and only resolve together. Bump each as one PR.
Web build toolchain¶
@vitejs/plugin-react -> vite -> vitest. @vitejs/plugin-react v6 requires
vite ^8; vitest v4 pairs with vite 8. Installing any one alone against an
older sibling throws ERESOLVE. vite 8 switches the bundler to Rolldown,
so verify the production build and the app, not only the tests.
Web lint toolchain¶
eslint -> eslint-plugin-react-hooks -> typescript-eslint. eslint 10
requires eslint-plugin-react-hooks >= 6. Check that typescript-eslint's
current range already lists eslint ^10 (recent 8.x does).
Watch a policy trap here: eslint-plugin-react-hooks 7's recommended
config newly enables the opinionated React Compiler lints (set-state-in-effect,
use-memo). Adopting those is a separate decision, not a version bump. To keep
the lint policy unchanged, pin the two classic hook rules explicitly instead of
spreading the new recommended set:
eslint 10 also adds a core no-useless-assignment rule that may flag genuine
dead stores. Those are real, behavior-preserving cleanups; fix them.
TypeScript 7: hold¶
typescript 7 is the new native (Go) compiler. typescript-eslint caps its
peer at typescript <6.1.0 and rejects it, so any lint-tooled package (the UI,
the backend if it runs typescript-eslint) fails ERESOLVE on install. Hold
the TS 7 PRs and revisit when typescript-eslint ships native-compiler support.
Do not burn time forcing it. If the backend has no eslint it may pass in
isolation, but do not split the repo onto two major compilers for no benefit.
The engines trap¶
A native module can require a newer Node than the project runs, and fail in a way that looks like a test flake.
Symptom on CI: unit files that do not touch the module pass, then the file that
opens it dies with Error: Worker exited unexpectedly (the native addon
segfaults the vitest worker fork). It passes on a dev machine only because dev
machines run a newer Node.
Root cause: the module declares engines: { node: ">=X" } and ships no prebuild
for the CI/runtime Node. npm installs anyway (engines is a warning by default),
then the addon fails to load.
The precedent, and how Sowel handled it: better-sqlite3 13 requires Node >= 22
while Sowel ran Node 20 everywhere and depended on better-sqlite3 ^11.x.
The day a better-sqlite3 major landed, that wall was production's problem.
Do not react to it as a dependency bump. Plan the Node move on its own terms. The recipe below is what the Node 20 to 22 migration actually followed:
- Node 20 reached end of life on 2026-04-30, so the move was overdue hygiene regardless of any single dependency.
- Change one variable. Bump the runtime alone and freeze every dependency,
better-sqlite3included. Riding a native-module major in on the same PR means a failure has two possible causes instead of one. - Bump
Dockerfile(everyFROM node:stage and the NodeSourcesetup_XX.xline in the runtime stage, which is easy to miss because it is not aFROM), allnode-versionin CI and release workflows, andenginesinpackage.json(root and any subpackage). - Verify with a real
docker buildof each image on the new Node, and load the native module inside the built image (node -e "require('better-sqlite3')..."), which is the exact path that crashes on the wrong Node. - Then run a shadow instance against a copy of production data before going anywhere near production.
- Merging the PR does not deploy. Roll the new images out on your own window.
Note the LTS calendar when picking a target, and prefer the active LTS. Sowel learned this the expensive way: the 20 to 22 move landed on a release that was already in maintenance with end of life on 2027-04-30, and was followed almost immediately by a 22 to 24 move onto the active LTS (end of life 2028-04-28). Aiming at active LTS in the first place would have been one migration instead of two.
The second move also showed the prebuild question is not settled by the first:
better-sqlite3 11.10.0 publishes prebuilds up to ABI 131 (Node 23) and none
for Node 24's ABI 137, so the runtime stage compiles it from source instead of
downloading it. That is fine here because the stage already installs the
toolchain, but it is worth checking before assuming a runtime bump is free.
Merge mechanics¶
Sowel's main is protected by a ruleset: required checks (Backend/Frontend/scan),
and Dependabot PRs need one approval (owner approval satisfies it). Two traps:
gh pr merge --delete-branchdeletes the branch even when the merge fails. If a PR is out of date and the merge is rejected, the branch is still deleted, which closes the PR unmerged. Recovery: recreate the change on a fresh branch offmain(rebase, regenerate the lockfile) and open a new PR.- PRs sharing a
package.json/ lockfile must be merged in sequence. After merging one, the next is out of date and its lockfile conflicts. Rebase it onmain, regenerate the lockfile (git checkout --ours package-lock.json, thennpm install), re-run the checks, then merge. Merge the shared-lockfile PRs one at a time; merge the last one last so it stays current.
Lockfile churn on a vitest major (hundreds of deleted lines) is usually
legitimate dedup, not corruption: vitest 3/4 share a single top-level
esbuild/vite instead of vitest 2's nested per-package copies.
Verify these in-app¶
CI does not exercise runtime behavior. Before merging a bump to a library that drives live state or math, do a quick manual check, even if CI is green:
- State stores (
zustand): load the app, exercise a flow, watch the console for render loops. - i18n (
i18next,react-i18next): confirm both languages still render. - Astronomical / time math (
suncalc): a major can shift the API or the output. Verify sunrise/sunset and anything downstream (sunlight, freecooling). - Logging (
pino): confirm structured logs and redaction still work. - Uploads / archives (
@fastify/multipart,archiver): exercise one round-trip.
Sowel is a production system. When in doubt, verify against a shadow instance, never production.
Checklist¶
- [ ] Read which CI step failed before assuming code work.
- [ ] Grouped coupled majors into one PR; closed the redundant singletons.
- [ ] Held TS 7 and anything else blocked upstream, with a comment on the PR.
- [ ] For a native-module or engine bump, treated the Node move as its own
deliberate PR, verified via
docker build. - [ ] Merged shared-lockfile PRs in sequence, rebasing and regenerating the lockfile between each; merged the last one last.
- [ ] Did an in-app check for any runtime-state library, even on green CI.
- [ ] Never deployed to production off the back of a merge without sign-off.