1use 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";
43const MAX_RELEASE_PAGES: u32 = 5;
48
49#[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 pub digest: Option<String>,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct CommitProvenance {
68 pub sha: String,
69 pub committer: String,
70 pub verified: bool,
71 pub paths: Vec<String>,
73 pub tree_truncated: bool,
76}
77
78pub trait DevReleaseSource {
81 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#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct ResolvedDevPackage {
89 pub version: String,
90 pub artifacts: Vec<PackageArtifactRef>,
91}
92
93pub 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
126pub 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 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
193fn 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
203fn 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
260fn 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
300pub 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}