Managing updates safely
Dependabot/Renovate and a patch cadence.
A pinned dependency is like fitting every door in a building with the exact same lock and writing down the model number. It is wonderfully reproducible. You know precisely what protects each door, and a new door gets the identical lock. The trouble arrives later. One day a locksmith publishes a note explaining how to pick that exact model, and now every door you own is a known way in. You changed nothing. The world changed around your frozen choice.
That is what happens to pinned software. Pinning (locking each library to one exact version so every build comes out identical) buys you reproducibility. But versions rot. As researchers find flaws in a library and file them as CVEs (Common Vulnerabilities and Exposures, public catalog entries that each describe one specific known security bug), your pinned version quietly collects them. The answer is not to unpin and float on whatever is newest. The answer is to keep updating on a schedule, so the version you are pinned to stays both exact and current.
See how far you have drifted
Before you fix drift, measure it. Two commands tell you how stale your locked tree is and whether any of that staleness is dangerous. The first comes from npm (Node Package Manager, the standard tool for pulling JavaScript libraries into a project) and lists what has newer releases. The second checks those exact locked versions against a vulnerability database.
npm outdated
Package Current Wanted Latest Location Depended bylodash 4.17.19 4.17.21 4.17.21 node_modules/lodash apiminimist 1.2.5 1.2.8 1.2.8 node_modules/minimist apiws 7.4.5 7.5.10 8.18.0 node_modules/ws api
Read the columns. "Current" is what you have locked. "Wanted" is the newest release that fits your allowed range, and "Latest" is the newest that exists. For ws the two disagree: 7.5.10 is a safe in-range bump, while 8.18.0 is a major version that could break your code. That gap is the whole game. Some of these bumps are cosmetic. Some are closing a hole. To find out which, ask a scanner.
osv-scanner --lockfile=package-lock.json
╭───────────────────────────────────┬──────┬───────────┬──────────┬─────────┬───────────────────╮│ OSV URL │ CVSS │ ECOSYSTEM │ PACKAGE │ VERSION │ SOURCE │├───────────────────────────────────┼──────┼───────────┼──────────┼─────────┼───────────────────┤│ https://osv.dev/GHSA-xvch-5gv4-… │ 9.8 │ npm │ minimist │ 1.2.5 │ package-lock.json ││ https://osv.dev/GHSA-3h5v-q93c-… │ 7.5 │ npm │ ws │ 7.4.5 │ package-lock.json ││ https://osv.dev/GHSA-35jh-r3h4-… │ 7.2 │ npm │ lodash │ 4.17.19 │ package-lock.json │╰───────────────────────────────────┴──────┴───────────┴──────────┴─────────┴───────────────────╯
Now the story is clear. The three sleepy-looking bumps from the first command are not cosmetic. The numbers in the CVSS column (CVSS, the Common Vulnerability Scoring System, rates each flaw from 0 to 10, where 10 is worst) tell you how bad each one is. minimist 1.2.5 carries a prototype-pollution flaw (a bug where crafted input poisons the default properties every JavaScript object quietly inherits) scored 9.8, right at the top of the scale. ws 7.4.5 has a denial-of-service bug at 7.5, the kind that lets an attacker hang or crash your service so real users cannot get in. lodash 4.17.19 has a command-injection issue at 7.2, where attacker-chosen input ends up running as a shell command on your box. Every one of them is already fixed in the version that "Wanted" pointed you to. This is the defender's view of a rotting lockfile (the file that records every exact version you have pinned, package-lock.json here): you can see precisely which known-exploitable versions you are still shipping, and precisely which patched version closes each one. The work now is to move onto those patched versions without turning it into a weekend project.
Hand the watch to a tool
You could run those scans by hand every week, but you would forget, and the whole point is to not rely on remembering. So you hire a tool to do the watching. Dependabot and Renovate are like a subscription service that tracks every book on your shelf, and whenever a new edition of one ships, mails you the replacement with a note describing what changed. The replacement does not go straight onto the shelf. It lands on your review desk first. In software terms, the tool watches your locked versions and, when a newer one appears, opens a PR (pull request, a proposed change that waits for review before it can merge). That PR runs through your normal CI (continuous integration, the automated pipeline that builds and tests every proposed change). You stay pinned, and you stay current, because the machine does the noticing and your existing pipeline does the vetting.
{"extends": ["config:recommended"],"dependencyDashboard": true,"osvVulnerabilityAlerts": true,"minimumReleaseAge": "3 days","prConcurrentLimit": 5,"packageRules": [{"matchUpdateTypes": ["patch", "minor"],"matchCurrentVersion": "!/^0/","automerge": true,"platformAutomerge": true},{"matchUpdateTypes": ["major"],"automerge": false,"addLabels": ["needs-human-review"]}],"vulnerabilityAlerts": {"labels": ["security"],"prPriority": 10,"automerge": false}}
Walk through what each line buys you. config:recommended is Renovate's sensible default set. dependencyDashboard opens one tracking issue that lists everything pending, so nothing hides. osvVulnerabilityAlerts pulls from the OSV database (Open Source Vulnerabilities, the same source the scanner used above), not only GitHub's advisories. The two packageRules encode a policy: patch and minor updates that pass CI merge themselves, while major updates stop and wait for a person and get a label. matchCurrentVersion set to "!/^0/" means "do not auto-merge anything still on a 0.x version," because pre-1.0 packages routinely break on a minor bump and have not promised otherwise. platformAutomerge hands the actual merge to GitHub's native auto-merge, so it lands the moment checks go green. And vulnerabilityAlerts gives security-driven PRs a high prPriority so they jump the queue, while keeping a human on the merge button for those.
version: 2updates:- package-ecosystem: "npm"directory: "/"schedule:interval: "daily"open-pull-requests-limit: 5groups:dev-minor-patch:patterns: ["*"]dependency-type: "development"update-types: ["minor", "patch"]labels: ["dependencies"]
If you would rather stay inside GitHub's own tool, the Dependabot config expresses the same intent. The groups block rolls all your dev-dependency minor and patch bumps into one PR instead of twenty, which cuts review noise sharply. One thing to know: Dependabot opens PRs but will not merge them for you. To get auto-merge you turn on the repository setting and add a small workflow that reads the PR's update type with the dependabot/fetch-metadata action, then calls gh pr merge --auto for the patch and minor tiers. Dependabot also has a cooldown setting, which matters for a reason we get to shortly.
Fast-track the fix, slow-walk the rest
A hospital does not treat every arrival in the order they walked in. Triage sorts them: the chest pain goes ahead of the sprained ankle. Your update stream needs the same triage, because the two kinds of update carry completely different clocks. A routine bump can wait until tonight, or next week, and nothing bad happens. A patch that closes a disclosed CVE cannot wait, because the gap between a flaw becoming public and someone scanning the internet for it is often measured in days, sometimes hours. Encoding the tiers in your tool is how the urgent gets urgency and the routine stops stealing your attention: security fixes get flagged and prioritized, ordinary patch and minor bumps flow through automatically once CI is green, and major versions (the ones allowed to break your code) stop and wait for a human who reads the changelog.
A cadence, not a scramble
The goal is that updating feels like a quiet background hum, never a fire drill. Small, frequent, tested updates keep you a step or two behind the newest release at most, so each change is tiny and easy to reason about. Let the tree drift for a year instead, and the eventual update becomes a huge, frightening migration that you keep postponing, which is precisely how you end up as the shop still running the version with the famous CVE. A patch cadence is a security control because it keeps you patchable. Here is what a healthy stream looks like on any given morning.
gh pr list --label dependencies --state open
Showing 4 of 4 open pull requests in acme/api#218 chore(deps): bump fastify from 4.28.1 to 4.29.0 deps/fastify-4.29.0#217 chore(deps): bump pino from 9.3.2 to 9.4.0 deps/pino-9.4.0#216 fix(deps): bump minimist from 1.2.5 to 1.2.8 [security] deps/minimist-1.2.8#215 chore(deps)!: bump @types/node from 20.x to 22.5.0 deps/types-node-22.5.0
Four small PRs, each doing one legible thing. Two ordinary minor bumps, one flagged security fix, and one major (#215, the exclamation mark and the 20 to 22 jump give it away). Come back after your automation has had a turn, and the tiers should have sorted themselves out.
gh pr list --label dependencies --state merged --limit 5
Showing 3 of 3 merged pull requests in acme/api#218 chore(deps): bump fastify from 4.28.1 to 4.29.0 deps/fastify-4.29.0 MERGED#217 chore(deps): bump pino from 9.3.2 to 9.4.0 deps/pino-9.4.0 MERGED#216 fix(deps): bump minimist from 1.2.5 to 1.2.8 [security] deps/minimist-1.2.8 MERGED
This is the policy working in the open. The two minor bumps merged on their own after CI passed. The critical minimist fix merged fast because it jumped the queue. And #215, the major, is not in this list at all: it is still sitting open with its needs-human-review label, waiting for someone to read what changed between Node types 20 and 22. Nobody spent attention on the boring three. All the attention that remains is pointed at the one change where a human genuinely adds something.
Let a new release cool before you swallow it
You would not eat the first plate out of an unfamiliar kitchen the instant it lands. You let it sit a moment, or you wait to see whether anyone else got sick. Brand-new package releases deserve the same caution. When a version publishes, you do not yet know whether it is a real improvement or a malicious release someone pushed after phishing a maintainer's credentials. The npm ecosystem has lived this many times: a popular package gets a poisoned version, and the community spots it and yanks it within hours to a day or two. The danger window is short, but if your automation auto-merges the instant a release appears, you can walk straight into it before anyone raises the alarm.
That is what minimumReleaseAge in the Renovate config above is for, and what Dependabot's cooldown does. "3 days" tells the tool to ignore a release until it has been public for three days, so a compromised version most likely gets pulled before your automerge ever touches it. The tension is real: a cooldown slows down poison, but you do not want to slow down the antidote. That is why the vulnerabilityAlerts block gives security fixes their own lane that skips the wait. Poison waits, cures do not. While you are in these files, point the same tools at your GitHub Actions and have them rewrite a moving reference like actions/checkout@v4 to a full commit SHA (a commit's unique fingerprint) and keep that SHA bumped. It closes the hole where an attacker who can move the v4 tag silently changes what runs inside your pipeline.
Run gh pr list --state merged --label dependencies once a week and look at what merged on its own. If the only things left sitting open are majors and flagged security fixes, your cadence is healthy: the boring updates are merging themselves, your known CVEs are closing on a short clock, and human attention is going only where a person actually changes the outcome.
Try this
Run npm outdated on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: an automerge runs code you may not have read. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.