id: crates-io-release-health-watch
namespace: company.team
description: |
Checks the Rust crates a project uses. It reads the project's Cargo.lock, looks up each crate on
crates.io and sends a Slack message if a crate is yanked, outdated or has had no release for a long time.
It only sends a message when the report is different from the last run.
inputs:
- id: cargo_lock_url
type: STRING
description: Raw URL of the Cargo.lock file. It works for public repositories.
For a private one you would need to add authentication.
defaults: https://raw.githubusercontent.com/sharkdp/fd/master/Cargo.lock
- id: max_crates
type: INT
description: Check at most this many crates in one run. crates.io allows only
one request per second, so a big list takes a while.
defaults: 25
- id: outdated_threshold
type: SELECT
description: How far behind a crate must be to count as OUTDATED. With major,
only a new major version counts (for 0.x crates a new minor version counts
as major).
values:
- major
- minor
- patch
defaults: major
- id: stale_after_days
type: INT
description: A crate is STALE if its latest stable release is older than this many days.
defaults: 365
- id: user_agent
type: STRING
description: crates.io asks for a User-Agent that says who you are. Put your
project name and a contact URL or email here.
defaults: kestra-crates-release-health-watch (replace-with-your-contact)
triggers:
- id: weekly
type: io.kestra.plugin.core.trigger.Schedule
description: Run every Monday at 08:00 UTC. It is disabled until you set the
Slack secret.
cron: "0 8 * * 1"
disabled: true
tasks:
- id: fetch_cargo_lock
type: io.kestra.plugin.core.http.Request
description: Download the Cargo.lock file.
uri: "{{ inputs.cargo_lock_url }}"
method: GET
- id: list_crates
type: io.kestra.plugin.transform.jsonata.TransformValue
description: Get the crates from the lock file (only the ones from crates.io,
not git or private ones) and keep the first max_crates.
from: "{{ outputs.fetch_cargo_lock.body | toJson }}"
expression: |
(
$found := $match($, /name = "([^"]+)"\r?\nversion = "([^"]+)"\r?\nsource = "registry\+https:\/\/github\.com\/rust-lang\/crates\.io-index"/);
$pairs := [$found.(groups[0] & "@" & groups[1])];
$pairs[[0..{{ inputs.max_crates }} - 1]]
)
- id: check_crates
type: io.kestra.plugin.core.flow.Loop
description: Check one crate at a time, because crates.io allows only one
request per second.
values: "{{ outputs.list_crates.value | toJson }}"
concurrencyLimit: 1
outputs:
- id: verdict
type: JSON
value: "{{ outputs.assess.value }}"
tasks:
- id: fetch_crate
type: io.kestra.plugin.core.http.Request
description: Get the crate info and the list of all its versions from crates.io.
uri: "https://crates.io/api/v1/crates/{{ item.value | split('@') | first }}"
method: GET
headers:
User-Agent: "{{ inputs.user_agent }}"
retry:
type: constant
interval: PT5S
maxAttempts: 3
- id: assess
type: io.kestra.plugin.transform.jsonata.TransformValue
description: Decide the status of this crate from the crates.io answer.
from: "{{ outputs.fetch_crate.body }}"
expression: |
(
$pinned := "{{ item.value | split('@') | last }}";
$current := versions[num = $pinned];
$maxv := crate.max_stable_version;
$latest := versions[num = $maxv];
$age := ($toMillis($now()) - $toMillis($latest[0].created_at)) / 86400000;
$yanked := $count($current) > 0 and $current[0].yanked = true;
$p := $split($pinned, ".");
$l := $split($maxv, ".");
$behind := (
($number($l[0]) > $number($p[0]) or ($p[0] = "0" and $l[0] = "0" and $number($l[1]) > $number($p[1]))) ? 3
: ($number($l[0]) = $number($p[0]) and $number($l[1]) > $number($p[1])) ? 2
: ($pinned != $maxv) ? 1
: 0
);
$needed := {"major": 3, "minor": 2, "patch": 1}."{{ inputs.outdated_threshold }}";
$status := $yanked ? "YANKED"
: $count($current) = 0 ? "UNKNOWN_VERSION"
: $age > {{ inputs.stale_after_days }} ? "STALE"
: $behind >= $needed ? "OUTDATED"
: "OK";
{
"crate": crate.name,
"pinned": $pinned,
"latest": crate.max_stable_version,
"days_since_release": $round($age),
"status": $status
}
)
- id: pause_for_rate_limit
type: io.kestra.plugin.core.flow.Sleep
description: Wait one second before the next crate.
duration: PT1S
- id: summarize
type: io.kestra.plugin.transform.jsonata.TransformValue
description: Put all the results together and write the message for the crates
that need attention.
from: "{{ outputs.check_crates.outputs | toJson }}"
expression: |
(
$all := outputs.verdict;
$bad := $all[status != "OK"];
{
"checked": $count($all),
"problem_count": $count($bad),
"message": $count($bad) = 0
? "All " & $count($all) & " crates look healthy."
: "Rust dependency health: " & $count($bad) & " of " & $count($all) & " crates need attention." & "\n" &
$join($bad.(
"• " & crate & " " & pinned & ": " & status &
(status = "YANKED" ? " (this version was pulled from crates.io)" : "") &
". Latest stable is " & latest & ", released " & days_since_release & " days ago."
), "\n")
}
)
- id: previous_report
type: io.kestra.plugin.core.kv.Get
description: Get the report from the last run for this Cargo.lock, if there is one.
key: "crates_report_{{ inputs.cargo_lock_url | sha256 }}"
errorOnMissing: false
- id: alert_if_new
type: io.kestra.plugin.core.flow.If
description: Send to Slack only if there is a problem and the report is
different from last time, so the same message is not sent every week.
condition: "{{ outputs.summarize.value.problem_count > 0 and
(outputs.previous_report.value | default('')) !=
outputs.summarize.value.message }}"
then:
- id: notify_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send the list of crates that need attention.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": {{ outputs.summarize.value.message | toJson }}
}
- id: remember_report
type: io.kestra.plugin.core.kv.Set
description: Save this report so the next run can see if anything changed.
key: "crates_report_{{ inputs.cargo_lock_url | sha256 }}"
value: "{{ outputs.summarize.value.message }}"
errors:
- id: alert_on_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send a message if the check itself fails, for example when the
Cargo.lock URL is wrong or crates.io cannot be reached.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": {{ ("The Rust crate health check failed in execution " ~ execution.id ~ ". Check that cargo_lock_url points to a valid Cargo.lock and that crates.io is reachable.") | toJson }}
}