Skip to content

Skills · dev-team

dependency-upgrade-review

Give a merge verdict on a dependency-upgrade pull request (Dependabot, Renovate or a hand-made bump) across npm, pip, Go, Cargo and Maven lockfiles and GitHub Actions pins. Reads the release notes for every skipped version, lists what the lockfile changes beyond the PR title (transitive bumps, new and removed packages, install scripts, registry changes), checks each SHA pin against its release tag and against the PR's other pins, checks raised runtime floors against the images and runners that install it, says whether CI ran the changed code, traces what each caller of a changed API now does and writes the rollback. The reply leads with MERGE, MERGE AFTER CHECKS or DO NOT MERGE and the reasons that decide it. Use when the user says "review this Dependabot PR", "is this Renovate bump safe to merge", "can I merge this dependency upgrade", "check this lockfile diff", or "is this action SHA pin right".

Pack dev-teamTier standardJob Build and review softwarev0.2.1

npx skilldrop-cli install dependency-upgrade-review
Other ways to install

With the sibling skills it hands off to:

npx skilldrop-cli install dependency-upgrade-review --with-related

The whole pack it belongs to:

npx skilldrop-cli install --pack dev-team

By hand, from a clone of the repo:

cp -R packs/dev-team/skills/dependency-upgrade-review ~/.claude/skills/

In Claude Code, with the dev-team plugin installed, invoke it as:

/dev-team:dependency-upgrade-review

Try it

A realistic prompt from the skill's acceptance evals:

Dependabot opened PR #212 on harbor-notes: "Bump quillmark from 3.4.1 to 4.1.0". CI is green. The PR diff is in pr.diff, quillmark's changelog is quillmark-CHANGELOG.md, and I've attached src/render/, test/ and .github/workflows/ci.yml from the repo. Can I merge it?

What a good result looks like

Example output

Worked example: Ledgerline, a Poetry patch bump that earns MERGE · the skill's own worked example, source

Worked example

Most worked examples end in a block. This one shows what it takes to close every check, so MERGE is earned rather than assumed.

Input

The user says: "Can I merge PR #57 on ledgerline, 'chore(deps): bump tidyparse to 2.2.2'? CI is green." and supplies:

  • the diff, inputs/ledgerline.diff (pyproject.toml and poetry.lock)
  • tidyparse's changelog for 2.2.1 and 2.2.2
  • the repo: app/ingest.py, tests/test_ingest.py, .github/workflows/ci.yml

Changelog excerpt as supplied:

## 2.2.2
- Security: a single line longer than 10 MB is now rejected with RowTooLongError
  (a subclass of ParseError) instead of being read into memory.

## 2.2.1
- Fixed: a quoted field containing a newline was split into two rows.

Repo excerpts as supplied:

# app/ingest.py
12  def import_statement(path):
13      try:
14          rows = tidyparse.parse_rows(path, dialect="excel")
15      except tidyparse.ParseError as exc:
16          raise StatementRejected(str(exc)) from exc
# tests/test_ingest.py
 3  from app.ingest import import_statement
 9  def test_quoted_newline_stays_one_row(tmp_path): ...
17  def test_malformed_file_is_rejected(tmp_path): ...

.github/workflows/ci.yml runs pytest -q on pull_request.

python3 scripts/upgrade_diff.py examples/inputs/ledgerline.diff --title "chore(deps): bump tidyparse to 2.2.2"
**Floor verdict: none from the diff** (mechanical; the changelog, usage and CI checks are still to do, and only they can make it MERGE)

## Changed (3)

| Package | Ecosystem | Old → New | Class | File | Notes |
|---|---|---|---|---|---|
| `charset-sniff` | pip | 1.4.0 → 1.4.1 | patch | `poetry.lock` | lockfile only (transitive); not named in the title |
| `tidyparse` | pip | 2.2.0 → 2.2.2 | patch | `poetry.lock` | direct |
| `tidyparse` | pip | ^2.2.0 → ^2.2.2 | patch | `pyproject.toml` |  |

## Beyond the title (1)

- `charset-sniff` changed: 1.4.0 → 1.4.1 (poetry.lock)

No install scripts, sources, licences or pins appear. The floor says only that the diff itself raises nothing; the notes, the search and CI decide the rest.

Review (what the agent hands back)

