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, beside `rk self-depend
5//! sync`, one of the two verbs that fetch: it consults each pin's check
6//! URL and reports per pin, where
7//! an unreachable or unparsable source is a reported result at exit 0,
8//! not an error — and it never edits `versions.toml`, because a pin
9//! update is a reviewed change in this repository. The fetch goes through
10//! `curl`, resolved like the forge CLIs with `RK_CURL_BIN` as the
11//! override, so the check needs no HTTP stack of its own and a test can
12//! substitute the network.
13
14use serde::Serialize;
15
16use crate::cli::versions::VersionsArgs;
17use crate::error::RkError;
18use crate::output::Output;
19use crate::{embedded, registry};
20
21/// One pin's check result.
22#[derive(Debug, Serialize)]
23struct PinResult {
24    /// The tool's registry name.
25    tool: String,
26    /// The pinned version.
27    pinned: String,
28    /// `current`, `update-available`, `source-unreachable`,
29    /// `source-unparsable`, or — for a pin whose freshness lives in its
30    /// ref alone — `no-version-source`.
31    result: &'static str,
32    /// The version the source serves, where one was read.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    available: Option<String>,
35    /// The immutable execution commit, where the pin is an action.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    commit: Option<String>,
38    /// How the discovery ref moves, from the registry.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    ref_class: Option<String>,
41    /// `ref-unmoved`, `ref-moved`, `ref-unreachable`, or
42    /// `ref-unparsable`, for a pin carrying an action and a commit.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    ref_result: Option<&'static str>,
45    /// The commit the discovery ref names today, where it was read.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    ref_commit: Option<String>,
48}
49
50/// The machine form of a check report.
51#[derive(Debug, Serialize)]
52struct Report {
53    /// The shape version of this document.
54    schema: &'static str,
55    /// One result per pin, in registry order.
56    pins: Vec<PinResult>,
57}
58
59/// Print the registry, or check each pin upstream under `--check`.
60///
61/// # Errors
62///
63/// Returns [`RkError::Other`] only when the report cannot serialize; an
64/// unreachable or unparsable source is a reported result, not a failure.
65pub fn run(args: &VersionsArgs) -> Result<(), RkError> {
66    if !args.check {
67        Output::human().result_raw(embedded::VERSIONS);
68        return Ok(());
69    }
70    let out = Output::new(args.json);
71    let mut results = Vec::new();
72    for pin in registry::pins() {
73        let mut result = pin.check.as_deref().map_or_else(
74            || PinResult {
75                tool: pin.name.clone(),
76                pinned: pin.version.clone(),
77                // A pin can live without a version source only where its
78                // freshness signal is the discovery ref itself.
79                result: if pin.action.is_some() && pin.commit.is_some() {
80                    "no-version-source"
81                } else {
82                    "source-unreachable"
83                },
84                available: None,
85                commit: None,
86                ref_class: None,
87                ref_result: None,
88                ref_commit: None,
89            },
90            |url| check_one(&pin.name, &pin.version, url),
91        );
92        if let (Some(action), Some(commit)) = (&pin.action, &pin.commit) {
93            let (ref_result, ref_commit) = resolve_ref(action, commit);
94            result.commit = Some(commit.clone());
95            result.ref_class.clone_from(&pin.ref_class);
96            result.ref_result = Some(ref_result);
97            result.ref_commit = ref_commit;
98        }
99        out.result_line(match (&result.result, &result.available) {
100            (&"update-available", Some(available)) => format!(
101                "update-available {} {} pinned, {available} at the source",
102                result.tool, result.pinned
103            ),
104            _ => format!("{} {} {}", result.result, result.tool, result.pinned),
105        });
106        if let Some(ref_result) = result.ref_result {
107            let reference = pin
108                .action
109                .as_deref()
110                .and_then(|action| action.split_once('@'))
111                .map_or_else(String::new, |(_, reference)| reference.to_owned());
112            out.result_line(match (ref_result, &result.ref_commit) {
113                // Movement of a discovery ref is normal and by design: it
114                // is an update signal the pinned commit already contains,
115                // never something the tool can call an attack.
116                ("ref-moved", Some(now)) => format!(
117                    "ref-moved {}: {reference} now names {now}; an update to review, not an incident",
118                    result.tool
119                ),
120                ("ref-unmoved", _) => format!(
121                    "ref-unmoved {}: {reference} still names the pinned commit",
122                    result.tool
123                ),
124                _ => format!("{ref_result} {}: {reference}", result.tool),
125            });
126        }
127        results.push(result);
128    }
129    out.next(&[
130        "a pin update is a reviewed change to versions.toml, with its checked date".to_owned(),
131    ]);
132    out.emit(&Report {
133        schema: "rk.versions-check/2",
134        pins: results,
135    })
136}
137
138/// Resolve an action's discovery ref to the commit it names today and
139/// compare it against the pinned execution commit.
140fn resolve_ref(action: &str, pinned_commit: &str) -> (&'static str, Option<String>) {
141    let Some((path, reference)) = action.split_once('@') else {
142        return ("ref-unparsable", None);
143    };
144    // An action may live below its repository's root — `owner/repo/init` is
145    // one entry point of `owner/repo` — and a ref belongs to the repository,
146    // so only the first two segments address it.
147    let mut segments = path.split('/');
148    let (Some(owner), Some(repo)) = (segments.next(), segments.next()) else {
149        return ("ref-unparsable", None);
150    };
151    // The ref is one path segment, so a `/` inside it is encoded rather than
152    // passed through. This endpoint happens to accept the raw form too, but
153    // the documented contract is the encoded one and a maintained-branch ref
154    // like `release/v1` is exactly the case that relies on it.
155    let encoded: String = reference
156        .bytes()
157        .map(|byte| {
158            if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'~') {
159                char::from(byte).to_string()
160            } else {
161                format!("%{byte:02X}")
162            }
163        })
164        .collect();
165    let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{encoded}");
166    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
167    let fetched = std::process::Command::new(curl)
168        .args(["-fsSL", "--max-time", "10", &url])
169        .output();
170    let body = match fetched {
171        Ok(output) if output.status.success() => output.stdout,
172        _ => return ("ref-unreachable", None),
173    };
174    let Some(sha) = serde_json::from_slice::<serde_json::Value>(&body)
175        .ok()
176        .and_then(|value| {
177            value
178                .get("sha")
179                .and_then(serde_json::Value::as_str)
180                .map(str::to_owned)
181        })
182    else {
183        return ("ref-unparsable", None);
184    };
185    if sha == pinned_commit {
186        ("ref-unmoved", Some(sha))
187    } else {
188        ("ref-moved", Some(sha))
189    }
190}
191
192/// The page bound for a paged answer: at 100 entries a page, ten pages is
193/// a thousand tags, past any project this registry pins. A source deeper
194/// than that reads as unreachable rather than as a version this check
195/// silently guessed.
196const MAX_PAGES: u32 = 10;
197
198/// One GET, or `None` where the fetch failed.
199fn fetch(url: &str) -> Option<Vec<u8>> {
200    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
201    let fetched = std::process::Command::new(curl)
202        .args(["-fsSL", "--max-time", "10", url])
203        .output();
204    match fetched {
205        Ok(output) if output.status.success() => Some(output.stdout),
206        _ => None,
207    }
208}
209
210/// The same URL at a later page, in the query form every paged source here
211/// takes.
212fn paged(url: &str, page: u32) -> String {
213    let joiner = if url.contains('?') { '&' } else { '?' };
214    format!("{url}{joiner}page={page}")
215}
216
217/// Fetch one check URL and classify the answer.
218///
219/// An object answer — a crates.io crate, a forge's latest release — is one
220/// GET. An array answer is a page of a list, and the endpoint documents no
221/// ordering, so reading one page would compute the greatest of an arbitrary
222/// subset and could report a stale pin as current. Every page is therefore
223/// read, to the bound above, and the greatest version across all of them
224/// wins.
225///
226/// Reaching the bound with a page still full is not an answer: the list
227/// continues past what was read, so the greatest version is unknown. That
228/// reads as `source-unreachable`, the same as a failed fetch, rather than
229/// as the greatest of the pages that happened to fit.
230fn check_one(tool: &str, pinned: &str, url: &str) -> PinResult {
231    let result = |result, available| PinResult {
232        tool: tool.to_owned(),
233        pinned: pinned.to_owned(),
234        result,
235        available,
236        commit: None,
237        ref_class: None,
238        ref_result: None,
239        ref_commit: None,
240    };
241    let Some(body) = fetch(url) else {
242        return result("source-unreachable", None);
243    };
244    let mut best = latest_version(&body);
245    if is_page(&body) && !is_empty_page(&body) {
246        let mut ended = false;
247        for page in 2..=MAX_PAGES {
248            let Some(body) = fetch(&paged(url, page)) else {
249                return result("source-unreachable", None);
250            };
251            // Every page of a list is a list. A later page that answers
252            // some other shape is a source this reader does not
253            // understand, and feeding it to the object parser would mint
254            // a version out of an answer that names no tag.
255            if !is_page(&body) {
256                return result("source-unparsable", None);
257            }
258            if is_empty_page(&body) {
259                ended = true;
260                break;
261            }
262            best = greater(best, latest_version(&body));
263        }
264        if !ended {
265            return result("source-unreachable", None);
266        }
267    }
268    let Some(available) = best else {
269        return result("source-unparsable", None);
270    };
271    if is_current(pinned, &available) {
272        result("current", Some(available))
273    } else {
274        result("update-available", Some(available))
275    }
276}
277
278/// Whether the answer is one page of a list rather than a single object.
279fn is_page(body: &[u8]) -> bool {
280    serde_json::from_slice::<serde_json::Value>(body).is_ok_and(|value| value.is_array())
281}
282
283/// Whether the answer is a page past the end of the list.
284fn is_empty_page(body: &[u8]) -> bool {
285    serde_json::from_slice::<serde_json::Value>(body)
286        .is_ok_and(|value| value.as_array().is_some_and(Vec::is_empty))
287}
288
289/// The greater of two versions, comparing numerically component by
290/// component, with an absent version losing to any present one.
291fn greater(left: Option<String>, right: Option<String>) -> Option<String> {
292    match (left, right) {
293        (Some(left), Some(right)) => {
294            if numeric_parts(&right) > numeric_parts(&left) {
295                Some(right)
296            } else {
297                Some(left)
298            }
299        }
300        (some, None) | (None, some) => some,
301    }
302}
303
304/// The latest version a source's JSON names, across the three answer
305/// shapes: `max_stable_version` from a crates.io answer, `tag_name` from a
306/// forge's releases answer, and the greatest `name` from a forge's tags
307/// answer, which is an array.
308///
309/// The tags shape exists for a project that publishes tags and cuts no
310/// releases, so no releases answer names its latest version. The greatest
311/// is taken rather than the first, because the endpoint promises no
312/// ordering.
313fn latest_version(body: &[u8]) -> Option<String> {
314    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
315    if let Some(tags) = value.as_array() {
316        return tags
317            .iter()
318            .filter_map(|tag| tag.get("name").and_then(serde_json::Value::as_str))
319            .filter_map(number_from)
320            .max_by(|left, right| numeric_parts(left).cmp(&numeric_parts(right)));
321    }
322    let raw = value
323        .get("crate")
324        .and_then(|krate| krate.get("max_stable_version"))
325        .or_else(|| value.get("tag_name"))
326        .and_then(serde_json::Value::as_str)?;
327    number_from(raw)
328}
329
330/// A ref name from its first digit on: a tag may prefix the number —
331/// `v2.13.1`, or a name before it.
332fn number_from(raw: &str) -> Option<String> {
333    let start = raw.find(|c: char| c.is_ascii_digit())?;
334    Some(raw[start..].to_owned())
335}
336
337/// A version's numeric components, so that 2.10 orders above 2.9 rather
338/// than below it; the first component that is not a number ends the list,
339/// which keeps a suffixed variant below its plain sibling.
340fn numeric_parts(version: &str) -> Vec<u64> {
341    version
342        .split('.')
343        .map_while(|part| part.parse::<u64>().ok())
344        .collect()
345}
346
347/// Whether the pin already matches the source: exactly, or — for a pin
348/// naming only a major, as the action pins do — by major version.
349fn is_current(pinned: &str, available: &str) -> bool {
350    if pinned == available {
351        return true;
352    }
353    !pinned.contains('.') && available.split('.').next() == Some(pinned)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::{PinResult, Report, is_current, latest_version};
359
360    #[test]
361    fn a_source_version_is_read_from_both_answer_shapes() {
362        assert_eq!(
363            latest_version(br#"{"crate":{"max_stable_version":"0.3.170"}}"#),
364            Some("0.3.170".to_owned())
365        );
366        assert_eq!(
367            latest_version(br#"{"tag_name":"v2.13.1"}"#),
368            Some("2.13.1".to_owned())
369        );
370        assert_eq!(
371            latest_version(br#"{"tag_name":"release-plz-v0.3.160"}"#),
372            Some("0.3.160".to_owned())
373        );
374        assert_eq!(latest_version(b"not json"), None);
375        assert_eq!(latest_version(br#"{"unrelated":true}"#), None);
376    }
377
378    /// The tags shape, for a project that publishes tags and cuts no
379    /// releases. The greatest wins, not the first, because the endpoint
380    /// promises no ordering; and 2.10 is above 2.9, not below it.
381    #[test]
382    fn a_tags_answer_reads_the_greatest_name() {
383        assert_eq!(
384            latest_version(br#"[{"name":"2.35.0"},{"name":"2.35.2"},{"name":"2.34.8"}]"#),
385            Some("2.35.2".to_owned())
386        );
387        assert_eq!(
388            latest_version(br#"[{"name":"2.9.0"},{"name":"2.10.0"}]"#),
389            Some("2.10.0".to_owned())
390        );
391        assert_eq!(
392            latest_version(br#"[{"name":"v1.2.3"}]"#),
393            Some("1.2.3".to_owned())
394        );
395        assert_eq!(latest_version(b"[]"), None);
396        assert_eq!(latest_version(br#"[{"sha":"abc"}]"#), None);
397    }
398
399    #[test]
400    fn a_major_only_pin_is_current_within_its_major() {
401        assert!(is_current("0.3.160", "0.3.160"));
402        assert!(!is_current("0.3.160", "0.3.170"));
403        assert!(is_current("4", "4.3.1"));
404        assert!(!is_current("4", "5.0.0"));
405    }
406
407    /// The complete `rk.versions-check/2` shape, held by snapshot.
408    #[test]
409    fn the_versions_check_schema_snapshot_holds() {
410        let report = Report {
411            schema: "rk.versions-check/2",
412            pins: vec![PinResult {
413                tool: "release-plz".into(),
414                pinned: "0.3.160".into(),
415                result: "update-available",
416                available: Some("0.3.170".into()),
417                commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
418                ref_class: Some("moving-minor-tag".into()),
419                ref_result: Some("ref-unmoved"),
420                ref_commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
421            }],
422        };
423        assert_eq!(
424            serde_json::to_string(&report).expect("a report serializes"),
425            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"}]}"#
426        );
427    }
428}