Skip to main content

release_kit/commands/
versions.rs

1//! `rk versions`: the pinned-tool registry, and its freshness check.
2//!
3//! Plain `rk versions` prints the registry exactly as authored, offline.
4//! `--check` is the canon-side freshness answer and the one verb allowed
5//! to fetch: it consults each pin's check URL and reports per pin, where
6//! an unreachable or unparsable source is a reported result at exit 0,
7//! not an error — and it never edits `versions.toml`, because a pin
8//! update is a reviewed change in this repository. The fetch goes through
9//! `curl`, resolved like the forge CLIs with `RK_CURL_BIN` as the
10//! override, so the check needs no HTTP stack of its own and a test can
11//! substitute the network.
12
13use serde::Serialize;
14
15use crate::cli::versions::VersionsArgs;
16use crate::error::RkError;
17use crate::output::Output;
18use crate::{embedded, registry};
19
20/// One pin's check result.
21#[derive(Debug, Serialize)]
22struct PinResult {
23    /// The tool's registry name.
24    tool: String,
25    /// The pinned version.
26    pinned: String,
27    /// `current`, `update-available`, `source-unreachable`,
28    /// `source-unparsable`, or — for a pin whose freshness lives in its
29    /// ref alone — `no-version-source`.
30    result: &'static str,
31    /// The version the source serves, where one was read.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    available: Option<String>,
34    /// The immutable execution commit, where the pin is an action.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    commit: Option<String>,
37    /// How the discovery ref moves, from the registry.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    ref_class: Option<String>,
40    /// `ref-unmoved`, `ref-moved`, `ref-unreachable`, or
41    /// `ref-unparsable`, for a pin carrying an action and a commit.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    ref_result: Option<&'static str>,
44    /// The commit the discovery ref names today, where it was read.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    ref_commit: Option<String>,
47}
48
49/// The machine form of a check report.
50#[derive(Debug, Serialize)]
51struct Report {
52    /// The shape version of this document.
53    schema: &'static str,
54    /// One result per pin, in registry order.
55    pins: Vec<PinResult>,
56}
57
58/// Print the registry, or check each pin upstream under `--check`.
59///
60/// # Errors
61///
62/// Returns [`RkError::Other`] only when the report cannot serialize; an
63/// unreachable or unparsable source is a reported result, not a failure.
64pub fn run(args: &VersionsArgs) -> Result<(), RkError> {
65    if !args.check {
66        Output::human().result_raw(embedded::VERSIONS);
67        return Ok(());
68    }
69    let out = Output::new(args.json);
70    let mut results = Vec::new();
71    for pin in registry::pins() {
72        let mut result = pin.check.as_deref().map_or_else(
73            || PinResult {
74                tool: pin.name.clone(),
75                pinned: pin.version.clone(),
76                // A pin can live without a version source only where its
77                // freshness signal is the discovery ref itself.
78                result: if pin.action.is_some() && pin.commit.is_some() {
79                    "no-version-source"
80                } else {
81                    "source-unreachable"
82                },
83                available: None,
84                commit: None,
85                ref_class: None,
86                ref_result: None,
87                ref_commit: None,
88            },
89            |url| check_one(&pin.name, &pin.version, url),
90        );
91        if let (Some(action), Some(commit)) = (&pin.action, &pin.commit) {
92            let (ref_result, ref_commit) = resolve_ref(action, commit);
93            result.commit = Some(commit.clone());
94            result.ref_class.clone_from(&pin.ref_class);
95            result.ref_result = Some(ref_result);
96            result.ref_commit = ref_commit;
97        }
98        out.result_line(match (&result.result, &result.available) {
99            (&"update-available", Some(available)) => format!(
100                "update-available {} {} pinned, {available} at the source",
101                result.tool, result.pinned
102            ),
103            _ => format!("{} {} {}", result.result, result.tool, result.pinned),
104        });
105        if let Some(ref_result) = result.ref_result {
106            let reference = pin
107                .action
108                .as_deref()
109                .and_then(|action| action.split_once('@'))
110                .map_or_else(String::new, |(_, reference)| reference.to_owned());
111            out.result_line(match (ref_result, &result.ref_commit) {
112                // Movement of a discovery ref is normal and by design: it
113                // is an update signal the pinned commit already contains,
114                // never something the tool can call an attack.
115                ("ref-moved", Some(now)) => format!(
116                    "ref-moved {}: {reference} now names {now}; an update to review, not an incident",
117                    result.tool
118                ),
119                ("ref-unmoved", _) => format!(
120                    "ref-unmoved {}: {reference} still names the pinned commit",
121                    result.tool
122                ),
123                _ => format!("{ref_result} {}: {reference}", result.tool),
124            });
125        }
126        results.push(result);
127    }
128    out.next(&[
129        "a pin update is a reviewed change to versions.toml, with its checked date".to_owned(),
130    ]);
131    out.emit(&Report {
132        schema: "rk.versions-check/2",
133        pins: results,
134    })
135}
136
137/// Resolve an action's discovery ref to the commit it names today and
138/// compare it against the pinned execution commit.
139fn resolve_ref(action: &str, pinned_commit: &str) -> (&'static str, Option<String>) {
140    let Some((repo, reference)) = action.split_once('@') else {
141        return ("ref-unparsable", None);
142    };
143    let url = format!("https://api.github.com/repos/{repo}/commits/{reference}");
144    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
145    let fetched = std::process::Command::new(curl)
146        .args(["-fsSL", "--max-time", "10", &url])
147        .output();
148    let body = match fetched {
149        Ok(output) if output.status.success() => output.stdout,
150        _ => return ("ref-unreachable", None),
151    };
152    let Some(sha) = serde_json::from_slice::<serde_json::Value>(&body)
153        .ok()
154        .and_then(|value| {
155            value
156                .get("sha")
157                .and_then(serde_json::Value::as_str)
158                .map(str::to_owned)
159        })
160    else {
161        return ("ref-unparsable", None);
162    };
163    if sha == pinned_commit {
164        ("ref-unmoved", Some(sha))
165    } else {
166        ("ref-moved", Some(sha))
167    }
168}
169
170/// Fetch one check URL and classify the answer.
171fn check_one(tool: &str, pinned: &str, url: &str) -> PinResult {
172    let result = |result, available| PinResult {
173        tool: tool.to_owned(),
174        pinned: pinned.to_owned(),
175        result,
176        available,
177        commit: None,
178        ref_class: None,
179        ref_result: None,
180        ref_commit: None,
181    };
182    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
183    let fetched = std::process::Command::new(curl)
184        .args(["-fsSL", "--max-time", "10", url])
185        .output();
186    let body = match fetched {
187        Ok(output) if output.status.success() => output.stdout,
188        _ => return result("source-unreachable", None),
189    };
190    let Some(available) = latest_version(&body) else {
191        return result("source-unparsable", None);
192    };
193    if is_current(pinned, &available) {
194        result("current", Some(available))
195    } else {
196        result("update-available", Some(available))
197    }
198}
199
200/// The latest version a source's JSON names: `max_stable_version` from a
201/// crates.io answer, `tag_name` from a forge's releases answer.
202fn latest_version(body: &[u8]) -> Option<String> {
203    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
204    let raw = value
205        .get("crate")
206        .and_then(|krate| krate.get("max_stable_version"))
207        .or_else(|| value.get("tag_name"))
208        .and_then(serde_json::Value::as_str)?;
209    // A tag may prefix the number — `v2.13.1`, or a name before it — so
210    // the version starts at the first digit.
211    let start = raw.find(|c: char| c.is_ascii_digit())?;
212    Some(raw[start..].to_owned())
213}
214
215/// Whether the pin already matches the source: exactly, or — for a pin
216/// naming only a major, as the action pins do — by major version.
217fn is_current(pinned: &str, available: &str) -> bool {
218    if pinned == available {
219        return true;
220    }
221    !pinned.contains('.') && available.split('.').next() == Some(pinned)
222}
223
224#[cfg(test)]
225mod tests {
226    #![allow(clippy::expect_used)]
227
228    use super::{PinResult, Report, is_current, latest_version};
229
230    #[test]
231    fn a_source_version_is_read_from_both_answer_shapes() {
232        assert_eq!(
233            latest_version(br#"{"crate":{"max_stable_version":"0.3.170"}}"#),
234            Some("0.3.170".to_owned())
235        );
236        assert_eq!(
237            latest_version(br#"{"tag_name":"v2.13.1"}"#),
238            Some("2.13.1".to_owned())
239        );
240        assert_eq!(
241            latest_version(br#"{"tag_name":"release-plz-v0.3.160"}"#),
242            Some("0.3.160".to_owned())
243        );
244        assert_eq!(latest_version(b"not json"), None);
245        assert_eq!(latest_version(br#"{"unrelated":true}"#), None);
246    }
247
248    #[test]
249    fn a_major_only_pin_is_current_within_its_major() {
250        assert!(is_current("0.3.160", "0.3.160"));
251        assert!(!is_current("0.3.160", "0.3.170"));
252        assert!(is_current("4", "4.3.1"));
253        assert!(!is_current("4", "5.0.0"));
254    }
255
256    /// The complete `rk.versions-check/2` shape, held by snapshot.
257    #[test]
258    fn the_versions_check_schema_snapshot_holds() {
259        let report = Report {
260            schema: "rk.versions-check/2",
261            pins: vec![PinResult {
262                tool: "release-plz".into(),
263                pinned: "0.3.160".into(),
264                result: "update-available",
265                available: Some("0.3.170".into()),
266                commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
267                ref_class: Some("moving-minor-tag".into()),
268                ref_result: Some("ref-unmoved"),
269                ref_commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
270            }],
271        };
272        assert_eq!(
273            serde_json::to_string(&report).expect("a report serializes"),
274            r#"{"schema":"rk.versions-check/2","pins":[{"tool":"release-plz","pinned":"0.3.160","result":"update-available","available":"0.3.170","commit":"2eb1d8bcb770b4c48ccfaad919734b38b51958c9","ref_class":"moving-minor-tag","ref_result":"ref-unmoved","ref_commit":"2eb1d8bcb770b4c48ccfaad919734b38b51958c9"}]}"#
275        );
276    }
277}