renovate-triage
Risk verdict: A Renovate PR's classification into one of three tiers — safe,
needs-review, or blocked — computed by a fixed hard-stop rule list (an explicit
breaking-change callout, a failing CI check, or a major bump with no changelog found
anywhere, each alone forcing blocked) with a bump-size baseline underneath, rather than
a weighted score. Blast radius (how widely the dependency is used in the consuming
codebase) and CI-pending status can each escalate the baseline by one tier; dev-only vs.
production placement is reported as context but never changes the verdict — both are
escalated identically.
Agent brief: A blocked-verdict's handoff content, written into the same PR comment
as the risk verdict rather than a separate artifact. Addressed to an agent continuing the
investigation, not a human skimming for discretion — concrete starting points: which
call sites to inspect, which changelog or migration-guide sections to read.
Datasource: Renovate's own name for a dependency's versioning source and lookup
mechanism — npm, docker, pypi, ansible-galaxy, and others. A built-in manager
(e.g. npm) has a fixed default datasource; a customManagers entry declares its own via
datasourceTemplate. This skill resolves a changed file's datasource by reading the
target repo's own renovate.json, never by guessing from the file's name or extension —
a custom regex manager can point any file at any datasource.
Datasource adapter: A declarative bundle — where to look for a changelog or release
notes, and how to search the codebase for call sites — that evidence-gathering
dispatches to once a file's datasource is resolved. One adapter per datasource; a
resolved datasource with no adapter built is skipped and named, never given generic
best-effort evidence gathering. For docker, one adapter run can consult two
repositories — a packaging repository resolved from registry metadata, and an upstream
repository found by scanning that packaging repository for an embedded GitHub URL — never
guessed at from the image name.
Opportunity: A relevant capability change found while scanning a minor or major
bump's full release range (every version between old and new, not just the latest) —
either a newly-added capability, or an existing capability the dependency now marks
deprecated — cross-referenced against the dependency's actual call sites in the
consuming codebase and reported only when relevant usage is found there. Reported per
dependency, in its own comment section, alongside but never merged into the Risk
verdict — it never escalates, de-escalates, or otherwise changes the verdict, regardless
of tier or placement.
Security advisory: An explicit security disclosure — an explicit CVE identifier, a
GitHub Security Advisory ID, a "Security" heading, or explicit urgency/vulnerability
language — found while scanning any changelog text this skill already gathered, for any
datasource, regardless of bump size. Reported per dependency, in its own comment
section, alongside but never merged into the Risk verdict — it never escalates,
de-escalates, or otherwise changes the verdict. Unlike Opportunity, both its widened
fetch and its reporting are unconditional: they run for every bump size and are never
skipped by --no-opportunities, and a finding is never cross-referenced against this
codebase's call sites — a security disclosure is reported regardless of whether a
matching call site is found.
The skill computes a Risk verdict for every open Renovate PR, or the single PR given as
an argument, reports in-session, and maintains exactly one idempotent comment per PR,
updated in place on later runs rather than duplicated. It never approves, merges, or
labels a PR — every merge decision stays the maintainer's. By default it also scans
every minor or major bump for Opportunities, reported in their own section separate
from the verdict; pass --no-opportunities to skip that pass for a single run.
Independent of bump size and of that flag, it also scans every dependency's gathered
changelog text for a Security advisory, reported in its own section and never affecting
the verdict.
Dependencies
Requires an authenticated gh CLI — every step shells out to it (gh pr list, gh pr view, gh pr checks, gh api, gh pr comment). Also requires npm and node — npm
to resolve an npm dependency's repository URL (npm view <dep> repository.url, used only
by the npm adapter), and node to run this skill's bundled resolution, extraction,
detection, verdict, and validation scripts (${CLAUDE_SKILL_DIR}/scripts/). The docker,
pypi, and ansible-galaxy adapters query their own registries over HTTP via curl —
ambient on any machine capable of running Claude Code, like git, so it isn't listed as
a precondition the way npm and node are.
When to use
- The user types
/renovate-triage with no argument — every open Renovate PR in the
currently checked-out repo gets a verdict in one pass.
- The user types
/renovate-triage <PR number or URL> — re-check or spot-check exactly
one PR without waiting for a full scan.
- The user asks to triage, review, or assess the risk of Renovate dependency-bump PRs.
When not to use
- A PR whose resolved datasource (step 6) has no adapter built — anything beyond
npm/docker/pypi/ansible-galaxy. This skill's evidence-gathering is designed around
those four specifically; running it against an unhandled datasource would produce a
verdict built on evidence it never actually gathered. A full-repo scan reports these
PRs as skipped rather than silently omitting them — see step 9.
- A PR whose changed files span more than one resolved datasource, or include a file
whose datasource can't be resolved locally (
unknown — including one reachable only
through an extends preset this skill never fetches). Reported as skipped, not
triaged on a partial assumption about what the PR touches — see step 8.
- A repo whose Renovate config isn't found or isn't plain JSON (
renovate.json5 and
JS-based configs aren't parsed) — reported as "detection unavailable," not silently
skipped. See step 7.
- Any repo other than the one currently checked out. There is no
--repo flag and no
cross-repo scanning.
- Wanting the skill to approve, merge, or label a PR based on its verdict — it never
does any of those, regardless of tier.
- Wanting migration-guide research on every PR. A migration or upgrade guide is
consulted only as a targeted follow-up when a
blocked verdict's Agent brief needs
more detail than the changelog alone gives — never a broad web search, and never for a
safe or needs-review verdict.
Resolving what to check
Before either case below, strip --no-opportunities out of the raw arguments if
present — a boolean switch, never combined with a value, that can appear with or
without a PR number or URL. When present, this entire run skips step 16's Opportunity
scan for every PR it checks; nothing else in this section changes — the Security
advisory scan (step 15) is never gated by this flag.
- An explicit target was given (PR number or URL): resolve it directly with
gh pr view <target> --json number,title,url,body,headRefName,author,files. This is always
a single-PR check, regardless of who authored it or what datasource it touches — an
explicit target names a PR that must already exist, and the datasource-resolution
check below still applies to it.
- No target was given (only
--no-opportunities, or no arguments at all): list
every open PR in the current repo — gh pr list --state open --json number,title,url,body,headRefName,author,files — and keep only the Renovate-authored
ones: author.login equal to renovate[bot] or app/renovate.
- Zero PRs survive step 2: report "no open Renovate PRs found" and stop — a clear
confirmation the scan actually ran, not silence or an error.
Orchestrating the scan
- No target was given (step 2 produced one or more PRs): dispatch one sub-agent per
PR via the Agent tool, run in parallel, each executing the complete per-PR flow below
(datasource resolution through comment posting) independently for its own PR. PRs are
already self-contained triage units — own rollup, own comment, own idempotency marker
— so no cross-PR coordination is needed; each sub-agent reports its result back for
step 23's summary. Adapters are never split out to their own sub-agent at
dependency/datasource granularity — splitting evidence-gathering across a further
sub-agent boundary would only add coordination overhead around an otherwise
PR-scoped, deterministic flow.
- An explicit target was given (step 1): run the flow inline in the current
session, no sub-agent spawned — a quick spot-check shouldn't pay the parallel-
orchestration overhead a full scan needs.
Resolving each file's datasource
Runs once per PR, before any evidence gathering, using the PR's files list from steps
1–2:
- Read the target repo's Renovate config — its root
renovate.json, or
.github/renovate.json if the former doesn't exist — and run node ${CLAUDE_SKILL_DIR}/scripts/resolve-datasource-cli.js --file <path> [--file <path> ...] for every file the PR changed. Execute this script directly — it
deterministically matches each file against the config's customManagers[] entries
(managerFilePatterns regexes paired with a datasourceTemplate) and a small table
of built-in managers' default file patterns (npm's four filenames are one entry in
this table now, not the sole recognized case), never re-derived by hand. extends
preset chains are never fetched or resolved — only the config file's own local
content is read. It prints { status, datasources } JSON: status is resolved or
detection-unavailable; datasources maps each file to its resolved datasource
string, or "unknown" when the file matches neither a built-in pattern nor a
customManagers[] entry — the same result a datasource reachable only through an
unresolved extends preset gets, since that case is locally indistinguishable from
"not managed at all."
status: "detection-unavailable" (no renovate.json found at either location, or it
failed to parse as JSON): report the PR as "detection unavailable" in place of a
verdict (explicit single-PR target) or under a dedicated "detection unavailable"
heading (full scan), and stop for that PR — a config problem surfaces instead of the
PR silently disappearing from the scan.
- Any file resolving to
"unknown", or a PR whose files resolve to more than one
distinct datasource, marks the PR out of scope: Renovate doesn't normally group
across datasources, so a mix (or an unresolved file) is evidence of an unusual PR this
skill isn't designed to read correctly, not a partial PR to triage anyway. Report it
under a "skipped — mixed or unresolved datasource" heading, naming each file's
resolved datasource.
- A PR whose single resolved datasource has no adapter built — anything beyond
npm/docker/pypi/ansible-galaxy — is reported under a "skipped — no adapter for
<datasource>" heading, never given generic best-effort evidence gathering. A PR
resolved to npm, docker, pypi, or ansible-galaxy proceeds to evidence-gathering below,
using that datasource's adapter.
Gathering evidence, per dependency
Steps 10–14 run once per dependency changed in the PR — for a PR Renovate grouped into
several dependencies, once per dependency in the group (see step 18 for how the
per-dependency results roll up). Steps 11 and 14 are the two facets a datasource's
adapter fills in (its changelog source, and its call-site search); steps 10, 12, and 13
are identical regardless of datasource.
Read the old and new version for this dependency from the PR's title or body (both
fixed Renovate output formats naming the dependency and its version range,
regardless of datasource), then classify the bump size immediately: node -e "console.log(require('${CLAUDE_SKILL_DIR}/scripts/classify-bump-size').classifyBumpSize(process.argv[1], process.argv[2]))" <old> <new>. Execute this directly — it calls the exact same
classifyBumpSize function step 17's compute-verdict-cli.js runs internally for
the verdict, just earlier, so step 11 below knows whether to widen its changelog
range; the bump size must never be hand-classified from the version strings.
Look for a changelog or release notes, per the PR's resolved datasource:
npm: in this fixed order, stopping at the first hit — (a) the dependency's
GitHub Releases, via gh api repos/<dep-owner>/<dep-repo>/releases once the
dependency's repository URL is resolved from its npm registry metadata (npm view <dep> repository.url); (b) the dependency's own CHANGELOG.md at its new release
tag, read from the same repository.
docker: a packaging repository from registry-published metadata only, never
guessed from the image name (pihole/pihole's real source is
pi-hole/docker-pi-hole, not a same-named repo), tried in this order, stopping at
the first hit:
- The image manifest's own OCI config labels — fetched from the registry API
directly, regardless of which registry hosts the image: an anonymous bearer
token (
curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:<namespace>/<image>:pull"),
the manifest list for the target tag, the platform-specific manifest it points
to, and finally that manifest's config blob, whose JSON body carries a
config.Labels object. Write that Labels object to a file and run node ${CLAUDE_SKILL_DIR}/scripts/extract-oci-source-label-cli.js --labels-file <path>. Execute this script directly — it deterministically checks
org.opencontainers.image.source, then .url, and parses an owner/repo pair
out of a github.com URL, never guessed by hand from either label's raw text.
A found result names the packaging repository. A none result (neither
label present, or the present label points at a non-github.com host) falls
through to the next tier.
- The existing registry field, kept as a cheap secondary check since it's
occasionally still populated even when OCI labels are absent — a GHCR image's
linked repository (
gh api orgs/<org>/packages/container/<image>, its
repository field), or a Docker Hub image's source_url field (curl -s https://hub.docker.com/v2/repositories/<namespace>/<image>/) — the latter a
relic of Docker Hub's old "automated builds" linking feature, empty for
ordinary Docker Hub images in practice, not just edge cases.
Once a packaging repository is found via either tier above, run the same
GitHub-Releases-then-CHANGELOG.md order as npm's (a)/(b) against it.
Always also look for a second, upstream repository, supplementing rather than
replacing the packaging-repository lookup above: fetch the packaging repository's
Dockerfile and version-pin files at the target tag — the same files this PR's
changed-file list already resolved to the docker datasource in step 6 — via gh api repos/<owner>/<repo>/contents/<path>?ref=<tag>, then run node ${CLAUDE_SKILL_DIR}/scripts/extract-upstream-repo-cli.js --packaging-repo <owner>/<repo> --file <path> [--file <path> ...] against their fetched content.
Execute this script directly — it deterministically scans for an embedded GitHub
release/tag/tarball URL naming a different repository than the packaging one,
never guessed by hand from the image or file name. A found result names
exactly one upstream repository — run the identical GitHub-Releases-then-
CHANGELOG.md lookup there too. A none or ambiguous result (zero candidates,
or more than one with no way to tell which is authoritative) means the adapter
never guesses — it proceeds packaging-repository-only, using only what the
packaging-tier lookup above already found.
pypi: the package's declared source from PyPI's JSON API (curl -s https://pypi.org/pypi/<package>/json) — a Source, Repository, or Changelog
entry (case-insensitive key match) in info.project_urls, or info.home_page
only when it itself points at a GitHub or GitLab repository. Never a guess from
the package name. Once found, the same GitHub-Releases-then-CHANGELOG.md order.
ansible-galaxy: the collection's declared repository field from the Galaxy
API v3 collection metadata (curl -s https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/<namespace>/<name>/).
Never a guess from the namespace or collection name. Once found, the same
GitHub-Releases-then-CHANGELOG.md order.
For every datasource, as a final tier, tried only after every registry-metadata
lookup above for that datasource has come back empty (for docker: both the
packaging-repo tier and, when a candidate was found, the upstream-repo tier): check
Renovate's own PR-body "Release Notes" section for this dependency, using the body
text steps 1–2 already fetched (gh pr view --json body) — no extra fetch. Write
that body to a file and run node ${CLAUDE_SKILL_DIR}/scripts/extract-release-notes-from-pr-body-cli.js --body-file <path> --dependency <name>. Execute this script directly — it deterministically
locates this dependency's own Release Notes section (never another dependency's, on
a grouped PR) and classifies it, never re-derived by hand. A found result's text
satisfies the "changelog found" check below and feeds the breaking-change-callout
scan (step 12) exactly like any other tier's text — but never the Security advisory
scan (step 15) or the Opportunity scan (step 16) — see the "combined text" paragraph
below. A compare-link-only result (the section's only content is a bare Compare
Source link, no itemized entries) or an absent result (no section for this
dependency at all) contributes nothing — the adapter's own registry-metadata tiers'
emptiness still stands.
"No changelog or release notes found anywhere" (relevant to the major-bump hard-stop
below) means every tier for that datasource — including this PR-body fallback —
came back empty at every stage; for docker specifically, this means the
packaging-tier lookup, the upstream-tier lookup (when a candidate was found), and
the PR-body fallback all came back empty. A none or ambiguous upstream-extraction
result means only the packaging tier's own emptiness matters — the upstream tier
contributes nothing to this determination. A raw compare/diff view between two tags
never counts as a changelog found, at any tier, for any datasource — including when
that bare link is the only thing the PR-body fallback's own section offers — only an
actual GitHub Release, a CHANGELOG.md section, or the PR body's own genuine
itemized Release Notes content does.
Whenever more than one registry-metadata tier returns content (docker's packaging
and upstream tiers both resolving a changelog), every downstream scan — the
breaking-change-callout scan (step 12), the Security advisory scan (step 15), and
the Opportunity scan (step 16) — reads the combined text of every such tier that
returned content, not just whichever tier resolved first; each tier can
independently carry relevant content (the packaging repository for the image's
build/runtime interface, the upstream repository for the software's actual
behavior). The PR-body fallback's text feeds only the "changelog found" check above
and step 12's breaking-change-callout scan — never the Security advisory scan (step
15) or the Opportunity scan (step 16), since Renovate truncates a very large
changelog when rendering the PR body, so it can't be assumed to enumerate every
intermediate version the way a direct range-fetch can; the fallback exists only to
answer "changelog found," never to supply the range those two scans require.
The full old→new release range is always fetched once a source is found via the
fixed registry-metadata order above (at either docker tier), for every bump size and
regardless of --no-opportunities — GitHub Releases already return every release,
so keep every entry whose tag falls in the old→new range instead of only the
latest; for CHANGELOG.md, read every dated or versioned section between the old
and new version headings, not just the top one. This full range is what the
unconditional Security advisory scan (step 15) reads. Step 16's Opportunity scan
still only reads this same range for a minor or major bump when
--no-opportunities wasn't passed — nothing about Opportunity's own trigger
condition changes, only the fact that the range it reads is no longer fetched
conditionally on its behalf alone.
If any changelog or release notes text was found in step 11 (the combined text of
every tier that returned content, for docker, plus the PR-body fallback's text when
it resolved found), scan it for an explicit
breaking-change callout (a "Breaking Changes" heading, or prose stating a breaking
change) and judge whether it's actually relevant to this codebase's usage — cross-
reference the callout's described change (a removed export, a changed function
signature, a changed default, a removed collection role) against the call sites
found in step 14. A callout naming something this codebase never touches does not
count as relevant.
Check CI status for the PR: gh pr checks <number> — classify as failing,
pending (checks exist but haven't finished), or passing (no failing or pending
checks).
Compute blast radius: count the distinct tracked files in the current repo using
this dependency, searched per the PR's resolved datasource:
- npm: files importing or requiring the dependency —
git grep -lE "['\"]<dep>(/|['\"])" -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs'.
- docker: files naming the image —
git grep -l "<image-repository>" -- '*Dockerfile*' 'docker-compose*.yml' 'docker-compose*.yaml'.
- pypi: files importing the package —
git grep -lE "^\s*(from|import)\s+<package>" -- '*.py'.
- ansible-galaxy: playbooks using the collection's fully-qualified name —
git grep -l "<namespace>\.<collection>\." -- '*.yml' '*.yaml'.
Treat the count as large when it exceeds 10 distinct files, regardless of
datasource. Ten is the point past which reviewing every call site by hand stops
being realistic inside an ordinary PR review — below it, a maintainer could still
plausibly open every call site directly and check it themselves if they chose to,
so a clean changelog is enough to stand on its own; above it, the sheer count is
itself the practical reason a bump needs a closer look, independent of what the
changelog says. This applies identically whether the dependency sits in a
production or dev-only role — placement is reported as context (step 19) but never
changes the verdict.
Running the Security advisory scan
- For every dependency, regardless of bump size and regardless of
--no-opportunities: write the combined changelog text gathered in step 11's
range-fetch (never the PR-body fallback's text) to a file and run node ${CLAUDE_SKILL_DIR}/scripts/detect-security-advisory-cli.js <changelog-file> once per dependency. Execute this script directly — it
deterministically checks for an explicit CVE identifier, a GitHub Security Advisory
ID, a "Security" heading, or explicit urgency/vulnerability language, OR-combined,
never re-derived by hand. A dependency with no changelog text at all (step 11
found nothing) or whose text matches none of the four signals produces no Security
advisory output for that dependency — not an empty placeholder. A finding here is
never cross-referenced against this codebase's call sites the way step 12's
breaking-change callout and step 16's Opportunity are — a security disclosure
applies regardless of which APIs this codebase actually calls — and it never
changes the verdict computed in step 17, regardless of tier.
Running the Opportunity scan
- For a minor or major bump (step 10) only, and only when
--no-opportunities wasn't
passed: scan the combined changelog text gathered in step 11's range-fetch (the
widened range; never the PR-body fallback's text) for two kinds of finding only — a newly-added capability, or an existing capability the
dependency now marks deprecated — no other category (a performance note, a
config-only addition) counts. Keep a finding only when it's actually relevant to
this codebase's usage, cross-referenced against the same call sites found in step 14
— the same relevance test step 12 already applies to a breaking-change callout, no
new codebase search. There's no cap on how many findings survive this filter per
dependency. A patch or indeterminate bump, a --no-opportunities run, or a
dependency with nothing relevant found, produces no Opportunity output at all for
that dependency — not an empty placeholder — and this never changes the verdict
computed in step 17, regardless of what's found or how many findings there are.
Computing the verdict, per dependency
- Compute the bump size and the verdict together by running
node ${CLAUDE_SKILL_DIR}/scripts/compute-verdict-cli.js --old-version <old> --new-version <new> --changelog-found <true|false> --breaking-callout <true|false> --ci-status <passing|pending|failing> --blast-radius-large <true|false>, using the facts gathered in steps 10–14 — classifying the bump size
itself needs no configurable threshold, since semver already defines the
patch/minor/major boundary (and a non-semver version — common for a docker tag or
an ansible-galaxy collection version — classifies deterministically as
indeterminate), so it's folded into the same deterministic call rather than judged
separately. Execute this script directly for each dependency — it
deterministically implements the decision table below, so the table must never be
hand-recomputed or re-derived from the prose. It prints { bumpSize, verdict, reason } as JSON. The rules it implements:
- Hard-stops, checked first — any one alone forces
blocked, and skips baseline
and escalation entirely for this dependency: an explicit breaking-change callout
relevant to this codebase's actual usage (step 12); a failing CI check (step 13);
a major bump with no changelog or release notes found anywhere (step 11) — this
hard-stop is inapplicable to an indeterminate bump size, since "major" can't be
asserted when the version strings don't parse as semver.
- Baseline, only reached when no hard-stop fired: an indeterminate bump size (a
non-semver old or new version) →
needs-review unconditionally, mirroring the
no-changelog-found case below — "I can't tell how big this bump is" must never
silently resolve to safe; patch or minor bump with a changelog found → safe;
major bump with a changelog found and no relevant breaking-change callout →
needs-review, regardless of blast radius (a major bump's baseline is never
safe — the version jump alone is enough to warrant a human glance even on a
clean changelog); patch or minor bump with no changelog found → needs-review
(this can't honestly reach safe — safe means a changelog was actually read and
found clean, not merely that no danger signal happened to fire — but it also
doesn't fit the major-bump hard-stop, which is specifically about major bumps).
- Escalations, applied to the baseline, each by exactly one tier: large blast
radius (step 14); CI pending rather than passing (step 13).
blocked is reached
only via a hard-stop — no combination of baseline and escalations ever produces
it, even when both escalations fire on the same dependency, and even when the
baseline came from an indeterminate bump size. This keeps a genuinely dangerous
signal (a hard-stop) from ever being diluted by, or confused with, an accumulation
of merely-cautious ones: needs-review is the ceiling anything but a hard-stop
can reach.
Rolling up a grouped PR
- A PR grouping several dependencies gets a per-dependency verdict breakdown (steps
10–17 run once per dependency) plus one overall rollup verdict, shown at the top of
the comment, equal to the worst (most severe:
blocked > needs-review > safe)
verdict among its dependencies — so one risky dependency in an otherwise-boring
bundle is never hidden behind the others. Opportunities roll up the same way, but
never into a single worst-of value: one Opportunities subsection per dependency that
has a finding, none for a dependency that doesn't — mirroring the verdict
breakdown's per-dependency shape without a rollup verdict of its own, since an
Opportunity is never ranked against another. A Security advisory finding (step 15)
is likewise computed per dependency and never rolled into a single worst-of value or
the overall verdict, for the same reason — how it's rendered for a grouped PR is
settled by whatever comment-composition convention is current, not fixed here.
Writing the Agent brief
- Only for a dependency (or PR, if ungrouped) whose final verdict is
blocked: name
the concrete call sites to inspect (the file list from step 14's blast-radius
search, not just a count), and point to the specific changelog or release-notes
section that triggered the hard-stop. For a docker dependency whose hard-stop is
the major-bump/no-changelog rule (step 11), name both repositories checked by
owner/repo — the packaging repository, and the upstream repository too when step
11's extraction found one — even though neither had a changelog, so a follow-up
agent starts from "these repositories were already checked and came back empty"
instead of re-deriving the dual-repo resolution from scratch. A non-docker
dependency's brief is unaffected — it never had a second repository to name. If the
hard-stop was a failing CI check, name the specific failing check(s) instead of a
changelog section. If the changelog links a migration or upgrade guide and its own
text isn't enough to say what needs to change at each named call site, fetch that
guide now — the one point in this skill's flow where reading a migration guide is
in scope — and cite the specific section relevant to the flagged change. Never write
an Agent brief for needs-review — that tier means a human should glance and
decide, not that information is missing.
Posting the comment
- Compose one comment body per PR following the literal shape in
${CLAUDE_SKILL_DIR}/COMMENT-SKELETON.md — copy that file's structure rather than
re-deriving the comment's shape from prose. It fixes the section order (marker, tier
line, per-dependency breakdown, Security advisories, Agent brief, Opportunities) and
the table-vs-prose choice for the per-dependency breakdown; this flow feeds it the
marker string (<!-- renovate-triage:verdict -->, used for the validation gate below
and the idempotency check in step 22, never shown in the rendered comment), the tier
line's reason from step 17, step 18's per-dependency rollup (including each
dependency's production/dev-only placement), step 19's Agent brief section when one
was produced, each dependency's Security advisory finding from step 15 when present,
and — kept in its own "Opportunities" section, separate from both the verdict and the
Agent brief — each dependency's findings from step 16, omitted entirely (no
placeholder line) for a dependency step 16 produced nothing for.
- Before any comment write executes for this run, validate every composed body: run
node ${CLAUDE_SKILL_DIR}/scripts/validate-comment-body-cli.js <verdict> <body-file> for each PR's body from step 20. Execute this script directly for
every PR in the batch before posting any of them — it is the machine-checkable gate
for the whole run, not a manual double-check. It confirms the verdict is one of
the three valid tiers, the idempotency marker appears exactly once, the tier line's
label matches the computed verdict, an Agent brief section is present if and only if
the verdict is blocked and — when present — its body is fenced in a ```text
block, and any "Opportunities" heading in the body is followed by real content
rather than an empty section or boilerplate empty-state text. A PR whose body fails
validation is skipped for posting — report it in step 23 alongside the reason
validation gave, rather than letting a malformed comment reach a real PR — while
every other PR in the batch still proceeds.
- For every PR whose body passed step 21's validation: search the PR's existing
comments for the marker:
gh api repos/<owner>/<repo>/issues/<number>/comments --jq '.[] | select(.body | contains("renovate-triage:verdict")) | .id'. If a match
exists, update it in place — gh api -X PATCH repos/<owner>/<repo>/issues/comments/<id> -F body=@<file> — rather than posting a
second one. If no match exists, create it — gh pr comment <number> --body-file <file>. Invoking the skill is sufficient authorization to write or update every
comment touched in the run; there is no separate per-PR confirmation prompt beyond
step 21's validation gate.
Reporting
- Report in-session, in addition to the PR comments: every PR checked, its verdict (or
per-dependency breakdown for a grouped PR), whether its comment was created or
updated, which PRs or dependencies received a new Security advisory finding (step
- or a new Opportunities section (step 16), every PR skipped for a mixed or
unresolved datasource (step 8), every PR skipped for having no adapter for its
resolved datasource (step 9), every PR skipped for detection being unavailable (step
7), and every PR skipped for failing step 21's validation gate — each with the
reason why. On a no-target scan that found zero open Renovate PRs, this report is
exactly the "no open Renovate PRs found" line from step 3 — never silence.
Worked example
A synthetic scenario set, fabricated for this dry run and discarded afterward — never
committed, so a fixture PR can't be mistaken for a real one — covering each hard-stop,
baseline, and escalation individually, the grouped-PR rollup, one fixture per
datasource, each Opportunity-scan shape, the docker adapter's dual-repo lookup, and the
Security advisory scan. See FIXTURES.md for the full fixture table and walkthrough,
and for the comment-idempotency check.
1---2name: renovate-triage3description: Reads each open Renovate dependency-bump PR's changelog, release notes, and CI status, computes a Risk verdict — safe, needs-review, or blocked — from a fixed hard-stop rule list, and posts (or updates) one PR comment stating the verdict and reason; blocked PRs also get an Agent brief naming call sites and changelog sections for follow-up. Resolves each changed file's datasource (npm, Docker, PyPI, Ansible Galaxy) from the repo's own renovate.json rather than guessing from filenames. For a minor or major bump it also reports relevant Opportunities in a separate, verdict-independent section (skip with `--no-opportunities`); for every bump it also scans gathered changelogs for a Security advisory — a CVE, GHSA ID, or urgency language — reported the same way, never changing the verdict. Use when the user types /renovate-triage with no argument to scan every open Renovate PR in the current repo, with a PR number or URL to check one, or asks to triage, review, or assess Renovate PR risk.4---56# renovate-triage78**Risk verdict**: A Renovate PR's classification into one of three tiers — `safe`,9`needs-review`, or `blocked` — computed by a fixed hard-stop rule list (an explicit10breaking-change callout, a failing CI check, or a major bump with no changelog found11anywhere, each alone forcing `blocked`) with a bump-size baseline underneath, rather than12a weighted score. Blast radius (how widely the dependency is used in the consuming13codebase) and CI-pending status can each escalate the baseline by one tier; dev-only vs.14production placement is reported as context but never changes the verdict — both are15escalated identically.1617**Agent brief**: A `blocked`-verdict's handoff content, written into the same PR comment18as the risk verdict rather than a separate artifact. Addressed to an agent continuing the19investigation, not a human skimming for discretion — concrete starting points: which20call sites to inspect, which changelog or migration-guide sections to read.2122**Datasource**: Renovate's own name for a dependency's versioning source and lookup23mechanism — `npm`, `docker`, `pypi`, `ansible-galaxy`, and others. A built-in manager24(e.g. npm) has a fixed default datasource; a `customManagers` entry declares its own via25`datasourceTemplate`. This skill resolves a changed file's datasource by reading the26target repo's own `renovate.json`, never by guessing from the file's name or extension —27a custom regex manager can point any file at any datasource.2829**Datasource adapter**: A declarative bundle — where to look for a changelog or release30notes, and how to search the codebase for call sites — that evidence-gathering31dispatches to once a file's datasource is resolved. One adapter per datasource; a32resolved datasource with no adapter built is skipped and named, never given generic33best-effort evidence gathering. For `docker`, one adapter run can consult two34repositories — a packaging repository resolved from registry metadata, and an upstream35repository found by scanning that packaging repository for an embedded GitHub URL — never36guessed at from the image name.3738**Opportunity**: A relevant capability change found while scanning a minor or major39bump's full release range (every version between old and new, not just the latest) —40either a newly-added capability, or an existing capability the dependency now marks41deprecated — cross-referenced against the dependency's actual call sites in the42consuming codebase and reported only when relevant usage is found there. Reported per43dependency, in its own comment section, alongside but never merged into the Risk44verdict — it never escalates, de-escalates, or otherwise changes the verdict, regardless45of tier or placement.4647**Security advisory**: An explicit security disclosure — an explicit CVE identifier, a48GitHub Security Advisory ID, a "Security" heading, or explicit urgency/vulnerability49language — found while scanning any changelog text this skill already gathered, for any50datasource, regardless of bump size. Reported per dependency, in its own comment51section, alongside but never merged into the Risk verdict — it never escalates,52de-escalates, or otherwise changes the verdict. Unlike Opportunity, both its widened53fetch and its reporting are unconditional: they run for every bump size and are never54skipped by `--no-opportunities`, and a finding is never cross-referenced against this55codebase's call sites — a security disclosure is reported regardless of whether a56matching call site is found.5758The skill computes a Risk verdict for every open Renovate PR, or the single PR given as59an argument, reports in-session, and maintains exactly one idempotent comment per PR,60updated in place on later runs rather than duplicated. It never approves, merges, or61labels a PR — every merge decision stays the maintainer's. By default it also scans62every minor or major bump for Opportunities, reported in their own section separate63from the verdict; pass `--no-opportunities` to skip that pass for a single run.64Independent of bump size and of that flag, it also scans every dependency's gathered65changelog text for a Security advisory, reported in its own section and never affecting66the verdict.6768## Dependencies6970Requires an authenticated `gh` CLI — every step shells out to it (`gh pr list`, `gh pr71view`, `gh pr checks`, `gh api`, `gh pr comment`). Also requires `npm` and `node` — `npm`72to resolve an npm dependency's repository URL (`npm view <dep> repository.url`, used only73by the npm adapter), and `node` to run this skill's bundled resolution, extraction,74detection, verdict, and validation scripts (`${CLAUDE_SKILL_DIR}/scripts/`). The docker,75pypi, and ansible-galaxy adapters query their own registries over HTTP via `curl` —76ambient on any machine capable of running Claude Code, like `git`, so it isn't listed as77a precondition the way `npm` and `node` are.7879## When to use8081- The user types `/renovate-triage` with no argument — every open Renovate PR in the82 currently checked-out repo gets a verdict in one pass.83- The user types `/renovate-triage <PR number or URL>` — re-check or spot-check exactly84 one PR without waiting for a full scan.85- The user asks to triage, review, or assess the risk of Renovate dependency-bump PRs.8687## When not to use8889- A PR whose resolved datasource (step 6) has no adapter built — anything beyond90 npm/docker/pypi/ansible-galaxy. This skill's evidence-gathering is designed around91 those four specifically; running it against an unhandled datasource would produce a92 verdict built on evidence it never actually gathered. A full-repo scan reports these93 PRs as skipped rather than silently omitting them — see step 9.94- A PR whose changed files span more than one resolved datasource, or include a file95 whose datasource can't be resolved locally (`unknown` — including one reachable only96 through an `extends` preset this skill never fetches). Reported as skipped, not97 triaged on a partial assumption about what the PR touches — see step 8.98- A repo whose Renovate config isn't found or isn't plain JSON (`renovate.json5` and99 JS-based configs aren't parsed) — reported as "detection unavailable," not silently100 skipped. See step 7.101- Any repo other than the one currently checked out. There is no `--repo` flag and no102 cross-repo scanning.103- Wanting the skill to approve, merge, or label a PR based on its verdict — it never104 does any of those, regardless of tier.105- Wanting migration-guide research on every PR. A migration or upgrade guide is106 consulted only as a targeted follow-up when a `blocked` verdict's Agent brief needs107 more detail than the changelog alone gives — never a broad web search, and never for a108 `safe` or `needs-review` verdict.109110## Resolving what to check111112Before either case below, strip `--no-opportunities` out of the raw arguments if113present — a boolean switch, never combined with a value, that can appear with or114without a PR number or URL. When present, this entire run skips step 16's Opportunity115scan for every PR it checks; nothing else in this section changes — the Security116advisory scan (step 15) is never gated by this flag.1171181. **An explicit target was given** (PR number or URL): resolve it directly with `gh pr119 view <target> --json number,title,url,body,headRefName,author,files`. This is always120 a single-PR check, regardless of who authored it or what datasource it touches — an121 explicit target names a PR that must already exist, and the datasource-resolution122 check below still applies to it.1232. **No target was given** (only `--no-opportunities`, or no arguments at all): list124 every open PR in the current repo — `gh pr list --state open --json125 number,title,url,body,headRefName,author,files` — and keep only the Renovate-authored126 ones: `author.login` equal to `renovate[bot]` or `app/renovate`.1273. **Zero PRs survive step 2**: report "no open Renovate PRs found" and stop — a clear128 confirmation the scan actually ran, not silence or an error.129130## Orchestrating the scan1311324. **No target was given** (step 2 produced one or more PRs): dispatch one sub-agent per133 PR via the Agent tool, run in parallel, each executing the complete per-PR flow below134 (datasource resolution through comment posting) independently for its own PR. PRs are135 already self-contained triage units — own rollup, own comment, own idempotency marker136 — so no cross-PR coordination is needed; each sub-agent reports its result back for137 step 23's summary. Adapters are never split out to their own sub-agent at138 dependency/datasource granularity — splitting evidence-gathering across a further139 sub-agent boundary would only add coordination overhead around an otherwise140 PR-scoped, deterministic flow.1415. **An explicit target was given** (step 1): run the flow inline in the current142 session, no sub-agent spawned — a quick spot-check shouldn't pay the parallel-143 orchestration overhead a full scan needs.144145## Resolving each file's datasource146147Runs once per PR, before any evidence gathering, using the PR's `files` list from steps1481–2:1491506. Read the target repo's Renovate config — its root `renovate.json`, or151 `.github/renovate.json` if the former doesn't exist — and run `node152 ${CLAUDE_SKILL_DIR}/scripts/resolve-datasource-cli.js --file <path> [--file <path>153 ...]` for every file the PR changed. **Execute this script directly — it154 deterministically matches each file against the config's `customManagers[]` entries155 (`managerFilePatterns` regexes paired with a `datasourceTemplate`) and a small table156 of built-in managers' default file patterns (npm's four filenames are one entry in157 this table now, not the sole recognized case), never re-derived by hand.** `extends`158 preset chains are never fetched or resolved — only the config file's own local159 content is read. It prints `{ status, datasources }` JSON: `status` is `resolved` or160 `detection-unavailable`; `datasources` maps each file to its resolved datasource161 string, or `"unknown"` when the file matches neither a built-in pattern nor a162 `customManagers[]` entry — the same result a datasource reachable only through an163 unresolved `extends` preset gets, since that case is locally indistinguishable from164 "not managed at all."1657. `status: "detection-unavailable"` (no `renovate.json` found at either location, or it166 failed to parse as JSON): report the PR as "detection unavailable" in place of a167 verdict (explicit single-PR target) or under a dedicated "detection unavailable"168 heading (full scan), and stop for that PR — a config problem surfaces instead of the169 PR silently disappearing from the scan.1708. Any file resolving to `"unknown"`, or a PR whose files resolve to more than one171 distinct datasource, marks the PR out of scope: Renovate doesn't normally group172 across datasources, so a mix (or an unresolved file) is evidence of an unusual PR this173 skill isn't designed to read correctly, not a partial PR to triage anyway. Report it174 under a "skipped — mixed or unresolved datasource" heading, naming each file's175 resolved datasource.1769. A PR whose single resolved datasource has no adapter built — anything beyond177 npm/docker/pypi/ansible-galaxy — is reported under a "skipped — no adapter for178 `<datasource>`" heading, never given generic best-effort evidence gathering. A PR179 resolved to npm, docker, pypi, or ansible-galaxy proceeds to evidence-gathering below,180 using that datasource's adapter.181182## Gathering evidence, per dependency183184Steps 10–14 run once per dependency changed in the PR — for a PR Renovate grouped into185several dependencies, once per dependency in the group (see step 18 for how the186per-dependency results roll up). Steps 11 and 14 are the two facets a datasource's187adapter fills in (its changelog source, and its call-site search); steps 10, 12, and 13188are identical regardless of datasource.18919010. Read the old and new version for this dependency from the PR's title or body (both191 fixed Renovate output formats naming the dependency and its version range,192 regardless of datasource), then classify the bump size immediately: `node -e193 "console.log(require('${CLAUDE_SKILL_DIR}/scripts/classify-bump-size').classifyBumpSize(process.argv[1],194 process.argv[2]))" <old> <new>`. **Execute this directly — it calls the exact same195 `classifyBumpSize` function step 17's `compute-verdict-cli.js` runs internally for196 the verdict, just earlier, so step 11 below knows whether to widen its changelog197 range; the bump size must never be hand-classified from the version strings.**19811. Look for a changelog or release notes, per the PR's resolved datasource:199 - **npm**: in this fixed order, stopping at the first hit — (a) the dependency's200 GitHub Releases, via `gh api repos/<dep-owner>/<dep-repo>/releases` once the201 dependency's repository URL is resolved from its npm registry metadata (`npm view202 <dep> repository.url`); (b) the dependency's own `CHANGELOG.md` at its new release203 tag, read from the same repository.204 - **docker**: a packaging repository from registry-published metadata only, never205 guessed from the image name (`pihole/pihole`'s real source is206 `pi-hole/docker-pi-hole`, not a same-named repo), tried in this order, stopping at207 the first hit:208 1. The image manifest's own OCI config labels — fetched from the registry API209 directly, regardless of which registry hosts the image: an anonymous bearer210 token (`curl -s211 "https://auth.docker.io/token?service=registry.docker.io&scope=repository:<namespace>/<image>:pull"`),212 the manifest list for the target tag, the platform-specific manifest it points213 to, and finally that manifest's config blob, whose JSON body carries a214 `config.Labels` object. Write that `Labels` object to a file and run `node215 ${CLAUDE_SKILL_DIR}/scripts/extract-oci-source-label-cli.js --labels-file216 <path>`. **Execute this script directly — it deterministically checks217 `org.opencontainers.image.source`, then `.url`, and parses an `owner/repo` pair218 out of a `github.com` URL, never guessed by hand from either label's raw text.**219 A `found` result names the packaging repository. A `none` result (neither220 label present, or the present label points at a non-`github.com` host) falls221 through to the next tier.222 2. The existing registry field, kept as a cheap secondary check since it's223 occasionally still populated even when OCI labels are absent — a GHCR image's224 linked repository (`gh api orgs/<org>/packages/container/<image>`, its225 `repository` field), or a Docker Hub image's `source_url` field (`curl -s226 https://hub.docker.com/v2/repositories/<namespace>/<image>/`) — the latter a227 relic of Docker Hub's old "automated builds" linking feature, empty for228 ordinary Docker Hub images in practice, not just edge cases.229230 Once a packaging repository is found via either tier above, run the same231 GitHub-Releases-then-`CHANGELOG.md` order as npm's (a)/(b) against it.232233 Always also look for a second, upstream repository, supplementing rather than234 replacing the packaging-repository lookup above: fetch the packaging repository's235 Dockerfile and version-pin files at the target tag — the same files this PR's236 changed-file list already resolved to the `docker` datasource in step 6 — via `gh237 api repos/<owner>/<repo>/contents/<path>?ref=<tag>`, then run `node238 ${CLAUDE_SKILL_DIR}/scripts/extract-upstream-repo-cli.js --packaging-repo239 <owner>/<repo> --file <path> [--file <path> ...]` against their fetched content.240 **Execute this script directly — it deterministically scans for an embedded GitHub241 release/tag/tarball URL naming a different repository than the packaging one,242 never guessed by hand from the image or file name.** A `found` result names243 exactly one upstream repository — run the identical GitHub-Releases-then-244 `CHANGELOG.md` lookup there too. A `none` or `ambiguous` result (zero candidates,245 or more than one with no way to tell which is authoritative) means the adapter246 never guesses — it proceeds packaging-repository-only, using only what the247 packaging-tier lookup above already found.248 - **pypi**: the package's declared source from PyPI's JSON API (`curl -s249 https://pypi.org/pypi/<package>/json`) — a `Source`, `Repository`, or `Changelog`250 entry (case-insensitive key match) in `info.project_urls`, or `info.home_page`251 only when it itself points at a GitHub or GitLab repository. Never a guess from252 the package name. Once found, the same GitHub-Releases-then-`CHANGELOG.md` order.253 - **ansible-galaxy**: the collection's declared `repository` field from the Galaxy254 API v3 collection metadata (`curl -s255 https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/<namespace>/<name>/`).256 Never a guess from the namespace or collection name. Once found, the same257 GitHub-Releases-then-`CHANGELOG.md` order.258259 **For every datasource, as a final tier**, tried only after every registry-metadata260 lookup above for that datasource has come back empty (for docker: both the261 packaging-repo tier and, when a candidate was found, the upstream-repo tier): check262 Renovate's own PR-body "Release Notes" section for this dependency, using the body263 text steps 1–2 already fetched (`gh pr view --json body`) — no extra fetch. Write264 that body to a file and run `node265 ${CLAUDE_SKILL_DIR}/scripts/extract-release-notes-from-pr-body-cli.js --body-file266 <path> --dependency <name>`. **Execute this script directly — it deterministically267 locates this dependency's own Release Notes section (never another dependency's, on268 a grouped PR) and classifies it, never re-derived by hand.** A `found` result's text269 satisfies the "changelog found" check below and feeds the breaking-change-callout270 scan (step 12) exactly like any other tier's text — but never the Security advisory271 scan (step 15) or the Opportunity scan (step 16) — see the "combined text" paragraph272 below. A `compare-link-only` result (the section's only content is a bare Compare273 Source link, no itemized entries) or an `absent` result (no section for this274 dependency at all) contributes nothing — the adapter's own registry-metadata tiers'275 emptiness still stands.276277 "No changelog or release notes found anywhere" (relevant to the major-bump hard-stop278 below) means every tier for that datasource — including this PR-body fallback —279 came back empty at every stage; for docker specifically, this means the280 packaging-tier lookup, the upstream-tier lookup (when a candidate was found), and281 the PR-body fallback all came back empty. A `none` or `ambiguous` upstream-extraction282 result means only the packaging tier's own emptiness matters — the upstream tier283 contributes nothing to this determination. A raw compare/diff view between two tags284 never counts as a changelog found, at any tier, for any datasource — including when285 that bare link is the only thing the PR-body fallback's own section offers — only an286 actual GitHub Release, a `CHANGELOG.md` section, or the PR body's own genuine287 itemized Release Notes content does.288289 Whenever more than one registry-metadata tier returns content (docker's packaging290 and upstream tiers both resolving a changelog), every downstream scan — the291 breaking-change-callout scan (step 12), the Security advisory scan (step 15), and292 the Opportunity scan (step 16) — reads the combined text of every such tier that293 returned content, not just whichever tier resolved first; each tier can294 independently carry relevant content (the packaging repository for the image's295 build/runtime interface, the upstream repository for the software's actual296 behavior). The PR-body fallback's text feeds only the "changelog found" check above297 and step 12's breaking-change-callout scan — never the Security advisory scan (step298 15) or the Opportunity scan (step 16), since Renovate truncates a very large299 changelog when rendering the PR body, so it can't be assumed to enumerate every300 intermediate version the way a direct range-fetch can; the fallback exists only to301 answer "changelog found," never to supply the range those two scans require.302303 The full old→new release range is always fetched once a source is found via the304 fixed registry-metadata order above (at either docker tier), for every bump size and305 regardless of `--no-opportunities` — GitHub Releases already return every release,306 so keep every entry whose tag falls in the old→new range instead of only the307 latest; for `CHANGELOG.md`, read every dated or versioned section between the old308 and new version headings, not just the top one. This full range is what the309 unconditional Security advisory scan (step 15) reads. Step 16's Opportunity scan310 still only reads this same range for a minor or major bump when311 `--no-opportunities` wasn't passed — nothing about Opportunity's own trigger312 condition changes, only the fact that the range it reads is no longer fetched313 conditionally on its behalf alone.31412. If any changelog or release notes text was found in step 11 (the combined text of315 every tier that returned content, for docker, plus the PR-body fallback's text when316 it resolved `found`), scan it for an explicit317 breaking-change callout (a "Breaking Changes" heading, or prose stating a breaking318 change) and judge whether it's actually relevant to this codebase's usage — cross-319 reference the callout's described change (a removed export, a changed function320 signature, a changed default, a removed collection role) against the call sites321 found in step 14. A callout naming something this codebase never touches does not322 count as relevant.32313. Check CI status for the PR: `gh pr checks <number>` — classify as `failing`,324 `pending` (checks exist but haven't finished), or `passing` (no failing or pending325 checks).32614. Compute blast radius: count the distinct tracked files in the current repo using327 this dependency, searched per the PR's resolved datasource:328 - **npm**: files importing or requiring the dependency — `git grep -lE329 "['\"]<dep>(/|['\"])" -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs'`.330 - **docker**: files naming the image — `git grep -l "<image-repository>" --331 '*Dockerfile*' 'docker-compose*.yml' 'docker-compose*.yaml'`.332 - **pypi**: files importing the package — `git grep -lE "^\s*(from|import)\s+<package>"333 -- '*.py'`.334 - **ansible-galaxy**: playbooks using the collection's fully-qualified name — `git335 grep -l "<namespace>\.<collection>\." -- '*.yml' '*.yaml'`.336337 Treat the count as **large** when it exceeds 10 distinct files, regardless of338 datasource. Ten is the point past which reviewing every call site by hand stops339 being realistic inside an ordinary PR review — below it, a maintainer could still340 plausibly open every call site directly and check it themselves if they chose to,341 so a clean changelog is enough to stand on its own; above it, the sheer count is342 itself the practical reason a bump needs a closer look, independent of what the343 changelog says. This applies identically whether the dependency sits in a344 production or dev-only role — placement is reported as context (step 19) but never345 changes the verdict.346347## Running the Security advisory scan34834915. For every dependency, regardless of bump size and regardless of350 `--no-opportunities`: write the combined changelog text gathered in step 11's351 range-fetch (never the PR-body fallback's text) to a file and run `node352 ${CLAUDE_SKILL_DIR}/scripts/detect-security-advisory-cli.js353 <changelog-file>` once per dependency. **Execute this script directly — it354 deterministically checks for an explicit CVE identifier, a GitHub Security Advisory355 ID, a "Security" heading, or explicit urgency/vulnerability language, OR-combined,356 never re-derived by hand.** A dependency with no changelog text at all (step 11357 found nothing) or whose text matches none of the four signals produces no Security358 advisory output for that dependency — not an empty placeholder. A finding here is359 never cross-referenced against this codebase's call sites the way step 12's360 breaking-change callout and step 16's Opportunity are — a security disclosure361 applies regardless of which APIs this codebase actually calls — and it never362 changes the verdict computed in step 17, regardless of tier.363364## Running the Opportunity scan36536616. For a minor or major bump (step 10) only, and only when `--no-opportunities` wasn't367 passed: scan the combined changelog text gathered in step 11's range-fetch (the368 widened range; never the PR-body fallback's text) for two kinds of finding only — a newly-added capability, or an existing capability the369 dependency now marks deprecated — no other category (a performance note, a370 config-only addition) counts. Keep a finding only when it's actually relevant to371 this codebase's usage, cross-referenced against the same call sites found in step 14372 — the same relevance test step 12 already applies to a breaking-change callout, no373 new codebase search. There's no cap on how many findings survive this filter per374 dependency. A patch or indeterminate bump, a `--no-opportunities` run, or a375 dependency with nothing relevant found, produces no Opportunity output at all for376 that dependency — not an empty placeholder — and this never changes the verdict377 computed in step 17, regardless of what's found or how many findings there are.378379## Computing the verdict, per dependency38038117. Compute the bump size and the verdict together by running382 `node ${CLAUDE_SKILL_DIR}/scripts/compute-verdict-cli.js --old-version <old>383 --new-version <new> --changelog-found <true|false> --breaking-callout384 <true|false> --ci-status <passing|pending|failing> --blast-radius-large385 <true|false>`, using the facts gathered in steps 10–14 — classifying the bump size386 itself needs no configurable threshold, since semver already defines the387 patch/minor/major boundary (and a non-semver version — common for a `docker` tag or388 an `ansible-galaxy` collection version — classifies deterministically as389 `indeterminate`), so it's folded into the same deterministic call rather than judged390 separately. **Execute this script directly for each dependency — it391 deterministically implements the decision table below, so the table must never be392 hand-recomputed or re-derived from the prose.** It prints `{ bumpSize, verdict,393 reason }` as JSON. The rules it implements:394 - **Hard-stops**, checked first — any one alone forces `blocked`, and skips baseline395 and escalation entirely for this dependency: an explicit breaking-change callout396 relevant to this codebase's actual usage (step 12); a failing CI check (step 13);397 a major bump with no changelog or release notes found anywhere (step 11) — this398 hard-stop is inapplicable to an `indeterminate` bump size, since "major" can't be399 asserted when the version strings don't parse as semver.400 - **Baseline**, only reached when no hard-stop fired: an indeterminate bump size (a401 non-semver old or new version) → `needs-review` unconditionally, mirroring the402 no-changelog-found case below — "I can't tell how big this bump is" must never403 silently resolve to `safe`; patch or minor bump with a changelog found → `safe`;404 major bump with a changelog found and no relevant breaking-change callout →405 `needs-review`, regardless of blast radius (a major bump's baseline is never406 `safe` — the version jump alone is enough to warrant a human glance even on a407 clean changelog); patch or minor bump with no changelog found → `needs-review`408 (this can't honestly reach `safe` — `safe` means a changelog was actually read and409 found clean, not merely that no danger signal happened to fire — but it also410 doesn't fit the major-bump hard-stop, which is specifically about major bumps).411 - **Escalations**, applied to the baseline, each by exactly one tier: large blast412 radius (step 14); CI pending rather than passing (step 13). `blocked` is reached413 *only* via a hard-stop — no combination of baseline and escalations ever produces414 it, even when both escalations fire on the same dependency, and even when the415 baseline came from an indeterminate bump size. This keeps a genuinely dangerous416 signal (a hard-stop) from ever being diluted by, or confused with, an accumulation417 of merely-cautious ones: `needs-review` is the ceiling anything but a hard-stop418 can reach.419420## Rolling up a grouped PR42142218. A PR grouping several dependencies gets a per-dependency verdict breakdown (steps423 10–17 run once per dependency) plus one overall rollup verdict, shown at the top of424 the comment, equal to the worst (most severe: `blocked` > `needs-review` > `safe`)425 verdict among its dependencies — so one risky dependency in an otherwise-boring426 bundle is never hidden behind the others. Opportunities roll up the same way, but427 never into a single worst-of value: one Opportunities subsection per dependency that428 has a finding, none for a dependency that doesn't — mirroring the verdict429 breakdown's per-dependency shape without a rollup verdict of its own, since an430 Opportunity is never ranked against another. A Security advisory finding (step 15)431 is likewise computed per dependency and never rolled into a single worst-of value or432 the overall verdict, for the same reason — how it's rendered for a grouped PR is433 settled by whatever comment-composition convention is current, not fixed here.434435## Writing the Agent brief43643719. Only for a dependency (or PR, if ungrouped) whose final verdict is `blocked`: name438 the concrete call sites to inspect (the file list from step 14's blast-radius439 search, not just a count), and point to the specific changelog or release-notes440 section that triggered the hard-stop. For a `docker` dependency whose hard-stop is441 the major-bump/no-changelog rule (step 11), name both repositories checked by442 `owner/repo` — the packaging repository, and the upstream repository too when step443 11's extraction found one — even though neither had a changelog, so a follow-up444 agent starts from "these repositories were already checked and came back empty"445 instead of re-deriving the dual-repo resolution from scratch. A non-docker446 dependency's brief is unaffected — it never had a second repository to name. If the447 hard-stop was a failing CI check, name the specific failing check(s) instead of a448 changelog section. If the changelog links a migration or upgrade guide and its own449 text isn't enough to say what needs to change at each named call site, fetch that450 guide now — the one point in this skill's flow where reading a migration guide is451 in scope — and cite the specific section relevant to the flagged change. Never write452 an Agent brief for `needs-review` — that tier means a human should glance and453 decide, not that information is missing.454455## Posting the comment45645720. Compose one comment body per PR following the literal shape in458 `${CLAUDE_SKILL_DIR}/COMMENT-SKELETON.md` — copy that file's structure rather than459 re-deriving the comment's shape from prose. It fixes the section order (marker, tier460 line, per-dependency breakdown, Security advisories, Agent brief, Opportunities) and461 the table-vs-prose choice for the per-dependency breakdown; this flow feeds it the462 marker string (`<!-- renovate-triage:verdict -->`, used for the validation gate below463 and the idempotency check in step 22, never shown in the rendered comment), the tier464 line's reason from step 17, step 18's per-dependency rollup (including each465 dependency's production/dev-only placement), step 19's Agent brief section when one466 was produced, each dependency's Security advisory finding from step 15 when present,467 and — kept in its own "Opportunities" section, separate from both the verdict and the468 Agent brief — each dependency's findings from step 16, omitted entirely (no469 placeholder line) for a dependency step 16 produced nothing for.47021. Before any comment write executes for this run, validate every composed body: run471 `node ${CLAUDE_SKILL_DIR}/scripts/validate-comment-body-cli.js <verdict>472 <body-file>` for each PR's body from step 20. **Execute this script directly for473 every PR in the batch before posting any of them — it is the machine-checkable gate474 for the whole run, not a manual double-check.** It confirms the verdict is one of475 the three valid tiers, the idempotency marker appears exactly once, the tier line's476 label matches the computed verdict, an Agent brief section is present if and only if477 the verdict is `blocked` and — when present — its body is fenced in a ` ```text `478 block, and any "Opportunities" heading in the body is followed by real content479 rather than an empty section or boilerplate empty-state text. A PR whose body fails480 validation is skipped for posting — report it in step 23 alongside the reason481 validation gave, rather than letting a malformed comment reach a real PR — while482 every other PR in the batch still proceeds.48322. For every PR whose body passed step 21's validation: search the PR's existing484 comments for the marker: `gh api repos/<owner>/<repo>/issues/<number>/comments485 --jq '.[] | select(.body | contains("renovate-triage:verdict")) | .id'`. If a match486 exists, update it in place — `gh api -X PATCH487 repos/<owner>/<repo>/issues/comments/<id> -F body=@<file>` — rather than posting a488 second one. If no match exists, create it — `gh pr comment <number> --body-file489 <file>`. Invoking the skill is sufficient authorization to write or update every490 comment touched in the run; there is no separate per-PR confirmation prompt beyond491 step 21's validation gate.492493## Reporting49449523. Report in-session, in addition to the PR comments: every PR checked, its verdict (or496 per-dependency breakdown for a grouped PR), whether its comment was created or497 updated, which PRs or dependencies received a new Security advisory finding (step498 15) or a new Opportunities section (step 16), every PR skipped for a mixed or499 unresolved datasource (step 8), every PR skipped for having no adapter for its500 resolved datasource (step 9), every PR skipped for detection being unavailable (step501 7), and every PR skipped for failing step 21's validation gate — each with the502 reason why. On a no-target scan that found zero open Renovate PRs, this report is503 exactly the "no open Renovate PRs found" line from step 3 — never silence.504505## Worked example506507A synthetic scenario set, fabricated for this dry run and discarded afterward — never508committed, so a fixture PR can't be mistaken for a real one — covering each hard-stop,509baseline, and escalation individually, the grouped-PR rollup, one fixture per510datasource, each Opportunity-scan shape, the docker adapter's dual-repo lookup, and the511Security advisory scan. See `FIXTURES.md` for the full fixture table and walkthrough,512and for the comment-idempotency check.