MERGE. Two patch releases; the only behaviour change, 2.2.2's new 10 MB line limit, raises an error the import code already handles, and the PR's tests run that code.

  • Both releases read. 2.2.1 fixes quoted newlines splitting rows, covered by test_quoted_newline_stays_one_row (tests/test_ingest.py:9). 2.2.2 is a security fix: lines over 10 MB raise RowTooLongError, a subclass of ParseError.
  • git grep -n "tidyparse" -- '*.py' finds one call, app/ingest.py:14, inside except tidyparse.ParseError (app/ingest.py:15), so the new error becomes StatementRejected like any malformed file.
  • ci.yml runs pytest -q on the PR and tests/test_ingest.py:3 imports import_statement, so the changed code ran.

Also in the lockfile: charset-sniff 1.4.0 → 1.4.1 (transitive patch). No new packages, install scripts, sources, runtime changes or pins.

Rollback: git revert the merge commit, or poetry add tidyparse@2.2.0 and commit pyproject.toml and poetry.lock; that reopens the 2.2.2 memory issue, so treat it as a stopgap.

About 150 words. No package table: two packages fit in a line. No "To confirm": nothing is open.

Why this is MERGE and not MERGE AFTER CHECKS

Every check in the verdict rule is closed with evidence in the reply: both releases read, the one changed behaviour searched and found handled, the calling code run by a PR test, no pins, no install-time code, no new source. Remove any one of those (no repo to search, a release with no notes, a test that does not import app/ingest.py) and the verdict drops to MERGE AFTER CHECKS with that item as the check.

The same shape when it blocks

Suppose instead the PR moved tidyparse to 3.0.0 and the notes (invented for this illustration) said 3.0.0 requires Python 3.11 and parse_rows() now returns a RowSet instead of a list. The reply keeps the order and stays short:

DO NOT MERGE. 3.0.0 needs Python 3.11 and the production image runs 3.10, and the import code indexes a result that is no longer a list.

🟥 1. Dockerfile:1 builds on python:3.10-slim; CI runs 3.12 (ci.yml:12), so the PR passed and the image build fails at install. Move the image to 3.11 in this PR. 🟥 2. app/ingest.py:17 does rows[0] on what is now a RowSet; no test reads the first row, so CI missed it. Use rows.first().

Also in the lockfile: charset-sniff 1.4.0 → 1.4.1. Rollback: git revert the merge commit and rebuild the image.

I can make both changes on this branch.

The lead names both blockers; each blocker says what breaks, where, why CI missed it and the fix; nothing is in the reply that does not change the decision.

How it's checked

Measured against plain Claude Code (2026-10-23): it met 65% of its checks, against 41% without it, and a blind judge preferred it. How skills are checked

59 assertions across 7 acceptance evals, and 9 trigger queries. Every skill's checks

