Skip to main content

greentic_dev/
release_github_source.rs

1//! Resolve the dev toolchain from GitHub releases instead of crates.io.
2//!
3//! `release snapshot` pins every package to the newest dev version on
4//! crates.io. On 2026-09-08 crates.io locked the account that publishes every
5//! Greentic crate, and from then on every dev build still attached its archives
6//! to a GitHub release while none of them reached the index — so the dev channel
7//! froze on the versions it pinned the day before, with fixes merged and built
8//! and unreachable through `gtc install`.
9//!
10//! `--source github-releases` reads the same builds from where they actually
11//! landed. For each toolchain package it picks the newest release in the dev
12//! lane whose archives are complete, and records each archive's URL and sha256
13//! as the package's `artifacts`, which gtc installs directly instead of going
14//! through `cargo binstall`.
15//!
16//! Two things crates.io used to do for us have to be done here instead:
17//!
18//! - **Yanking.** A GitHub release cannot be yanked. The 2026-09-06 worm
19//!   rewrote branch tips with look-alike commits (LOCAL committer, a
20//!   `.vscode/tasks.json` dropper, JavaScript posing as `public/fonts/fa-solid-*`
21//!   files), and CI built releases from some of them. So the commit a release
22//!   was tagged at must carry GitHub's own verified committer and none of those
23//!   files. A release that fails that check is REFUSED, never skipped: quietly
24//!   falling back to an older build would publish a channel nobody chose.
25//! - **Lane filtering.** Only plain `MAJOR.MINOR.RUN` versions in the release's
26//!   own lane count. greentic-pack also tags `v1.2.0-research.N` in the same
27//!   minor, and those are not dev builds.
28
29use anyhow::{Context, Result, bail};
30use semver::Version;
31use serde::Deserialize;
32
33use crate::release_cmd::{
34    PackageArtifactRef, TOOLCHAIN_MANIFEST_SCHEMA, TOOLCHAIN_NAME, ToolchainManifest,
35    ToolchainPackage, ambient_github_token, check_release_assets, lane_of,
36    manifest_bins_for_source, manifest_crate_name_for_source,
37};
38use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
39
40const GITHUB_API_BASE: &str = "https://api.github.com";
41const RELEASE_OWNER: &str = "greenticai";
42const DEV_CHANNEL: &str = "dev";
43/// Pages of 100 releases read before concluding a repository has no dev build.
44/// Releases are listed newest first, so the current dev build is on page one in
45/// practice; the extra pages only matter for a repository whose recent releases
46/// are all on another lane.
47const MAX_RELEASE_PAGES: u32 = 5;
48
49/// One GitHub release, reduced to what resolution needs.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct RepoRelease {
52    pub tag: String,
53    pub draft: bool,
54    pub assets: Vec<RepoReleaseAsset>,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct RepoReleaseAsset {
59    pub name: String,
60    pub url: Option<String>,
61    /// As GitHub reports it: `sha256:<hex>`.
62    pub digest: Option<String>,
63}
64
65/// Where the commit a release is tagged at came from.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct CommitProvenance {
68    pub sha: String,
69    pub committer: String,
70    pub verified: bool,
71    /// Every path in the commit's tree.
72    pub paths: Vec<String>,
73    /// GitHub truncates very large recursive trees; a truncated listing cannot
74    /// prove a file is absent.
75    pub tree_truncated: bool,
76}
77
78/// Reads releases and commit provenance. Injected so resolution is testable
79/// without the network.
80pub trait DevReleaseSource {
81    /// Releases of `repo`, newest first, one page at a time (`page` starts at 1).
82    fn releases_page(&self, repo: &str, page: u32) -> Result<Vec<RepoRelease>>;
83    fn commit_provenance(&self, repo: &str, tag: &str) -> Result<CommitProvenance>;
84}
85
86/// A package resolved from its GitHub release.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct ResolvedDevPackage {
89    pub version: String,
90    pub artifacts: Vec<PackageArtifactRef>,
91}
92
93/// Build a dev-channel manifest for gtc `release` from GitHub releases.
94pub fn snapshot_manifest_from_github_releases(
95    release: &str,
96    source: &dyn DevReleaseSource,
97    created_at: Option<String>,
98) -> Result<ToolchainManifest> {
99    let lane = lane_of(release).with_context(|| {
100        format!("gtc release `{release}` is not MAJOR.MINOR.PATCH, so it names no lane")
101    })?;
102    let mut packages = Vec::new();
103    for spec in GREENTIC_TOOLCHAIN_PACKAGES {
104        let crate_in_manifest = manifest_crate_name_for_source(DEV_CHANNEL, spec.crate_name);
105        let resolved = resolve_dev_package(source, spec.crate_name, &crate_in_manifest, lane)?;
106        packages.push(ToolchainPackage {
107            crate_name: crate_in_manifest,
108            bins: manifest_bins_for_source(DEV_CHANNEL, spec.bins),
109            version: resolved.version,
110            artifacts: Some(resolved.artifacts),
111        });
112    }
113    Ok(ToolchainManifest {
114        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
115        toolchain: TOOLCHAIN_NAME.to_string(),
116        version: release.to_string(),
117        channel: Some(DEV_CHANNEL.to_string()),
118        created_at,
119        packages,
120        extension_packs: None,
121        components: None,
122        gtc: None,
123    })
124}
125
126/// Pick the release to pin for one package and name its archives.
127///
128/// `repo` is the base crate name (the repository), `crate_in_manifest` the
129/// `-dev` mirror whose archives the release carries.
130pub fn resolve_dev_package(
131    source: &dyn DevReleaseSource,
132    repo: &str,
133    crate_in_manifest: &str,
134    lane: (u64, u64),
135) -> Result<ResolvedDevPackage> {
136    let mut candidates: Vec<(Version, RepoRelease)> = Vec::new();
137    for page in 1..=MAX_RELEASE_PAGES {
138        let releases = source
139            .releases_page(repo, page)
140            .with_context(|| format!("failed to list releases of {RELEASE_OWNER}/{repo}"))?;
141        if releases.is_empty() {
142            break;
143        }
144        candidates.extend(releases.into_iter().filter_map(|release| {
145            let version = dev_lane_version(&release, lane)?;
146            Some((version, release))
147        }));
148        if !candidates.is_empty() {
149            break;
150        }
151    }
152    candidates.sort_by(|left, right| right.0.cmp(&left.0));
153
154    let mut incomplete = Vec::new();
155    for (version, release) in candidates {
156        let version_text = version.to_string();
157        let names: Vec<String> = release
158            .assets
159            .iter()
160            .map(|asset| asset.name.clone())
161            .collect();
162        if let Err(reason) = check_release_assets(&names, &version_text) {
163            // Still uploading, or a build that never finished: not a dev build
164            // anyone can install yet, so the next one down is the newest that is.
165            incomplete.push(format!("{}: {reason}", release.tag));
166            continue;
167        }
168        let artifacts = artifacts_for(&release, crate_in_manifest, &version_text)?;
169        let provenance = source
170            .commit_provenance(repo, &release.tag)
171            .with_context(|| format!("failed to read the commit behind {repo} {}", release.tag))?;
172        refuse_unclean_commit(repo, &release.tag, &provenance)?;
173        return Ok(ResolvedDevPackage {
174            version: version_text,
175            artifacts,
176        });
177    }
178    if incomplete.is_empty() {
179        bail!(
180            "{RELEASE_OWNER}/{repo} has no {}.{}.<run> dev release",
181            lane.0,
182            lane.1
183        );
184    }
185    bail!(
186        "{RELEASE_OWNER}/{repo} has no complete {}.{}.<run> dev release:\n  {}",
187        lane.0,
188        lane.1,
189        incomplete.join("\n  ")
190    )
191}
192
193/// The version a release carries when it is a dev build in `lane`.
194fn dev_lane_version(release: &RepoRelease, lane: (u64, u64)) -> Option<Version> {
195    if release.draft {
196        return None;
197    }
198    let version = Version::parse(release.tag.strip_prefix('v')?).ok()?;
199    let in_lane = (version.major, version.minor) == lane;
200    (in_lane && version.pre.is_empty() && version.build.is_empty()).then_some(version)
201}
202
203/// One artifact per target, from `<crate>-v<version>-<target>.tgz|.zip`.
204fn artifacts_for(
205    release: &RepoRelease,
206    crate_in_manifest: &str,
207    version: &str,
208) -> Result<Vec<PackageArtifactRef>> {
209    let prefix = format!("{crate_in_manifest}-v{version}-");
210    let mut artifacts = Vec::new();
211    for asset in &release.assets {
212        let Some(rest) = asset.name.strip_prefix(&prefix) else {
213            continue;
214        };
215        let Some(target) = rest
216            .strip_suffix(".tgz")
217            .or_else(|| rest.strip_suffix(".zip"))
218        else {
219            continue;
220        };
221        let (Some(url), Some(digest)) = (asset.url.as_deref(), asset.digest.as_deref()) else {
222            bail!(
223                "{} asset {} has no download URL or digest in the GitHub API response",
224                release.tag,
225                asset.name
226            );
227        };
228        let Some(sha256) = digest.strip_prefix("sha256:") else {
229            bail!(
230                "{} asset {} reports a non-sha256 digest `{digest}`",
231                release.tag,
232                asset.name
233            );
234        };
235        if artifacts
236            .iter()
237            .any(|existing: &PackageArtifactRef| existing.target == target)
238        {
239            bail!(
240                "{} carries more than one {crate_in_manifest} archive for {target}",
241                release.tag
242            );
243        }
244        artifacts.push(PackageArtifactRef {
245            target: target.to_string(),
246            url: url.to_string(),
247            sha256: sha256.to_string(),
248        });
249    }
250    if artifacts.is_empty() {
251        bail!(
252            "{} carries no {crate_in_manifest}-v{version}-<target> archive",
253            release.tag
254        );
255    }
256    artifacts.sort_by(|left, right| left.target.cmp(&right.target));
257    Ok(artifacts)
258}
259
260/// Paths the 2026-09-06 worm added to the commits it forged.
261fn worm_marker(path: &str) -> bool {
262    path == ".vscode/tasks.json"
263        || path.ends_with("/.vscode/tasks.json")
264        || path.contains("public/fonts/fa-solid-")
265}
266
267fn refuse_unclean_commit(repo: &str, tag: &str, provenance: &CommitProvenance) -> Result<()> {
268    let mut problems = Vec::new();
269    if provenance.committer != "GitHub" || !provenance.verified {
270        problems.push(format!(
271            "committer is `{}` (verified: {}), not GitHub's own verified committer",
272            provenance.committer, provenance.verified
273        ));
274    }
275    if provenance.tree_truncated {
276        problems
277            .push("the commit's tree listing is truncated, so it cannot be checked".to_string());
278    }
279    let markers: Vec<&str> = provenance
280        .paths
281        .iter()
282        .map(String::as_str)
283        .filter(|path| worm_marker(path))
284        .collect();
285    if !markers.is_empty() {
286        problems.push(format!("the tree carries {}", markers.join(", ")));
287    }
288    if problems.is_empty() {
289        return Ok(());
290    }
291    bail!(
292        "refusing {RELEASE_OWNER}/{repo} {tag} (commit {}): {}. A GitHub release cannot be yanked, \
293         so a build from a commit that does not look like one GitHub made is never pinned — \
294         inspect it by hand and publish a clean build instead.",
295        provenance.sha,
296        problems.join("; ")
297    )
298}
299
300/// Production source: the GitHub REST API, with the ambient token when one is
301/// available (these repositories are public; the token lifts the rate limit).
302pub struct GithubDevReleaseSource {
303    base_url: String,
304    token: Option<String>,
305    client: reqwest::blocking::Client,
306}
307
308impl GithubDevReleaseSource {
309    pub fn new(raw_token: Option<&str>) -> Result<Self> {
310        let client = reqwest::blocking::Client::builder()
311            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
312            .build()
313            .context("failed to build GitHub API client")?;
314        Ok(Self {
315            base_url: GITHUB_API_BASE.to_string(),
316            token: ambient_github_token(raw_token),
317            client,
318        })
319    }
320
321    fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
322        let url = format!("{}{path}", self.base_url);
323        let mut request = self
324            .client
325            .get(&url)
326            .header(reqwest::header::ACCEPT, "application/vnd.github+json");
327        if let Some(token) = &self.token {
328            request = request.bearer_auth(token);
329        }
330        let response = request
331            .send()
332            .with_context(|| format!("failed to GET {url}"))?;
333        let status = response.status();
334        let body = response
335            .text()
336            .with_context(|| format!("failed to read body of {url}"))?;
337        if !status.is_success() {
338            bail!("GitHub API GET {url} returned {status}: {body}");
339        }
340        serde_json::from_str(&body).with_context(|| format!("failed to parse {url}"))
341    }
342}
343
344#[derive(Deserialize)]
345struct ApiRelease {
346    tag_name: String,
347    #[serde(default)]
348    draft: bool,
349    #[serde(default)]
350    assets: Vec<ApiAsset>,
351}
352
353#[derive(Deserialize)]
354struct ApiAsset {
355    name: String,
356    #[serde(default)]
357    browser_download_url: Option<String>,
358    #[serde(default)]
359    digest: Option<String>,
360}
361
362#[derive(Deserialize)]
363struct ApiCommit {
364    sha: String,
365    commit: ApiCommitDetail,
366}
367
368#[derive(Deserialize)]
369struct ApiCommitDetail {
370    committer: ApiCommitter,
371    #[serde(default)]
372    verification: Option<ApiVerification>,
373}
374
375#[derive(Deserialize)]
376struct ApiCommitter {
377    #[serde(default)]
378    name: String,
379}
380
381#[derive(Deserialize)]
382struct ApiVerification {
383    #[serde(default)]
384    verified: bool,
385}
386
387#[derive(Deserialize)]
388struct ApiTree {
389    #[serde(default)]
390    truncated: bool,
391    #[serde(default)]
392    tree: Vec<ApiTreeEntry>,
393}
394
395#[derive(Deserialize)]
396struct ApiTreeEntry {
397    path: String,
398}
399
400impl DevReleaseSource for GithubDevReleaseSource {
401    fn releases_page(&self, repo: &str, page: u32) -> Result<Vec<RepoRelease>> {
402        let releases: Vec<ApiRelease> = self.get_json(&format!(
403            "/repos/{RELEASE_OWNER}/{repo}/releases?per_page=100&page={page}"
404        ))?;
405        Ok(releases
406            .into_iter()
407            .map(|release| RepoRelease {
408                tag: release.tag_name,
409                draft: release.draft,
410                assets: release
411                    .assets
412                    .into_iter()
413                    .map(|asset| RepoReleaseAsset {
414                        name: asset.name,
415                        url: asset.browser_download_url,
416                        digest: asset.digest,
417                    })
418                    .collect(),
419            })
420            .collect())
421    }
422
423    fn commit_provenance(&self, repo: &str, tag: &str) -> Result<CommitProvenance> {
424        let commit: ApiCommit =
425            self.get_json(&format!("/repos/{RELEASE_OWNER}/{repo}/commits/{tag}"))?;
426        let tree: ApiTree = self.get_json(&format!(
427            "/repos/{RELEASE_OWNER}/{repo}/git/trees/{}?recursive=1",
428            commit.sha
429        ))?;
430        Ok(CommitProvenance {
431            sha: commit.sha,
432            committer: commit.commit.committer.name,
433            verified: commit
434                .commit
435                .verification
436                .is_some_and(|verification| verification.verified),
437            paths: tree.tree.into_iter().map(|entry| entry.path).collect(),
438            tree_truncated: tree.truncated,
439        })
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::collections::BTreeMap;
447
448    const LANE: (u64, u64) = (1, 2);
449
450    fn digest(ch: char) -> String {
451        format!("sha256:{}", ch.to_string().repeat(64))
452    }
453
454    fn complete_release(crate_name: &str, version: &str) -> RepoRelease {
455        let mut assets = Vec::new();
456        for target in ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"] {
457            let name = format!("{crate_name}-v{version}-{target}.tgz");
458            assets.push(RepoReleaseAsset {
459                url: Some(format!(
460                    "https://github.com/greenticai/x/releases/download/v{version}/{name}"
461                )),
462                digest: Some(digest('a')),
463                name: name.clone(),
464            });
465            assets.push(RepoReleaseAsset {
466                name: format!("{name}.sha256"),
467                url: Some("https://example.invalid/sidecar".to_string()),
468                digest: Some(digest('b')),
469            });
470        }
471        RepoRelease {
472            tag: format!("v{version}"),
473            draft: false,
474            assets,
475        }
476    }
477
478    fn clean() -> CommitProvenance {
479        CommitProvenance {
480            sha: "c".repeat(40),
481            committer: "GitHub".to_string(),
482            verified: true,
483            paths: vec!["Cargo.toml".to_string(), "src/main.rs".to_string()],
484            tree_truncated: false,
485        }
486    }
487
488    struct FakeSource {
489        releases: Vec<RepoRelease>,
490        provenance: BTreeMap<String, CommitProvenance>,
491    }
492
493    impl DevReleaseSource for FakeSource {
494        fn releases_page(&self, _repo: &str, page: u32) -> Result<Vec<RepoRelease>> {
495            Ok(if page == 1 {
496                self.releases.clone()
497            } else {
498                Vec::new()
499            })
500        }
501
502        fn commit_provenance(&self, _repo: &str, tag: &str) -> Result<CommitProvenance> {
503            Ok(self.provenance.get(tag).cloned().unwrap_or_else(clean))
504        }
505    }
506
507    fn source(releases: Vec<RepoRelease>) -> FakeSource {
508        FakeSource {
509            releases,
510            provenance: BTreeMap::new(),
511        }
512    }
513
514    #[test]
515    fn picks_the_newest_complete_dev_build_in_the_lane() {
516        let crate_name = "greentic-start-dev";
517        let mut uploading = complete_release(crate_name, "1.2.40000000000");
518        uploading
519            .assets
520            .retain(|asset| !asset.name.ends_with(".sha256"));
521        let mut draft = complete_release(crate_name, "1.2.50000000000");
522        draft.draft = true;
523        let fake = source(vec![
524            complete_release(crate_name, "1.3.60000000000"),
525            complete_release(crate_name, "1.2.0-research.4"),
526            draft,
527            uploading,
528            complete_release(crate_name, "1.2.34207645334"),
529            complete_release(crate_name, "1.2.34019234621"),
530        ]);
531
532        let resolved =
533            resolve_dev_package(&fake, "greentic-start", crate_name, LANE).expect("resolved");
534
535        assert_eq!(resolved.version, "1.2.34207645334");
536        assert_eq!(
537            resolved
538                .artifacts
539                .iter()
540                .map(|artifact| artifact.target.as_str())
541                .collect::<Vec<_>>(),
542            vec!["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]
543        );
544        assert!(
545            resolved
546                .artifacts
547                .iter()
548                .all(|artifact| artifact.sha256 == "a".repeat(64))
549        );
550        assert!(
551            resolved
552                .artifacts
553                .iter()
554                .all(|artifact| !artifact.url.ends_with(".sha256")),
555            "a checksum sidecar is not an archive"
556        );
557    }
558
559    #[test]
560    fn refuses_rather_than_skips_a_release_built_from_a_forged_commit() {
561        let crate_name = "greentic-runner-dev";
562        let mut forged = clean();
563        forged.committer = "greentic-ci[bot]".to_string();
564        forged.verified = false;
565        forged.paths.push(".vscode/tasks.json".to_string());
566        forged
567            .paths
568            .push("public/fonts/fa-solid-400.woff2".to_string());
569        let fake = FakeSource {
570            releases: vec![
571                complete_release(crate_name, "1.2.2"),
572                complete_release(crate_name, "1.2.1"),
573            ],
574            provenance: BTreeMap::from([("v1.2.2".to_string(), forged)]),
575        };
576
577        let err = resolve_dev_package(&fake, "greentic-runner", crate_name, LANE)
578            .expect_err("a forged newest build must not fall back to v1.2.1");
579        let message = format!("{err:#}");
580        assert!(message.contains("v1.2.2"), "{message}");
581        assert!(message.contains("greentic-ci[bot]"), "{message}");
582        assert!(message.contains(".vscode/tasks.json"), "{message}");
583    }
584
585    #[test]
586    fn refuses_a_truncated_tree_it_cannot_check() {
587        let crate_name = "greentic-mcp-dev";
588        let mut truncated = clean();
589        truncated.tree_truncated = true;
590        let fake = FakeSource {
591            releases: vec![complete_release(crate_name, "1.2.9")],
592            provenance: BTreeMap::from([("v1.2.9".to_string(), truncated)]),
593        };
594        assert!(resolve_dev_package(&fake, "greentic-mcp", crate_name, LANE).is_err());
595    }
596
597    #[test]
598    fn a_legitimate_vscode_settings_file_is_not_a_marker() {
599        assert!(!worm_marker(".vscode/settings.json"));
600        assert!(!worm_marker("web/public/fonts/inter.woff2"));
601        assert!(worm_marker("crates/x/.vscode/tasks.json"));
602        assert!(worm_marker("public/fonts/fa-solid-900.eot"));
603    }
604
605    #[test]
606    fn an_archive_without_a_digest_is_an_error() {
607        let crate_name = "greentic-pack-dev";
608        let mut release = complete_release(crate_name, "1.2.7");
609        release.assets[0].digest = None;
610        let fake = source(vec![release]);
611        let err = resolve_dev_package(&fake, "greentic-pack", crate_name, LANE)
612            .expect_err("no digest, no pin");
613        assert!(format!("{err:#}").contains("digest"), "{err:#}");
614    }
615
616    #[test]
617    fn a_repository_with_no_dev_build_is_an_error() {
618        let fake = source(vec![complete_release("greentic-flow-dev", "1.3.0")]);
619        let err = resolve_dev_package(&fake, "greentic-flow", "greentic-flow-dev", LANE)
620            .expect_err("no lane build");
621        assert!(
622            format!("{err:#}").contains("no 1.2.<run> dev release"),
623            "{err:#}"
624        );
625    }
626
627    #[test]
628    fn only_incomplete_builds_report_why() {
629        let mut uploading = complete_release("greentic-gui-dev", "1.2.5");
630        uploading
631            .assets
632            .retain(|asset| !asset.name.ends_with(".sha256"));
633        let fake = source(vec![uploading]);
634        let err = resolve_dev_package(&fake, "greentic-gui", "greentic-gui-dev", LANE)
635            .expect_err("incomplete");
636        assert!(format!("{err:#}").contains("missing .sha256"), "{err:#}");
637    }
638
639    #[test]
640    fn a_manifest_covers_every_toolchain_package_with_artifacts() {
641        struct EveryRepo;
642        impl DevReleaseSource for EveryRepo {
643            fn releases_page(&self, repo: &str, page: u32) -> Result<Vec<RepoRelease>> {
644                if page > 1 {
645                    return Ok(Vec::new());
646                }
647                let crate_in_manifest = manifest_crate_name_for_source(DEV_CHANNEL, repo);
648                Ok(vec![complete_release(&crate_in_manifest, "1.2.100")])
649            }
650            fn commit_provenance(&self, _repo: &str, _tag: &str) -> Result<CommitProvenance> {
651                Ok(clean())
652            }
653        }
654
655        let manifest = snapshot_manifest_from_github_releases("1.2.34087396714", &EveryRepo, None)
656            .expect("manifest");
657
658        assert_eq!(manifest.channel.as_deref(), Some("dev"));
659        assert_eq!(manifest.packages.len(), GREENTIC_TOOLCHAIN_PACKAGES.len());
660        for package in &manifest.packages {
661            assert!(
662                package.crate_name.ends_with("-dev"),
663                "{}",
664                package.crate_name
665            );
666            assert_eq!(package.version, "1.2.100");
667            assert!(
668                package.artifacts.as_ref().is_some_and(|a| !a.is_empty()),
669                "{} has artifacts",
670                package.crate_name
671            );
672        }
673    }
674}