Managing updates safely

Dependabot/Renovate and a patch cadence.

Advanced10 min · lesson 6 of 18

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.

terminal
npm outdated
output
Package Current Wanted Latest Location Depended by
lodash 4.17.19 4.17.21 4.17.21 node_modules/lodash api
minimist 1.2.5 1.2.8 1.2.8 node_modules/minimist api
ws 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.

terminal
osv-scanner --lockfile=package-lock.json
output
╭───────────────────────────────────┬──────┬───────────┬──────────┬─────────┬───────────────────╮
│ 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.

renovate.json
{
"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.

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 5
groups:
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.

How an update PR should move
An update PR just opened. How should it travel?
It fixes a known CVE
Fast-track it
Skip the cooldown, prioritize, merge on a short clock once scans pass
Patch or minor, CI green, past the cooldown
Let it auto-merge
No human needed; the pipeline is the reviewer
Major version bump
Send it to a human
Breaking-change risk; read the changelog and test by hand

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.

terminal
gh pr list --label dependencies --state open
output
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.

terminal
gh pr list --label dependencies --state merged --limit 5
output
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.

An automerge runs code you may not have read
A dependency update PR does not sit there quietly. Its build runs in your pipeline, so the new version's install and build scripts execute in CI, and automerge can land it without a human reading a single line. Keep these PRs on a read-only token with no production secrets (GitHub already gives Dependabot PRs a read-only GITHUB_TOKEN, the automatic credential a workflow runs under, and no access to your secrets; do not undo that by checking out and running the PR's own code under pull_request_target, a trigger that hands running code your repository's secrets). Require your scans to pass before merge, add a cooldown so a poisoned release gets caught first, and consider installing with scripts disabled in the PR build. Automation that keeps you current is good. Automation that merges unreviewed code into a privileged pipeline is a fresh way in.
Quick check
01A teammate calls minimumReleaseAge: "3 days" in your renovate.json pointless friction that holds up good updates for no reason. What does the three day wait actually buy you?
Incorrect — The clock starts when the version lands on the registry, not when your pipeline starts, so a release already three days old merges the moment checks go green. How long your suite runs never enters into the setting.
Correct — The wait is sized to the window in which the community notices a poisoned release and removes it. Your automation sits on the far side of that window, and the vulnerabilityAlerts block still lets real security fixes skip the queue.
Incorrect — That is a stability argument and it sometimes holds, but it is not why the setting is there. The three days is aimed at the release that was never meant to be trusted, not the one with a rough edge.
Incorrect — dependencyDashboard opens one issue listing what is pending and needs no waiting period to stay current. The three days is measured against the moment the version was published.
02In the npm outdated output, ws reads Current 7.4.5, Wanted 7.5.10, Latest 8.18.0, while lodash reads 4.17.21 under both Wanted and Latest. What accounts for the difference between those two rows?
Incorrect — The Depended by column reads api on every row in that output, so both packages reach you the same way. The rows differ because a newer major exists for one of them and not the other.
Incorrect — npm outdated never consults an advisory feed; that is exactly why you run osv-scanner as a second step. Both columns are computed from version numbers, which is why the scan told you something the first command could not.
Correct — One column respects the range you declared and the other ignores it. When nothing newer exists beyond your limit the two print the same number, so lodash showing 4.17.21 twice means you have run out of in-range upgrades.
Incorrect — Neither column looks at anyone else's machine. One comes from the range you declared, the other from the newest version the registry holds, so the ws gap is a major sitting out of bounds.
03You run gh pr list --label dependencies --state merged --limit 5 and get three of three: the two minor bumps and the minimist security fix. #215 is not in that list, and it is still open carrying needs-human-review. How do you read that?
Correct — That split is what the two rules were written to produce. The routine tier and the prioritized fix cost you no attention, and the one change allowed to break your build waits for somebody who will actually study what changed.
Incorrect — A setup that merged the major on its own would be the broken one. Majors can break your code, which is why the rule matching them sets automerge to false and attaches a label instead.
Incorrect — The automerge rule matches patch and minor only; the major matches a separate rule that never merges. #215 would sit right where it is with every check green, and nothing in the output points at a failure.
Incorrect — The security pull request here is #216, which was flagged and merged. #215 is a jump from 20.x to 22.5.0 with an exclamation mark in its title, the marker for a breaking change rather than an unpatched flaw.

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.

Related