StackPractices
beginner By Mathias Paulenko

Dependency Upgrade Runbook

A five-phase runbook for upgrading project dependencies safely, covering preparation, the upgrade itself, validation, rollback, and post-deploy monitoring.

Topics: devops

Overview

Outdated dependencies expose projects to security vulnerabilities, compatibility bugs, and capabilities they’re missing. This runbook gives you a repeatable, five-phase process for upgrading them with minimal risk. The same checklist works for a single CVE patch or a quarterly maintenance window.

When to Use

  • A critical security patch lands for a direct or transitive dependency and you need it in production today, not next sprint.
  • A major version upgrade is needed for long-term support or compatibility with the rest of your stack.
  • You run scheduled dependency refreshes each sprint or quarter and want the same procedure every time.
  • A junior engineer is running their first upgrade and needs the checklist, not tribal knowledge.

The Runbook process at a glance

Flowchart of the five dependency upgrade phases: preparation, upgrade, validation, rollback, and merge with monitoring.

The Runbook

# Dependency Upgrade Runbook

## 1. Preparation

- [ ] Identify the dependency and target version
- [ ] Review the changelog / release notes for breaking changes
- [ ] Check open issues in the dependency repository for upgrade-related bugs
- [ ] Create a dedicated branch: `deps/upgrade-<name>-<version>`
- [ ] Ensure CI is green on the current main branch

## 2. Upgrade

- [ ] Update the version in `package.json`, `requirements.txt`, `pom.xml`, etc.
- [ ] Run the dependency installation command
- [ ] Check for peer dependency warnings or conflicts
- [ ] Run automated tests (unit, integration, lint)
- [ ] Run smoke tests against a local or staging environment

## 3. Validation

- [ ] Review test coverage reports for regressions
- [ ] Check application logs for new warnings or errors
- [ ] Verify critical user paths manually if behavior changed
- [ ] Run security scan (`npm audit`, `safety check`, OWASP dependency check)

## 4. Rollback Plan

- [ ] Tag the last known good commit before merge
- [ ] Document any manual data or config changes required
- [ ] Confirm rollback can be executed within 15 minutes

## 5. Merge & Monitor

- [ ] Open a pull request with changelog summary
- [ ] Deploy to staging and let it soak for 24 hours
- [ ] Deploy to production during low-traffic window
- [ ] Monitor error rates and latency for 48 hours post-deploy

How the Five Phases Work

The runbook splits upgrades into phases that each catch a different failure mode. Preparation kills surprises: most upgrade incidents trace back to a changelog nobody read. The upgrade itself stays isolated in a branch so the diff is reviewable. Validation mixes automated checks with a manual pass on whatever behavior actually changed. The rollback plan exists before you need it. “Revert fast” is a decision made while calm, not during an incident. Merge and monitor closes the loop: staging soak plus 48 hours of watching error rates catches the regressions tests miss.

Each phase also has a cost if you skip it. Skip preparation and you discover breaking changes in production. Skip the isolated branch and the upgrade tangos with whatever else was in flight that week. Skip validation and “tests pass” quietly covers the one path nobody exercised. Skip the rollback plan and the revert becomes an archaeology project. Skip monitoring and you learn about the regression from a user report instead of a dashboard.

Choosing the Target Version

“Latest” isn’t always the right target. Pick the version by intent:

  • Security fix. Take the smallest release that patches the CVE, even if it means a backport branch like 2.4.x instead of 3.x. Big jumps under incident pressure multiply risk.
  • Routine maintenance. Latest stable is fine, but prefer a version with a few weeks in the wild over a release published yesterday. Let someone else find the .0 bugs.
  • Major migration. Target the newest LTS line, not the bleeding edge. For frameworks, check the official support window first: an upgrade that lands you on a version going end-of-life in three months buys you a second emergency.

