Skip to main content

opys_dev/
github.rs

1//! Shared GitHub Releases helpers.
2//!
3//! Several loaders — GraalVM CE here, and the forge family once it ports —
4//! resolve their versions straight off GitHub Releases. Listing the releases
5//! and reading an asset's digest live here so no resolver carries its own
6//! copy.
7//!
8//! `@opys/dev`'s `github.ts` is still the JS spelling of this while the
9//! forge-family loaders remain in TypeScript; it retires with them.
10
11use serde::{Deserialize, Serialize};
12
13use crate::http::{self, HttpError};
14use crate::pin::{pin_url, PinError, Pinned};
15
16/// Where the Releases API lives. Overridable per call so a resolver can point
17/// at a GitHub Enterprise host or a mirror, matching the `api_base` the
18/// Adoptium and Azul resolvers already take.
19pub const GITHUB_API_BASE: &str = "https://api.github.com";
20
21/// A single asset on a release, mirroring the `/releases` API shape.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct GitHubAsset {
24    pub name: String,
25    pub size: u64,
26    pub browser_download_url: String,
27    /// `sha256:<hex>` when present (GitHub added it in 2024); older releases
28    /// have no digest at all.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub digest: Option<String>,
31}
32
33/// A single release entry from the `/releases` API.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct GitHubRelease {
36    pub tag_name: String,
37    #[serde(default)]
38    pub prerelease: bool,
39    #[serde(default)]
40    pub draft: bool,
41    #[serde(default)]
42    pub published_at: String,
43    #[serde(default)]
44    pub assets: Vec<GitHubAsset>,
45}
46
47/// Which release to take off the listing.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ReleaseSelector<'a> {
50    /// Newest non-prerelease.
51    Latest,
52    /// Newest, prereleases included.
53    Prerelease,
54    /// Exact `tag_name` match.
55    Tag(&'a str),
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum GitHubError {
60    #[error(transparent)]
61    Http(#[from] HttpError),
62    #[error("GitHub API {status} listing {repo} releases")]
63    Api { status: u16, repo: String },
64    #[error("GitHub returned an unreadable release listing for {repo}: {source}")]
65    Malformed {
66        repo: String,
67        #[source]
68        source: serde_json::Error,
69    },
70    #[error("No {stability}GitHub release in {repo}{filtered}")]
71    NoRelease {
72        /// `"stable "` or empty — the JS spelling collapses the gap when the
73        /// selector allows prereleases.
74        stability: &'static str,
75        repo: String,
76        /// `" matching the loader filter"` or empty.
77        filtered: &'static str,
78    },
79    #[error("GitHub release '{tag}' not found in {repo}. Available: {available}")]
80    NoTag {
81        tag: String,
82        repo: String,
83        available: String,
84    },
85}
86
87/// List all releases for `repo` (`owner/name`), newest first — a single page
88/// of up to 100. `token` raises the rate limit.
89pub fn list_github_releases(
90    api_base: &str,
91    repo: &str,
92    token: Option<&str>,
93) -> Result<Vec<GitHubRelease>, GitHubError> {
94    let url = format!("{api_base}/repos/{repo}/releases?per_page=100");
95    let auth = token.map(|t| format!("Bearer {t}"));
96    let mut headers: Vec<(&str, &str)> = vec![
97        ("Accept", "application/vnd.github+json"),
98        ("X-GitHub-Api-Version", "2022-11-28"),
99    ];
100    if let Some(auth) = auth.as_deref() {
101        headers.push(("Authorization", auth));
102    }
103
104    let res = http::get(&url, &headers)?;
105    if !res.ok() {
106        return Err(GitHubError::Api {
107            status: res.status,
108            repo: repo.to_owned(),
109        });
110    }
111    serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
112        repo: repo.to_owned(),
113        source,
114    })
115}
116
117/// Fetch the one release tagged `tag`, by address rather than by listing.
118///
119/// The listing is a single page of a hundred, which is every release only for
120/// a repository that has not made its hundred-and-first. A caller that already
121/// knows the tag should not depend on how far back it is.
122pub fn fetch_github_release(
123    api_base: &str,
124    repo: &str,
125    tag: &str,
126    token: Option<&str>,
127) -> Result<GitHubRelease, GitHubError> {
128    let url = format!("{api_base}/repos/{repo}/releases/tags/{tag}");
129    let auth = token.map(|t| format!("Bearer {t}"));
130    let mut headers: Vec<(&str, &str)> = vec![
131        ("Accept", "application/vnd.github+json"),
132        ("X-GitHub-Api-Version", "2022-11-28"),
133    ];
134    if let Some(auth) = auth.as_deref() {
135        headers.push(("Authorization", auth));
136    }
137
138    let res = http::get(&url, &headers)?;
139    if res.status == 404 {
140        return Err(GitHubError::NoTag {
141            tag: tag.to_owned(),
142            repo: repo.to_owned(),
143            available: "not listed".to_owned(),
144        });
145    }
146    if !res.ok() {
147        return Err(GitHubError::Api {
148            status: res.status,
149            repo: repo.to_owned(),
150        });
151    }
152    serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
153        repo: repo.to_owned(),
154        source,
155    })
156}
157
158/// The hex sha256 behind an asset's `digest` field, when GitHub computed one.
159pub fn github_asset_sha256(asset: &GitHubAsset) -> Option<&str> {
160    asset.digest.as_deref()?.strip_prefix("sha256:")
161}
162
163/// An asset's size and sha256, whatever it takes to know them.
164///
165/// GitHub has computed a digest for every asset uploaded since 2024, and for
166/// those this is a field read. For an older one the asset is downloaded here
167/// and hashed — so anything resolved off a release comes out pinned, and the
168/// size is the one that was read rather than the one the listing claims.
169pub fn pin_github_asset(asset: &GitHubAsset) -> Result<Pinned, PinError> {
170    match github_asset_sha256(asset) {
171        Some(digest) => Ok(Pinned {
172            size: asset.size,
173            sha256: digest.to_owned(),
174        }),
175        None => pin_url(&asset.browser_download_url, &[]),
176    }
177}
178
179/// Apply a selector to an already-narrowed candidate list. Pure — the
180/// caller has done the draft/filter pass.
181pub fn select_github_release<'r>(
182    releases: &'r [GitHubRelease],
183    selector: &ReleaseSelector<'_>,
184) -> Option<&'r GitHubRelease> {
185    match selector {
186        ReleaseSelector::Latest => releases.iter().find(|r| !r.prerelease),
187        ReleaseSelector::Prerelease => releases.first(),
188        ReleaseSelector::Tag(tag) => releases.iter().find(|r| r.tag_name == *tag),
189    }
190}
191
192/// Fetch `repo`'s releases and pick the one matching `selector`. Drafts are
193/// always skipped; `filter` further narrows candidates first — use it when
194/// the loader needs specific assets present, so `Latest` falls through to the
195/// newest release that actually qualifies.
196pub fn pick_github_release(
197    api_base: &str,
198    repo: &str,
199    selector: &ReleaseSelector<'_>,
200    token: Option<&str>,
201    filter: Option<&dyn Fn(&GitHubRelease) -> bool>,
202) -> Result<GitHubRelease, GitHubError> {
203    let candidates: Vec<GitHubRelease> = list_github_releases(api_base, repo, token)?
204        .into_iter()
205        .filter(|r| !r.draft)
206        .filter(|r| match filter {
207            Some(keep) => keep(r),
208            None => true,
209        })
210        .collect();
211
212    if let Some(picked) = select_github_release(&candidates, selector) {
213        return Ok(picked.clone());
214    }
215
216    let filtered = if filter.is_some() {
217        " matching the loader filter"
218    } else {
219        ""
220    };
221    match selector {
222        ReleaseSelector::Latest => Err(GitHubError::NoRelease {
223            stability: "stable ",
224            repo: repo.to_owned(),
225            filtered,
226        }),
227        ReleaseSelector::Prerelease => Err(GitHubError::NoRelease {
228            stability: "",
229            repo: repo.to_owned(),
230            filtered,
231        }),
232        ReleaseSelector::Tag(tag) => {
233            let tags: Vec<&str> = candidates
234                .iter()
235                .take(5)
236                .map(|r| r.tag_name.as_str())
237                .collect();
238            let mut available = tags.join(", ");
239            if candidates.len() > 5 {
240                available.push_str(", …");
241            }
242            Err(GitHubError::NoTag {
243                tag: (*tag).to_owned(),
244                repo: repo.to_owned(),
245                available,
246            })
247        }
248    }
249}