What the output must do
  • Gives exactly one verdict at the top, and it is DO NOT MERGE
  • Reports that renderInline() was removed in 4.0.0 and that src/render/comment.js still imports and calls it, citing the file and line
  • Flags that 4.0.0 changed the sanitize default to false and that src/render/note.js renders note bodies with quillmark.render(markdown) and no sanitize option, so raw HTML from users passes through
  • Lists the lockfile changes the title does not mention: entity-codec 2.1.0 to 3.0.2 (a major, with its licence changing from MIT to MPL-2.0), html-escape-lite removed, and quill-native-highlight and prebuild-install added, with quill-native-highlight's new install script
  • Says the green CI does not cover the change: the only test renders Markdown with no raw HTML, and no test imports src/render/comment.js
  • Accounts for the skipped releases, not only 4.1.0: names the 3.5.0 link-title security fix and the setOptions() deprecation that src/render/note.js still uses
  • Gives a rollback that pins quillmark back and regenerates package-lock.json (or reverts the PR), and names taking 3.5.x as the way to get the security fix without the breaking changes
  • Opens with the verdict and the decisive reasons (the removed renderInline() call and the sanitize default) in plain words before any list, keeps the lockfile changes to a line or a short list rather than a table, and does not describe the session's network or tool access
  • Does not invent versions, advisory IDs, APIs, files or tests that are not in the supplied input
  • Gives MERGE AFTER CHECKS, not MERGE
  • Treats the northwind-ci/checkout-lite SHA as unverified and gives the exact git ls-remote command for refs/tags/v3.2.0 and refs/tags/v3.2.0^{}, saying which line must equal 9b8e7a6d5c4b3a291807f6e5d4c3b2a1908f7e6d
  • Says octo-tools/pypi-publish moving from @v2 to @v3 is a tag-only pin that can be moved after review, recommends pinning it to a commit SHA, and notes the step receives secrets.PYPI_TOKEN
  • Says the passing checks never ran pypi-publish v3, because release.yml runs only on a v* tag push, and names a way to exercise it before the next real release
  • Reports that release.yml still passes the deprecated password input (release.yml line 22) and that verify-metadata now defaults to true, so the next release can fail on an invalid long description
  • Classes the jumps: checkout-lite v3.1.0 to v3.2.0 as minor, pypi-publish v2 to v3 as major
  • Gives a rollback (revert the PR or restore the old uses: lines) and notes a revert cannot undo a release already published to PyPI with v3
  • Does not report either SHA or the tag move as verified, and does not explain the gap by describing the session's network or tool access; it gives the command and the SHA it must equal instead
  • Does not invent action versions, SHAs, inputs or workflow steps that are not in the supplied files
  • Gives MERGE AFTER CHECKS, not MERGE
  • States that v0.8.3 to v0.9.0 is a 0.x minor bump, which semver treats as potentially breaking
  • Names the Wait behaviour change and the NewLimiter deprecation, and gives a concrete search command for Wait( and NewLimiter( since no code was supplied, without claiming there are or are not hits
  • Points out that only the v0.9.0 notes were supplied, so any releases between v0.8.3 and v0.9.0 are still unread
  • Flags github.com/lanternhq/clock v1.2.0 as a new indirect module the PR title does not mention
  • Says the passing CI only covers this if a test exercises the burst-0 Wait path, and lists that as a check rather than assuming it
  • Gives a rollback command such as go get github.com/lanternhq/ratelimit@v0.8.3 followed by go mod tidy, or reverting the PR
  • Stays short, as the user asked for a quick verdict: the verdict and its reasons come first, and there is no package table and no empty section
  • Does not invent file names, call sites, tests or release versions that are not in the prompt
  • Gives the verdict DO NOT MERGE for #287 (not MERGE, and not MERGE AFTER CHECKS without code changes), and ties it to at least one breaking change that hits code in this repo.
  • Points out that read_legacy_archive in src/ingest/reader.py imports quillstream.legacy.parse_frame inside the function, and that the 3.0.0 entry in the changelog (a release this PR skips) removes that module. Names quillstream.frames.decode(buf, strict=False) as the replacement and says the header, payload = parse_frame(...) unpacking has to change too, because decode returns a Frame object.
  • Points out that fetch_with_retry in src/ingest/client.py retries only on if result is None. Under 3.0.0, Client.fetch raises quillstream.errors.QuillTimeout instead of returning None, so timeouts will escape as exceptions rather than being retried. Suggests catching QuillTimeout, or migrating first with 2.5.0's Client(raise_on_timeout=True).
  • Flags that the Dockerfile builds FROM python:3.9-slim while 3.0.0 requires Python >=3.10 (and grainwire wheels only cover CPython 3.10–3.13), so the production image build or install will break. Notes that CI and pip-compile both ran on Python 3.11, so a green CI could not catch this.
  • Lists the requirements.txt changes beyond the title, as shown in pr.diff: new transitive package grainwire 1.0.0, removed compat-six 1.16.2, and typing-shimx bumped 4.5.0 -> 4.9.1. Ties grainwire to the 3.1.0 changelog entry, including the sdist/Rust-toolchain caveat for platforms without wheels.
  • Explains why the green check is weak evidence: tests/test_reader.py only tests read_stream, with a mocked Client whose fetch returns a payload. Nothing exercises read_legacy_archive (the lazy import means the missing module never fails at import or collection time) or the timeout/None path.
  • Gives a concrete rollback: revert the PR, or restore quillstream==2.4.1 in requirements.in and re-run pip-compile --generate-hashes, then rebuild and redeploy the image. Optionally offers a staged path, e.g. moving to 2.5.0 first to surface the deprecation warnings.
  • Does not invent versions, APIs or changelog entries that are not in the files (e.g. no made-up 3.0.1 fix, no Python-3.9 compatibility shim). Does not claim to have installed the package, run the tests, or checked the sha256 hashes against PyPI. If it recommends checking the hashes, it says that needs network access and gives the command rather than asserting they match.
  • Gives a separate verdict for each PR. #413 is DO NOT MERGE. #412 is not a plain MERGE: it is MERGE AFTER CHECKS or DO NOT MERGE until the pins are reconciled.
  • Catches that pr-412.diff pins setup-toolchain to two different commits that are both labelled # v3.3.0: 8141d107d0837efc25f86e096a7eefd58e979515 in ci.yml (from Dependabot) and 7dd0aa52537f8f4f4002b505507186cdaec669c4 in release.yml (from mkowalczyk's hand-made commit). Says at most one of them can be the tag's commit, and that the release.yml one is the suspect because it was edited by hand and is the one that runs with production secrets.
  • Says plainly that it cannot confirm offline which SHA (if either) the v3.3.0 tag points to. Gives a concrete command, e.g. git ls-remote https://github.com/bramblewick/setup-toolchain 'refs/tags/v3.3.0*' (using the peeled ^{} line if the tag is annotated) or gh api repos/bramblewick/setup-toolchain/git/ref/tags/v3.3.0. Never states that either SHA is verified.
  • For #412, notes that v3.3.0 switches the action to the node24 runtime, which needs self-hosted runners on v2.328.0 or newer. release.yml runs on [self-hosted, linux, release] and PR CI runs only on ubuntu-latest, so the first real test would be Friday's tag push. Also mentions the cache default flipping to true, which affects the release job.
  • For #413, finds that scripts/release.mjs calls createNotary({ legacyMode: true, ... }). 1.9.0 removed legacyMode and now throws TypeError: Unknown option "legacyMode", so npm run release in release.yml will fail. Also notes that the switch to the v2 manifest layout breaks the downstream ledger-portal parser mentioned in the code comment.
  • For #413, lists the package-lock changes beyond the title: a new transitive package notary-telemetry 0.2.1 with "hasInstallScript": true, resolved from https://npm.notary-cdn.io rather than registry.npmjs.org; ansi-tint 1.0.4 removed; semver-lite 2.1.0 -> 2.3.0; release-notary's engines raised to node >=20. Treats the install script plus the off-registry tarball as a supply-chain concern in its own right.
  • Explains that the green checks never ran the changed code. ci.yml runs on pull_request with npm ci --ignore-scripts and never runs scripts/release.mjs. release.yml runs plain npm ci, so notary-telemetry's install script would first execute in the production environment, with RELEASE_SIGNING_KEY and NPM_TOKEN in the job and contents: write / id-token: write permissions. Mentions NOTARY_TELEMETRY=0 and/or keeping 1.8.x as mitigations.
  • Gives a rollback for each PR: revert #412 to restore the c8aee2ae521bdb1ef1b97761673ebbe92c953b0f (v3.2.0) pins; revert #413, or pin release-notary back to 1.8.2 and regenerate package-lock.json. Does not invent versions, flags or APIs that are not in the files, and does not claim to have run npm install, the workflows, or the release script.
  • Picks out the v1.8.3 entry in vendor-notes/httpkit-CHANGELOG.md (a version the Dependabot release notes in PR.md leave out): NewClient no longer applies a default 30-second Timeout, and the zero value now means no client-level timeout. It treats this as the key finding even though the bump looks like a patch.
  • Points to internal/payments/client.go NewClient, which calls httpkit.NewClient with only WithBaseURL and WithRetries and whose comment says it relies on the 30s default (INC-2291), and to internal/payments/settle.go, which calls Charge with context.Background() and no deadline. So after the upgrade a hung gateway can block the settlement batch indefinitely. It also notes that internal/notify/client.go sets WithTimeout(5*time.Second) explicitly and is unaffected.
  • Explains that the green CI does not test this change: client_test.go injects fakeDoer, so the real httpkit client is never exercised, and ci.yml runs go test -race -short ./... without -tags integration, so client_integration_test.go (TestChargeHungGatewayTimesOut) did not run on this PR. It runs only nightly or on manual dispatch via integration.yml.
  • Gives a verdict of DO NOT MERGE as-is, or MERGE AFTER CHECKS on the specific condition that payments NewClient gains an explicit httpkit.WithTimeout(30*time.Second) (or a per-call context deadline in SettleBatch) and the integration test passes. It is never plain MERGE.
  • Lists the go.mod/go.sum changes the PR title does not mention: indirect github.com/fernwire/backoff v0.3.0 -> v0.4.0 and a newly added indirect github.com/fernwire/otelshim v0.1.0. It ties them to the v1.8.2 (backoff, full-jitter) and v1.8.4 (WithTracer) changelog entries rather than calling them unexplained.
  • Gives a concrete rollback: revert the merge commit, or go get github.com/fernwire/httpkit@v1.8.1 && go mod tidy, which also returns backoff to v0.3.0 and drops otelshim. It notes that adding the explicit WithTimeout is safe to keep on either version.
  • Does not claim to have run tests or verified the go.sum hashes or upstream tags. It gives the network or execution steps as commands to run (for example go mod verify, go test -tags integration -run TestChargeHungGatewayTimesOut ./internal/payments/, or triggering integration.yml via workflow_dispatch).
  • Invents no httpkit versions, options or behaviours beyond those in the files (for example no made-up v1.8.5, no claimed retry-on-POST change, no options other than WithBaseURL, WithRetries, WithTimeout and WithTracer).
  • Ends with a verdict of MERGE. It does not downgrade to MERGE AFTER CHECKS or DO NOT MERGE over problems the files do not contain (for example an install script, a registry change, a maintainer change, or a breaking change that affects this repo), and it does not insist on splitting the PR without a concrete reason from the files.
  • Checks bundlecrate 6.1.0's new Node >=18.18 minimum against the repo and finds it harmless: .nvmrc is 20.11.1, package.json engines is node >=20, and ci.yml uses node-version-file: .nvmrc.
  • Checks the repo's uses of the changed APIs and finds nothing that needs action. vellum-test 2.8.0 deprecates vt.legacyTimers(), but tests/due-date.test.ts uses only vt.fakeTimers/vt.restoreTimers. lintwright 4.3.0's new no-floating-promise rule is only in lintwright:strict, while .lintwright.json extends lintwright:recommended.
  • Lists the lockfile changes beyond the five headline bumps: @bundlecrate/resolver-core 1.0.0 added, bundlecrate-legacy-resolver 2.3.4 removed, and the transitive picolabel 0.9.1 -> 0.9.2. It ties each to a changelog entry (bundlecrate 6.1.0 resolver extraction, lintwright 4.3.0 picolabel update).
  • States that no lockfile entry in pr.diff has "hasInstallScript" and that every resolved URL is on https://registry.npmjs.org/, which matches .npmrc. So there are no new install-time scripts and no registry switch.
  • Says CI did exercise the changed code, mapping the packages to jobs in .github/workflows/ci.yml: lintwright -> lint, prettyquill -> format, tsforge-types -> typecheck, vellum-test -> test, bundlecrate -> build-and-smoke (which runs test:dist against the built bundle). It says the pass/fail status comes from PR.md and was not observed directly.
  • Gives a rollback: revert the merge commit (restoring package.json and package-lock.json together) and run npm ci. It notes these are all devDependencies, so only the bundlecrate-built dist/ could reach users, and that build is covered by the smoke step.
  • Does not claim to have verified integrity hashes, provenance or upstream tags offline. It invents no versions, rules or APIs not in the files, and frames anything network-dependent (for example npm audit signatures, npm ci locally) as an optional command, not a blocker.
When it should and shouldn't activate

Should activate:

  • Dependabot wants to bump express from 4.19 to 5.0, can I merge the PR?
  • review this Renovate PR, it updates 14 packages in package-lock.json
  • is this actions/checkout SHA pin in the dependabot PR actually the v4.2.2 commit?
  • I bumped pydantic and regenerated poetry.lock, anything in the lockfile diff I should worry about before merging?
  • check this go.mod / go.sum diff from the deps update PR, CI is green

Should leave to another skill:

  • review my PR that adds rate limiting to the checkout API before I merge
  • write the release notes for our 3.2.0 release from the merged PRs
  • plan our migration from React 17 to React 19 across the four frontend apps
  • scan the skills I'm about to install for risky scripts

Hands off to

Related skills

release-notes

Source

SKILL.md · the whole folder on GitHub.