Two checks before committing to a number. First, runtime requirements: many majors quietly raise the minimum Node, Python, or JVM version, and that upgrade turns into two upgrades. Second, the ecosystem lag: type definitions, plugins, and wrappers (@types/*, webpack plugins, ORM integrations) often trail a major release by weeks. If the wrappers you use haven’t caught up, waiting is cheaper than patching around them.

Upgrade Commands by Ecosystem

The five phases are the same everywhere; only the commands change. A quick map for the common toolchains:

EcosystemSee what’s outdatedUpgrade one packageLock fileVulnerability audit
npmnpm outdatednpm install pkg@x.y.zpackage-lock.jsonnpm audit
yarn / pnpmyarn outdated / pnpm outdatedyarn up pkg / pnpm update pkgyarn.lock / pnpm-lock.yamlyarn npm audit / pnpm audit
pip + pip-toolspip list --outdatedpip-compile --upgrade-package pkgrequirements.txtpip-audit
poetrypoetry show --outdatedpoetry update pkgpoetry.lockpip-audit on exported reqs
Mavenmvn versions:display-dependency-updatesmvn versions:use-latest-versions(versions in pom.xml)dependency-check (OWASP)
Go modulesgo list -u -m allgo get -u pkg@vX.Y.Zgo.sumgovulncheck ./...
Cargocargo outdatedcargo update -p pkgCargo.lockcargo audit
Bundlerbundle outdatedbundle update gemGemfile.lockbundler-audit

Whatever the ecosystem, the workflow in phase 2 is identical: bump the manifest, regenerate the lock file, run the tests, then read the lock diff before committing. The lock diff is where surprise transitive upgrades show up.

Variants

ContextApproachNotes
Security patchFast-track branchSkip soak time only for CVEs with active exploits
Major versionFeature-flag rolloutIsolate new behavior behind flags during transition
MonorepoBatch upgradesUpgrade shared libraries first, then consumers
Transitive-only CVEForce-pin via overrides / resolutionsDocument why the pin exists and when to remove it

Best Practices

  1. Upgrade one major dependency at a time. Two majors in one PR makes failures impossible to attribute.
  2. Pin exact versions and commit lock files. package-lock.json and poetry.lock make builds reproducible and diffs reviewable.
  3. Use npm ci in CI, not npm install. It installs exactly from the lock file and fails when the file drifts from package.json:
# GitHub Actions
- name: Install dependencies
  run: npm ci
  1. Automate the boring 90%. Dependabot or Renovate can auto-merge patch and minor bumps; keep humans for majors:
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      patch-and-minor:
        update-types: ["patch", "minor"]
  1. Keep a deprecation calendar. Track end-of-life dates for frameworks and runtimes so upgrades are planned, not forced by a CVE.

Common Mistakes

  1. Upgrading several majors at once. When tests fail, you can’t tell which bump broke things.
  2. Ignoring peer dependency warnings. They become runtime errors in production.
  3. Skipping the rollback plan. Improvising a revert under pressure extends downtime.
  4. Not reviewing transitive changes. git diff package-lock.json often shows surprise updates that deserve scrutiny.
  5. Upgrading devDependencies without a full build. Webpack, babel, or eslint can break the production bundle even when tests pass:
npm run build && npm run test
  1. Dismissing deprecation warnings. Warnings in one minor version become errors in the next major. Capture and track them:
npm test 2>&1 | grep -i "deprecat" > deprecation-warnings.txt

A Worked Example: Upgrading React 18 to 19

A concrete pass through the runbook makes the phases tangible. Say the upgrade is React 18 to 19 on an app with a week of runway.

Preparation. The React 19 changelog calls out removed APIs (defaultProps on function components, string refs) and changed behavior around useEffect cleanup timing and ref as a prop. The project’s issue tracker flags a known problem with a popular form library on 19.0.0, fixed in 19.0.2, so the target is 19.0.2, not 19.0.0. The app’s Node version already meets the requirement. Branch: deps/upgrade-react-19.

Upgrade. Bump react and react-dom together (they ship in lockstep), plus @types/react and @types/react-dom. Run the official codemod for ref as a prop. npm install reports a peer warning from an old version of a charting library that caps React at 18, so that library gets upgraded in the same branch because a peer conflict is a failure in phase 2, not a separate project.

Validation. Unit and integration suites pass. The manual pass exercises the three paths the changelog marked as risky: a form with uncontrolled inputs, a modal using createPortal, and a component that relied on defaultProps. The codemod rewrote the last one automatically.

Rollback. The pre-upgrade tag exists; the rollback is git checkout <tag> -- package.json package-lock.json && npm ci. Tested once on a throwaway branch: four minutes.

Merge & monitor. PR links the changelog summary and lists the codemod diff separately so reviewers can skim generated changes. Staging soaks a day, production deploys Tuesday morning, error rate and INP stay flat through the 48-hour window.

Handling Breaking Changes

Breaking changes are where upgrades go to die. The defense is boring but effective: read the migration guide before touching the manifest, not after the build breaks. Most mature projects ship one (MIGRATION.md, upgrade docs, or release notes with a “Breaking” section). If the project only publishes a raw changelog, look for renamed APIs, dropped options, changed defaults, and removed deprecations you were relying on.

For majors, the mechanical work often has tooling:

  • Codemods: many ecosystems ship AST transforms: npx @org/codemod, Angular’s ng update, React’s official codemods, or OpenRewrite recipes for Java. Run them on a clean branch and review the diff like any other code change.
  • Adapter layers: when you call the old API in forty places, wrap it. Write a thin module matching the old surface that delegates to the new API, migrate call sites behind it, then drop the adapter. This converts one risky flag-day into many small diffs.
  • Dual-running: for infrastructure-level deps (HTTP clients, serializers, ORM), run old and new side by side behind a flag when the library allows it, and cut over per environment.

A warning about test coverage: if your suite is thin, an upgrade is the wrong time to discover it. Before the major bump, add contract tests around the code paths that touch the dependency: serialization boundaries, retry behavior, error shapes. Those tests are the difference between “tests pass” and “it works”.

Coordinating Upgrades Across Repos

Inside one repo the runbook is enough. Across repos, like a shared library plus its consumers, the ordering matters more than the mechanics:

  1. Upgrade the producer first. Publish the new shared-library version to a registry (or a prerelease tag) before touching consumers.
  2. Declare a compatibility window. If the library can support both old and new consumers for a release or two, say so explicitly. It decouples team schedules and removes the pressure for a coordinated flag-day.
  3. Migrate a canary consumer. Pick the lowest-risk service, run the full five-phase runbook on it, and let it soak in production. Its failure modes teach you what the other twenty consumers will hit.
  4. Batch the followers. Once the canary is clean, upgrade the remaining consumers in one maintenance window per team, not one per service.

Contract tests between producer and consumers catch the subtle breaks (a changed error shape, a reordered enum) that unit tests in either repo can’t see.

Advanced: Automating Upgrades

Renovate configuration with safe auto-merge

{
  "extends": ["config:base"],
  "schedule": ["before 6am on Monday"],
  "packageRules": [
    {
      "updateTypes": ["patch", "minor"],
      "automerge": true,
      "groupName": "patch and minor updates"
    },
    {
      "updateTypes": ["major"],
      "automerge": false,
      "labels": ["major-upgrade", "needs-review"],
      "dependencyDashboardApproval": true
    }
  ],
  "vulnerabilityAlerts": {
    "enabled": true,
    "labels": ["security"],
    "schedule": ["at any time"]
  }
}

Rollback script for npm upgrades

#!/bin/bash
set -euo pipefail

BRANCH=$(git rev-parse --abbrev-ref HEAD)
TAG="pre-upgrade-$(date +%Y%m%d-%H%M%S)"

git tag "$TAG"
echo "Created safety tag: $TAG"

# If the upgrade fails, restore manifests from the tag:
# git checkout "$TAG" -- package.json package-lock.json && npm ci

echo "To rollback: git checkout $TAG -- package.json package-lock.json && npm ci"

Pinned Python upgrades with pip-tools

#!/bin/bash
set -euo pipefail

# requirements.in holds loose constraints; requirements.txt is the locked output
echo "package-name==2.0.0" >> requirements.in

pip-compile --upgrade-package package-name --output-file requirements.txt requirements.in

pip install -r requirements.txt --dry-run   # catch conflicting transitive deps
pytest tests/ -x

git add requirements.in requirements.txt
git commit -m "deps: upgrade package-name to 2.0.0"

Audit report and SBOM for compliance

#!/bin/bash
set -euo pipefail

npx @cyclonedx/cyclonedx-npm --output-file sbom.json
npm audit --json > audit-report.json

node -e "
const audit = require('./audit-report.json');
const vulns = audit.vulnerabilities || {};
const high = Object.entries(vulns).filter(([,v]) => v.severity === 'high' || v.severity === 'critical');
if (high.length > 0) {
  high.forEach(([name, info]) => console.log(name + ': ' + info.severity));
  process.exit(1);
}
"

When NOT to Upgrade

A runbook is also permission to wait. Legitimate reasons to defer:

  • A release freeze is days away. Upgrading right before a code freeze puts the riskiest change into the least-tested window.
  • The dependency is scheduled for removal. If you’re migrating off the library next quarter, patching it today is work you’ll throw away; document the exposure and monitor instead.
  • The upgrade forces a bigger one. A package whose new version demands a newer runtime or framework converts a small task into a project. Schedule the project; don’t smuggle it into maintenance.
  • Nothing is broken and nothing is exposed. A dependency pinned to an old-but-patched version, with no CVEs and no capabilities the project misses, can wait for the next cycle. Deferring on purpose is a decision; forgetting isn’t.

Whatever the reason, write it down next to the dependency. An undocumented “we’ll do it later” is how dependencies end up three majors behind.

Metrics Worth Tracking

You can’t improve a process you don’t measure. Four numbers tell you whether upgrades are working:

  • Upgrade cadence: time between dependency refreshes. If patches sit for months, “routine” upgrades turn into risky jumps.
  • Mean time to patch (MTTP): how long a published CVE takes to reach production. For critical CVEs with active exploits, measure it in days, not weeks.
  • Freshness: percentage of direct dependencies within one major of latest. Watch the trend, not the absolute number.
  • Failed-upgrade rate: how often a bump gets rolled back. A rising rate usually means phases 1 and 3 are being skipped, not that upgrades are risky by nature.

Track them in whatever you already use for engineering metrics; a spreadsheet works fine. The point is spotting the drift before a CVE forces the conversation.

Further Reading

Frequently Asked Questions

How often should I upgrade dependencies?

Patch versions weekly or automatically via Dependabot/Renovate. Minor versions monthly. Major versions quarterly, or sooner when they gate security fixes or LTS support. The worst cadence is "when something breaks"; it turns every upgrade into an emergency.

What if a transitive dependency has a CVE?

You don't have to wait for the direct dependency to release a fix. Use npm audit fix, overrides in package.json, pip-audit for Python, or Maven dependency management to force a patched transitive version.

Should I commit lock files?

Always. Lock files give you reproducible builds across machines and CI, and they turn dependency upgrades into reviewable diffs. npm ci, pip install -r requirements.txt, and similar commands all assume a committed lock file.

How do I handle an unmaintained dependency?

Evaluate maintained alternatives first: a migration is usually cheaper than carrying a dead dependency. If nothing fits, fork it (license permitting) or vendor it into your repo, then add it to the deprecation calendar with a replacement deadline. Scan the last published version so you at least know your exposure.

What's the difference between ~ and ^ in semver?

^1.2.3 allows anything that doesn't change the leftmost non-zero digit (so 1.x.x), while ~1.2.3 allows patch-level only (1.2.x). Caret is fine for most deps; use tilde or exact pins for packages whose stability you depend on.

When does a monorepo need special tooling?

Nx, Turborepo, or Lerna earn their complexity when packages share dependencies and upgrades must land together. They add workspace hoisting, caching, and batch upgrade commands. For a handful of packages, a single package.json and normal tooling is simpler and just as safe.

How do I test an upgrade when test coverage is thin?

Lean on the environment, not the suite. Deploy to staging first and run a scripted smoke pass over the critical user paths: login, the top three API calls, the one batch job that matters. If you can, do a canary deploy to a small slice of production traffic and watch error rates for a few hours before full rollout. And capture the baseline before the upgrade: you can't spot a 2% latency regression without knowing last week's number.

Should I upgrade to a new major version right away?

Usually not. Unless it patches a CVE you're exposed to, give a fresh major a few weeks: the .0 release collects the real-world bug reports, and the .1 or .2 that follows is where most teams should land. Use the waiting time to watch the issue tracker for upgrade-specific reports and to let @types, plugins, and wrappers catch up.