id: pinned-dependency-yanked-release-watch
namespace: company.team
description: |
Check the exact dependency versions your services pin on PyPI, npm and crates.io, and alert in Slack
when a maintainer yanks or deprecates one of them, with the maintainer's reason and the latest
release to move to. Lockfiles keep installing yanked versions without failing the build.
inputs:
- id: dependencies
type: JSON
displayName: Pinned dependencies
description: List of objects with an `ecosystem` (`pypi`, `npm` or `crates`),
the package `name`, the pinned `version`, and an `owner` to mention.
defaults: |
[
{"ecosystem": "pypi", "name": "requests", "version": "2.32.0", "owner": "@api-team"},
{"ecosystem": "npm", "name": "request", "version": "2.88.2", "owner": "@web-team"},
{"ecosystem": "crates", "name": "tokio", "version": "1.39.0", "owner": "@platform"},
{"ecosystem": "pypi", "name": "urllib3", "version": "2.2.3", "owner": "@api-team"}
]
tasks:
- id: for_each_dependency
type: io.kestra.plugin.core.flow.Loop
description: Check every pinned version independently, four at a time.
values: "{{ inputs.dependencies }}"
concurrencyLimit: 4
tasks:
- id: fetch_version
type: io.kestra.plugin.core.http.Download
description: Metadata of the pinned version from the public registry (no key).
uri: >-
{%- set d = fromJson(item.value) -%} {%- if d.ecosystem == 'pypi'
-%}https://pypi.org/pypi/{{ d.name }}/{{ d.version }}/json {%- elseif
d.ecosystem == 'npm' -%}https://registry.npmjs.org/{{ d.name }}/{{
d.version }} {%- else -%}https://crates.io/api/v1/crates/{{ d.name
}}/{{ d.version }}{%- endif -%}
headers:
User-Agent: kestra-pinned-dependency-watch (https://kestra.io/blueprints)
- id: fetch_latest
type: io.kestra.plugin.core.http.Download
description: Metadata of the package's latest release, to suggest an upgrade target.
uri: >-
{%- set d = fromJson(item.value) -%} {%- if d.ecosystem == 'pypi'
-%}https://pypi.org/pypi/{{ d.name }}/json {%- elseif d.ecosystem ==
'npm' -%}https://registry.npmjs.org/{{ d.name }}/latest {%- else
-%}https://crates.io/api/v1/crates/{{ d.name }}{%- endif -%}
headers:
User-Agent: kestra-pinned-dependency-watch (https://kestra.io/blueprints)
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: Status reported at the last run for this exact version. Missing on
the first run.
key: "yank_watch_{{ fromJson(item.value).ecosystem }}_{{
fromJson(item.value).name | replace({'@': 'scoped-', '/': '-'}) |
slugify }}_{{ fromJson(item.value).version | replace({'.': '-'}) |
slugify }}"
errorOnMissing: false
- id: evaluate
type: io.kestra.plugin.scripts.python.Script
description: Read the yanked or deprecated flag and the reason, and pick the
latest release.
containerImage: python:3.12-slim
inputFiles:
version.json: "{{ outputs.fetch_version.uri }}"
latest.json: "{{ outputs.fetch_latest.uri }}"
dependency.json: "{{ item.value }}"
previous.json: "{{ (outputs.load_previous.value ?? {}) | toJson }}"
script: |
import json
def load(name):
with open(name, encoding="utf-8") as f:
data = json.load(f)
return json.loads(data) if isinstance(data, str) else data
dep = load("dependency.json")
meta = load("version.json")
latest_meta = load("latest.json")
previous = load("previous.json") or {}
if dep["ecosystem"] == "pypi":
status = "yanked" if meta["info"].get("yanked") else "ok"
reason = meta["info"].get("yanked_reason") or ""
latest = latest_meta["info"]["version"]
link = f"https://pypi.org/project/{dep['name']}/{dep['version']}/"
elif dep["ecosystem"] == "npm":
status = "deprecated" if meta.get("deprecated") else "ok"
reason = meta.get("deprecated") or ""
latest = latest_meta["version"]
link = f"https://www.npmjs.com/package/{dep['name']}/v/{dep['version']}"
elif dep["ecosystem"] == "crates":
status = "yanked" if meta["version"].get("yanked") else "ok"
reason = meta["version"].get("yank_message") or ""
latest = latest_meta["crate"].get("max_stable_version") or latest_meta["crate"]["newest_version"]
link = f"https://crates.io/crates/{dep['name']}/{dep['version']}"
else:
raise SystemExit(f"Unknown ecosystem {dep['ecosystem']!r}; use pypi, npm or crates.")
if latest == dep["version"]:
advice = "There is no newer release, so the whole package is affected: plan a replacement."
else:
advice = f"Latest release: *{latest}*. Update the pin, or record why this version stays."
changed = status != previous.get("status", "ok")
print(f"{dep['ecosystem']}:{dep['name']}=={dep['version']}: {status}"
+ (f" ({reason[:120]})" if reason else "") + f", latest {latest}, changed {changed}")
print("::" + json.dumps({"outputs": {
"status": status,
"reason": reason or "no reason given by the maintainer",
"latest": latest,
"advice": advice,
"link": link,
"notify": changed,
"state": {"status": status},
}}) + "::")
- id: if_changed
type: io.kestra.plugin.core.flow.If
description: Only post when the pinned version becomes yanked or deprecated, or
is restored.
condition: "{{ outputs.evaluate.vars.notify }}"
then:
- id: alert_owner
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the registry status, the maintainer's reason and the upgrade
target.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{% if outputs.evaluate.vars.status == 'ok' %}:white_check_mark: *{{ fromJson(item.value).name }} {{ fromJson(item.value).version }} ({{ fromJson(item.value).ecosystem }}) is no longer yanked or deprecated* (owner: {{ fromJson(item.value).owner }})
{% else %}:no_entry: *{{ fromJson(item.value).name }} {{ fromJson(item.value).version }} ({{ fromJson(item.value).ecosystem }}) was {{ outputs.evaluate.vars.status }} by its maintainers* (owner: {{ fromJson(item.value).owner }})
Reason: {{ outputs.evaluate.vars.reason }}
{{ outputs.evaluate.vars.advice }}
{% endif %}<{{ outputs.evaluate.vars.link }}|Open on the registry>
- id: remember_status
type: io.kestra.plugin.core.kv.Set
description: Store the status for the next comparison. Runs after the alert.
key: "yank_watch_{{ fromJson(item.value).ecosystem }}_{{
fromJson(item.value).name | replace({'@': 'scoped-', '/': '-'}) |
slugify }}_{{ fromJson(item.value).version | replace({'.': '-'}) |
slugify }}"
kvType: JSON
overwrite: true
value: "{{ outputs.evaluate.vars.state | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A dependency that cannot be checked must not look fine.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: Pinned dependency watch failed in execution {{
execution.id }} ({{ flow.namespace }}.{{ flow.id }}). Open the failed loop
iteration: an HTTP 404 means the package or version does not exist (a
typo, or it was deleted from the registry)."
triggers:
- id: daily
type: io.kestra.plugin.core.trigger.Schedule
description: Check every morning. Disabled until the Slack secret is set.
cron: "30 6 * * *"
disabled: true