xtask_lib/tasks/util/
github.rs

1//! GitHub Releases API helpers.
2//!
3//! Fetches release metadata and returns typed asset information including the
4//! download URL and optional SHA-256 digest for checksum verification.
5//!
6//! The `digest` field in the GitHub API response has the format
7//! `sha256:<hex>`.  [`Asset::sha256`] strips the prefix so the hex string can
8//! be passed directly to [`super::http::download_verified`].
9//!
10//! ## Authentication
11//!
12//! All requests use a shared [`reqwest::blocking::Client`] that sets the
13//! required `User-Agent` header and, when `GH_TOKEN` or `GITHUB_TOKEN` is
14//! present in the environment, an `Authorization: Bearer` header.  This
15//! avoids 403 rate-limit errors in GitHub Actions where the token is always
16//! available.
17
18use std::path::Path;
19
20use anyhow::{Context, Result};
21use serde::Deserialize;
22
23/// Builds a `reqwest` blocking client with the GitHub API `User-Agent` and,
24/// when available, an `Authorization: Bearer` token from the environment.
25///
26/// Checks `GH_TOKEN` first, then `GITHUB_TOKEN`.
27fn client() -> Result<reqwest::blocking::Client> {
28    let mut builder = reqwest::blocking::Client::builder().user_agent("cargo-xtask/cadmus");
29
30    if let Some(token) = std::env::var("GH_TOKEN")
31        .ok()
32        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
33    {
34        let mut auth = reqwest::header::HeaderMap::new();
35        auth.insert(
36            reqwest::header::AUTHORIZATION,
37            reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
38                .context("invalid token value for Authorization header")?,
39        );
40        builder = builder.default_headers(auth);
41    }
42
43    builder.build().context("failed to build HTTP client")
44}
45
46/// A single asset from a GitHub release.
47#[derive(Debug, Deserialize)]
48pub struct Asset {
49    /// The direct download URL.
50    pub browser_download_url: String,
51    /// The asset filename.
52    pub name: String,
53    /// The digest in `sha256:<hex>` format, if provided by GitHub.
54    pub digest: Option<String>,
55}
56
57impl Asset {
58    /// Returns the SHA-256 hex digest, stripping the `sha256:` prefix.
59    ///
60    /// Returns `None` if the `digest` field is absent or has an unexpected
61    /// format.
62    pub fn sha256(&self) -> Option<&str> {
63        self.digest
64            .as_deref()
65            .and_then(|d| d.strip_prefix("sha256:"))
66    }
67}
68
69/// The subset of a GitHub release response that we need.
70#[derive(Debug, Deserialize)]
71struct Release {
72    assets: Vec<Asset>,
73}
74
75/// Fetches the named asset from a GitHub release.
76///
77/// `repo` must be in `owner/name` format (e.g. `"pgaskin/NickelMenu"`).
78/// `tag` must include the `v` prefix if the release uses one (e.g. `"v0.6.0"`).
79///
80/// # Errors
81///
82/// Returns an error if the HTTP request fails, the response cannot be parsed,
83/// or no asset with the given name exists in the release.
84pub fn fetch_release_asset(repo: &str, tag: &str, asset_name: &str) -> Result<Asset> {
85    let url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
86    fetch_release_from_url(&url)?
87        .assets
88        .into_iter()
89        .find(|a| a.name == asset_name)
90        .with_context(|| format!("asset '{asset_name}' not found in release {tag} of {repo}"))
91}
92
93/// Fetches the named asset from the latest GitHub release.
94///
95/// `repo` must be in `owner/name` format (e.g. `"OGKevin/cadmus"`).
96///
97/// Currently unused because asset directories are sourced from the Plato
98/// release zip as a workaround (see [issue #64]).  When that issue is resolved
99/// and Cadmus builds these directories from source, the entire
100/// `download_assets` task will be removed and this function along with it.
101///
102/// [issue #64]: https://github.com/OGKevin/cadmus/issues/64
103///
104/// # Errors
105///
106/// Returns an error if the HTTP request fails, the response cannot be parsed,
107/// or no asset with the given name exists in the latest release.
108#[allow(dead_code)]
109pub fn fetch_latest_release_asset(repo: &str, asset_name: &str) -> Result<Asset> {
110    let url = format!("https://api.github.com/repos/{repo}/releases/latest");
111    fetch_release_from_url(&url)?
112        .assets
113        .into_iter()
114        .find(|a| a.name == asset_name)
115        .with_context(|| format!("asset '{asset_name}' not found in latest release of {repo}"))
116}
117
118/// Fetches the first asset whose name starts with `prefix` from the latest
119/// GitHub release.
120///
121/// Useful when the asset name includes a version number
122/// (e.g. `"plato-0.9.45.zip"`).
123///
124/// `repo` must be in `owner/name` format (e.g. `"baskerville/plato"`).
125///
126/// Currently unused — asset downloads use a pinned version via
127/// [`fetch_release_asset`] instead.  Kept for potential future use.
128///
129/// # Errors
130///
131/// Returns an error if the HTTP request fails, the response cannot be parsed,
132/// or no asset matching the prefix exists in the latest release.
133#[allow(dead_code)]
134pub fn fetch_latest_release_asset_by_prefix(repo: &str, prefix: &str) -> Result<Asset> {
135    let url = format!("https://api.github.com/repos/{repo}/releases/latest");
136    fetch_release_from_url(&url)?
137        .assets
138        .into_iter()
139        .find(|a| a.name.starts_with(prefix))
140        .with_context(|| {
141            format!("no asset with prefix '{prefix}' found in latest release of {repo}")
142        })
143}
144
145/// Downloads a release asset to `dest`, using GitHub authentication when available.
146///
147/// Uses the same authenticated client as the API calls so that assets from
148/// repositories that require authentication can be downloaded without a
149/// separate token setup.
150///
151/// # Errors
152///
153/// Returns an error if the download or write fails.
154pub fn download_asset(asset: &Asset, dest: &Path) -> Result<()> {
155    if let Some(parent) = dest.parent() {
156        std::fs::create_dir_all(parent)
157            .with_context(|| format!("failed to create parent directory for {}", dest.display()))?;
158    }
159
160    let bytes = client()?
161        .get(&asset.browser_download_url)
162        .send()
163        .with_context(|| format!("HTTP request failed for {}", asset.browser_download_url))?
164        .error_for_status()
165        .with_context(|| {
166            format!(
167                "server returned error status for {}",
168                asset.browser_download_url
169            )
170        })?
171        .bytes()
172        .with_context(|| {
173            format!(
174                "failed to read response body from {}",
175                asset.browser_download_url
176            )
177        })?;
178
179    std::fs::write(dest, &bytes)
180        .with_context(|| format!("failed to write downloaded file to {}", dest.display()))
181}
182
183fn fetch_release_from_url(url: &str) -> Result<Release> {
184    client()?
185        .get(url)
186        .send()
187        .with_context(|| format!("HTTP request failed for {url}"))?
188        .error_for_status()
189        .with_context(|| format!("server returned error status for {url}"))?
190        .json()
191        .with_context(|| format!("failed to parse GitHub API response from {url}"))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn sample_release_json(name: &str, digest: Option<&str>) -> String {
199        let digest_field = match digest {
200            Some(d) => format!(r#", "digest": "{d}""#),
201            None => String::new(),
202        };
203        format!(
204            r#"{{
205                "assets": [
206                    {{
207                        "browser_download_url": "https://github.com/example/releases/download/v1.0/{name}",
208                        "name": "{name}"{digest_field}
209                    }}
210                ]
211            }}"#
212        )
213    }
214
215    #[test]
216    fn asset_sha256_strips_prefix() {
217        let asset = Asset {
218            browser_download_url: String::new(),
219            name: String::new(),
220            digest: Some("sha256:deadbeef".to_owned()),
221        };
222        assert_eq!(asset.sha256(), Some("deadbeef"));
223    }
224
225    #[test]
226    fn asset_sha256_returns_none_when_absent() {
227        let asset = Asset {
228            browser_download_url: String::new(),
229            name: String::new(),
230            digest: None,
231        };
232        assert!(asset.sha256().is_none());
233    }
234
235    #[test]
236    fn asset_sha256_returns_none_for_unexpected_prefix() {
237        let asset = Asset {
238            browser_download_url: String::new(),
239            name: String::new(),
240            digest: Some("md5:deadbeef".to_owned()),
241        };
242        assert!(asset.sha256().is_none());
243    }
244
245    #[test]
246    fn release_deserializes_asset_with_digest() {
247        let json = sample_release_json("foo.tgz", Some("sha256:abc123"));
248        let release: Release = serde_json::from_str(&json).unwrap();
249        assert_eq!(release.assets.len(), 1);
250        assert_eq!(release.assets[0].name, "foo.tgz");
251        assert_eq!(release.assets[0].sha256(), Some("abc123"));
252    }
253
254    #[test]
255    fn release_deserializes_asset_without_digest() {
256        let json = sample_release_json("foo.tgz", None);
257        let release: Release = serde_json::from_str(&json).unwrap();
258        assert!(release.assets[0].sha256().is_none());
259    }
260
261    #[test]
262    fn release_deserializes_download_url() {
263        let json = sample_release_json("foo.tgz", None);
264        let release: Release = serde_json::from_str(&json).unwrap();
265        assert!(release.assets[0].browser_download_url.contains("foo.tgz"));
266    }
267
268    #[test]
269    fn release_deserializes_empty_assets() {
270        let json = r#"{"assets": []}"#;
271        let release: Release = serde_json::from_str(json).unwrap();
272        assert!(release.assets.is_empty());
273    }
274}