Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6
7use anyhow::{Context, Result, anyhow, bail};
8use async_trait::async_trait;
9use oci_distribution::Reference;
10use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
11use oci_distribution::secrets::RegistryAuth;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16
17use crate::cli::{
18    ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
22use crate::toolchain_catalogue::{
23    GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
24    OciPackageSpec,
25};
26
27const DEFAULT_OAUTH_USER: &str = "oauth2";
28pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
29pub const TOOLCHAIN_NAME: &str = "gtc";
30pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
31const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ToolchainManifest {
35    pub schema: String,
36    pub toolchain: String,
37    pub version: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub channel: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub created_at: Option<String>,
42    pub packages: Vec<ToolchainPackage>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub extension_packs: Option<Vec<ExtensionPackRef>>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub components: Option<Vec<ComponentRef>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ToolchainPackage {
51    #[serde(rename = "crate")]
52    pub crate_name: String,
53    pub bins: Vec<String>,
54    pub version: String,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ExtensionPackRef {
59    pub id: String,
60    pub version: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ComponentRef {
65    pub id: String,
66    pub version: String,
67}
68
69pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
70    let resolver = CargoSearchVersionResolver;
71    let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
72    let source = block_on_maybe_runtime(load_source_manifest(
73        &args.repo,
74        &args.from,
75        args.token.as_deref(),
76    ))
77    .with_context(|| {
78        format!(
79            "failed to resolve source manifest `{}`",
80            toolchain_ref(&args.repo, &args.from)
81        )
82    })?;
83    let source = match source {
84        Some(source) => Some(source),
85        None => bootstrap_source_manifest_if_needed(
86            &args.repo,
87            &args.from,
88            args.token.as_deref(),
89            args.dry_run,
90            &resolver,
91        )?,
92    };
93    let manifest = generate_manifest_with_artifact_resolver(
94        &args.release,
95        &args.from,
96        source.as_ref(),
97        &resolver,
98        &artifact_resolver,
99        Some(created_at_now()?),
100    )?;
101    if args.dry_run {
102        println!("{}", serde_json::to_string_pretty(&manifest)?);
103        return Ok(());
104    }
105    let path = write_manifest(&args.out, &manifest)?;
106    println!("Wrote {}", path.display());
107    Ok(())
108}
109
110fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
111    repo: &str,
112    tag: &str,
113    token: Option<&str>,
114    dry_run: bool,
115    resolver: &R,
116) -> Result<Option<ToolchainManifest>> {
117    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
118    if dry_run {
119        eprintln!(
120            "Dry run: would bootstrap missing source manifest {}",
121            toolchain_ref(repo, tag)
122        );
123        return Ok(Some(manifest));
124    }
125
126    let auth = match optional_registry_auth(token)? {
127        RegistryAuth::Anonymous => {
128            eprintln!(
129                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
130                toolchain_ref(repo, tag)
131            );
132            return Ok(Some(manifest));
133        }
134        auth => auth,
135    };
136    block_on_maybe_runtime(async {
137        let client = oci_client();
138        let source_ref = parse_reference(repo, tag)?;
139        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
140    })
141    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
142    println!("Bootstrapped {}", toolchain_ref(repo, tag));
143    Ok(Some(manifest))
144}
145
146fn bootstrap_source_manifest<R: CrateVersionResolver>(
147    tag: &str,
148    resolver: &R,
149    created_at: Option<String>,
150) -> Result<ToolchainManifest> {
151    generate_manifest(tag, tag, None, resolver, created_at)
152}
153
154pub fn publish(args: ReleasePublishArgs) -> Result<()> {
155    let (release, manifest, source) = publish_manifest_input(&args)?;
156
157    if args.dry_run {
158        println!(
159            "Dry run: would publish {}",
160            toolchain_ref(&args.repo, &release)
161        );
162        if let Some(tag) = &args.tag {
163            println!(
164                "Dry run: would tag {} as {}",
165                toolchain_ref(&args.repo, &release),
166                toolchain_ref(&args.repo, tag)
167            );
168        }
169        return Ok(());
170    }
171
172    let auth = registry_auth(args.token.as_deref())?;
173    block_on_maybe_runtime(async {
174        let client = oci_client();
175        let release_ref = parse_reference(&args.repo, &release)?;
176        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
177            bail!(
178                "release tag `{}` already exists; pass --force to overwrite it",
179                toolchain_ref(&args.repo, &release)
180            );
181        }
182        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
183        if let Some(tag) = &args.tag {
184            let tag_ref = parse_reference(&args.repo, tag)?;
185            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
186        }
187        Ok(())
188    })?;
189
190    if let Some(source) = source {
191        match source {
192            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
193            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
194        }
195    }
196    println!("Published {}", toolchain_ref(&args.repo, &release));
197    if let Some(tag) = &args.tag {
198        println!("Updated {}", toolchain_ref(&args.repo, tag));
199    }
200    Ok(())
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204enum PublishManifestSource {
205    Generated(PathBuf),
206    Local(PathBuf),
207}
208
209fn publish_manifest_input(
210    args: &ReleasePublishArgs,
211) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
212    if let Some(path) = &args.manifest {
213        let mut manifest = read_manifest_file(path)?;
214        validate_manifest(&manifest)?;
215        let release = if let Some(release) = &args.release {
216            manifest.version = release.clone();
217            release.clone()
218        } else {
219            manifest.version.clone()
220        };
221        return Ok((
222            release,
223            manifest,
224            Some(PublishManifestSource::Local(path.clone())),
225        ));
226    }
227
228    let release = args
229        .release
230        .as_deref()
231        .context("pass --release or --manifest")?;
232    let from = args.from.as_deref().unwrap_or("latest");
233    let resolver = CargoSearchVersionResolver;
234    let source = block_on_maybe_runtime(load_source_manifest(
235        &args.repo,
236        from,
237        args.token.as_deref(),
238    ))
239    .with_context(|| {
240        format!(
241            "failed to resolve source manifest `{}`",
242            toolchain_ref(&args.repo, from)
243        )
244    })?;
245    let manifest = generate_manifest(
246        release,
247        from,
248        source.as_ref(),
249        &resolver,
250        Some(created_at_now()?),
251    )?;
252    let path = if args.dry_run {
253        println!("{}", serde_json::to_string_pretty(&manifest)?);
254        None
255    } else {
256        Some(PublishManifestSource::Generated(write_manifest(
257            &args.out, &manifest,
258        )?))
259    };
260    Ok((release.to_string(), manifest, path))
261}
262
263fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
264    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
265    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
266}
267
268pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
269    if args.dry_run {
270        println!(
271            "Dry run: would promote {} to {}",
272            toolchain_ref(&args.repo, &args.release),
273            toolchain_ref(&args.repo, &args.tag)
274        );
275        return Ok(());
276    }
277
278    let auth = registry_auth(args.token.as_deref())?;
279    block_on_maybe_runtime(async {
280        let client = oci_client();
281        let source_ref = parse_reference(&args.repo, &args.release)?;
282        let target_ref = parse_reference(&args.repo, &args.tag)?;
283        let (manifest, _) = client
284            .pull_manifest(&source_ref, &auth)
285            .await
286            .with_context(|| {
287                format!(
288                    "failed to resolve source release `{}`",
289                    toolchain_ref(&args.repo, &args.release)
290                )
291            })?;
292        client
293            .push_manifest(&target_ref, &manifest)
294            .await
295            .with_context(|| {
296                format!(
297                    "failed to update tag `{}`",
298                    toolchain_ref(&args.repo, &args.tag)
299                )
300            })?;
301        Ok(())
302    })?;
303    println!(
304        "Promoted {} to {}",
305        toolchain_ref(&args.repo, &args.release),
306        toolchain_ref(&args.repo, &args.tag)
307    );
308    Ok(())
309}
310
311pub fn view(args: ReleaseViewArgs) -> Result<()> {
312    let tag = release_view_tag(&args)?;
313    let manifest = block_on_maybe_runtime(load_source_manifest(
314        &args.repo,
315        &tag,
316        args.token.as_deref(),
317    ))
318    .with_context(|| {
319        format!(
320            "failed to resolve manifest `{}`",
321            toolchain_ref(&args.repo, &tag)
322        )
323    })?
324    .with_context(|| {
325        format!(
326            "manifest `{}` was not found or is not authorized for this token",
327            toolchain_ref(&args.repo, &tag)
328        )
329    })?;
330    println!("{}", serde_json::to_string_pretty(&manifest)?);
331    Ok(())
332}
333
334pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
335    let manifest = latest_manifest(Some(created_at_now()?));
336    if args.dry_run {
337        println!("{}", serde_json::to_string_pretty(&manifest)?);
338        println!(
339            "Dry run: would publish {}",
340            toolchain_ref(&args.repo, "latest")
341        );
342        return Ok(());
343    }
344
345    let auth = registry_auth(args.token.as_deref())?;
346    block_on_maybe_runtime(async {
347        let client = oci_client();
348        let latest_ref = parse_reference(&args.repo, "latest")?;
349        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
350            bail!(
351                "latest tag `{}` already exists; pass --force to overwrite it",
352                toolchain_ref(&args.repo, "latest")
353            );
354        }
355        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
356    })?;
357    println!("Published {}", toolchain_ref(&args.repo, "latest"));
358    Ok(())
359}
360
361fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
362    ToolchainManifest {
363        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
364        toolchain: TOOLCHAIN_NAME.to_string(),
365        version: "latest".to_string(),
366        channel: Some("latest".to_string()),
367        created_at,
368        packages: latest_manifest_packages(),
369        extension_packs: Some(
370            GREENTIC_EXTENSION_PACK_PACKAGES
371                .iter()
372                .map(|package| ExtensionPackRef {
373                    id: package.package.to_string(),
374                    version: "latest".to_string(),
375                })
376                .collect(),
377        ),
378        components: Some(
379            GREENTIC_COMPONENT_PACKAGES
380                .iter()
381                .map(|package| ComponentRef {
382                    id: package.package.to_string(),
383                    version: "latest".to_string(),
384                })
385                .collect(),
386        ),
387    }
388}
389
390fn latest_manifest_packages() -> Vec<ToolchainPackage> {
391    std::iter::once(ToolchainPackage {
392        crate_name: TOOLCHAIN_NAME.to_string(),
393        bins: vec![delegated_binary_name_for_channel(
394            TOOLCHAIN_NAME,
395            ToolchainChannel::Development,
396        )],
397        version: "latest".to_string(),
398    })
399    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
400        ToolchainPackage {
401            crate_name: package.crate_name.to_string(),
402            bins: package
403                .bins
404                .iter()
405                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
406                .collect(),
407            version: "latest".to_string(),
408        }
409    }))
410    .collect()
411}
412
413fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
414    match (&args.release, &args.tag) {
415        (Some(release), None) => Ok(release.clone()),
416        (None, Some(tag)) => Ok(tag.clone()),
417        _ => bail!("pass exactly one of --release or --tag"),
418    }
419}
420
421pub fn generate_manifest<R: CrateVersionResolver>(
422    release: &str,
423    from: &str,
424    source: Option<&ToolchainManifest>,
425    resolver: &R,
426    created_at: Option<String>,
427) -> Result<ToolchainManifest> {
428    let artifact_resolver = ReleaseArtifactVersionResolver { release };
429    generate_manifest_with_artifact_resolver(
430        release,
431        from,
432        source,
433        resolver,
434        &artifact_resolver,
435        created_at,
436    )
437}
438
439pub fn generate_manifest_with_artifact_resolver<R, A>(
440    release: &str,
441    from: &str,
442    source: Option<&ToolchainManifest>,
443    resolver: &R,
444    artifact_resolver: &A,
445    created_at: Option<String>,
446) -> Result<ToolchainManifest>
447where
448    R: CrateVersionResolver,
449    A: ArtifactVersionResolver,
450{
451    if let Some(source) = source {
452        validate_manifest(source)?;
453    }
454    let source_versions = source_version_map(source);
455    let mut packages = Vec::new();
456    for package in GREENTIC_TOOLCHAIN_PACKAGES {
457        let source_version = source_versions.get(package.crate_name);
458        let version = match source_version.map(String::as_str) {
459            Some(version) if version != "latest" => version.to_string(),
460            _ => resolver.resolve_latest(package.crate_name)?,
461        };
462        packages.push(ToolchainPackage {
463            crate_name: package.crate_name.to_string(),
464            bins: manifest_bins_for_source(from, package.bins),
465            version,
466        });
467    }
468    Ok(ToolchainManifest {
469        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
470        toolchain: TOOLCHAIN_NAME.to_string(),
471        version: release.to_string(),
472        channel: Some(from.to_string()),
473        created_at,
474        packages,
475        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
476        components: Some(component_refs_for_release(source, artifact_resolver)?),
477    })
478}
479
480fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
481    let channel = match from {
482        "dev" => ToolchainChannel::Development,
483        "rnd" => ToolchainChannel::Rnd,
484        _ => ToolchainChannel::Stable,
485    };
486    bins.iter()
487        .map(|bin| delegated_binary_name_for_channel(bin, channel))
488        .collect()
489}
490
491fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
492    source: Option<&ToolchainManifest>,
493    artifact_resolver: &A,
494) -> Result<Vec<ExtensionPackRef>> {
495    let source_versions = source_ref_version_map(source.and_then(|manifest| {
496        manifest
497            .extension_packs
498            .as_ref()
499            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
500    }));
501    GREENTIC_EXTENSION_PACK_PACKAGES
502        .iter()
503        .map(|package| {
504            Ok(ExtensionPackRef {
505                id: package.package.to_string(),
506                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
507            })
508        })
509        .collect()
510}
511
512fn component_refs_for_release<A: ArtifactVersionResolver>(
513    source: Option<&ToolchainManifest>,
514    artifact_resolver: &A,
515) -> Result<Vec<ComponentRef>> {
516    let source_versions = source_ref_version_map(source.and_then(|manifest| {
517        manifest
518            .components
519            .as_ref()
520            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
521    }));
522    GREENTIC_COMPONENT_PACKAGES
523        .iter()
524        .map(|package| {
525            Ok(ComponentRef {
526                id: package.package.to_string(),
527                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
528            })
529        })
530        .collect()
531}
532
533fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
534where
535    I: Iterator<Item = (&'a String, &'a String)>,
536{
537    let mut out = BTreeMap::new();
538    if let Some(refs) = refs {
539        for (id, version) in refs {
540            out.insert(id.clone(), version.clone());
541        }
542    }
543    out
544}
545
546fn ref_version_for_package(
547    package: &OciPackageSpec,
548    source_versions: &BTreeMap<String, String>,
549    artifact_resolver: &impl ArtifactVersionResolver,
550) -> Result<String> {
551    match source_versions.get(package.package).map(String::as_str) {
552        Some(version) if version != "latest" => Ok(version.to_string()),
553        _ => artifact_resolver
554            .resolve_latest(package.package)
555            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
556    }
557}
558
559pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
560    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
561        bail!(
562            "unsupported toolchain manifest schema `{}`",
563            manifest.schema
564        );
565    }
566    if manifest.toolchain != TOOLCHAIN_NAME {
567        bail!("unsupported toolchain `{}`", manifest.toolchain);
568    }
569    Ok(())
570}
571
572pub fn toolchain_ref(repo: &str, tag: &str) -> String {
573    format!("{repo}:{tag}")
574}
575
576fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
577    let mut out = BTreeMap::new();
578    if let Some(source) = source {
579        for package in &source.packages {
580            out.insert(package.crate_name.clone(), package.version.clone());
581        }
582    }
583    out
584}
585
586fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
587    fs::create_dir_all(out_dir)
588        .with_context(|| format!("failed to create {}", out_dir.display()))?;
589    let path = out_dir.join(manifest_file_name(manifest));
590    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
591    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
592    Ok(path)
593}
594
595fn manifest_file_name(manifest: &ToolchainManifest) -> String {
596    match manifest.channel.as_deref() {
597        Some("stable") | None => format!("gtc-{}.json", manifest.version),
598        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
599    }
600}
601
602fn created_at_now() -> Result<String> {
603    OffsetDateTime::now_utc()
604        .format(&Rfc3339)
605        .context("failed to format current time")
606}
607
608pub trait CrateVersionResolver {
609    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
610}
611
612pub trait ArtifactVersionResolver {
613    fn resolve_latest(&self, package: &str) -> Result<String>;
614}
615
616struct CargoSearchVersionResolver;
617
618impl CrateVersionResolver for CargoSearchVersionResolver {
619    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
620        let output = Command::new("cargo")
621            .arg("search")
622            .arg(crate_name)
623            .arg("--limit")
624            .arg("1")
625            .output()
626            .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
627        if !output.status.success() {
628            bail!(
629                "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
630                output.status.code()
631            );
632        }
633        let stdout = String::from_utf8(output.stdout).with_context(|| {
634            format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
635        })?;
636        parse_cargo_search_version(crate_name, &stdout)
637    }
638}
639
640struct ReleaseArtifactVersionResolver<'a> {
641    release: &'a str,
642}
643
644impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
645    fn resolve_latest(&self, _package: &str) -> Result<String> {
646        Ok(self.release.to_string())
647    }
648}
649
650struct GhcrArtifactVersionResolver {
651    client: reqwest::blocking::Client,
652    registry: String,
653    namespace: String,
654    basic_token: Option<String>,
655}
656
657impl GhcrArtifactVersionResolver {
658    fn new(raw_token: Option<&str>) -> Result<Self> {
659        Ok(Self {
660            client: reqwest::blocking::Client::builder()
661                .build()
662                .context("failed to build GHCR HTTP client")?,
663            registry: "ghcr.io".to_string(),
664            namespace: "greenticai".to_string(),
665            basic_token: resolve_registry_token(raw_token)?
666                .or_else(|| std::env::var("GHCR_TOKEN").ok())
667                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
668        })
669    }
670
671    fn bearer_token(&self, repository: &str) -> Result<String> {
672        let scope = format!("repository:{repository}:pull");
673        let mut request = self
674            .client
675            .get(format!("https://{}/token", self.registry))
676            .query(&[
677                ("service", self.registry.as_str()),
678                ("scope", scope.as_str()),
679            ]);
680        if let Some(token) = &self.basic_token {
681            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
682        }
683        let response = request
684            .send()
685            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
686            .error_for_status()
687            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
688        let body: GhcrTokenResponse = response
689            .json()
690            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
691        Ok(body.token)
692    }
693
694    fn tags(&self, repository: &str) -> Result<Vec<String>> {
695        let token = self.bearer_token(repository)?;
696        let response = self
697            .client
698            .get(format!(
699                "https://{}/v2/{repository}/tags/list",
700                self.registry
701            ))
702            .bearer_auth(token)
703            .send()
704            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
705            .error_for_status()
706            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
707        let body: GhcrTagsResponse = response
708            .json()
709            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
710        Ok(body.tags)
711    }
712}
713
714impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
715    fn resolve_latest(&self, package: &str) -> Result<String> {
716        let repository = format!("{}/{}", self.namespace, package);
717        let tags = self.tags(&repository)?;
718        select_latest_artifact_tag(&tags)
719            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
720    }
721}
722
723#[derive(Deserialize)]
724struct GhcrTokenResponse {
725    token: String,
726}
727
728#[derive(Deserialize)]
729struct GhcrTagsResponse {
730    #[serde(default)]
731    tags: Vec<String>,
732}
733
734fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
735    tags.iter()
736        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
737        .max_by(|(left, _), (right, _)| left.cmp(right))
738        .map(|(_, tag)| tag.clone())
739        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
740        .context("no semver or latest tags found")
741}
742
743fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
744    let first_line = stdout
745        .lines()
746        .find(|line| !line.trim().is_empty())
747        .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
748    let Some((found_name, rhs)) = first_line.split_once('=') else {
749        bail!("unexpected cargo search output: {first_line}");
750    };
751    if found_name.trim() != crate_name {
752        bail!(
753            "`cargo search {crate_name} --limit 1` returned `{}` first",
754            found_name.trim()
755        );
756    }
757    let quoted = rhs
758        .split('#')
759        .next()
760        .map(str::trim)
761        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
762    let version = quoted.trim_matches('"');
763    Version::parse(version)
764        .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
765    Ok(version.to_string())
766}
767
768#[async_trait]
769trait ToolchainManifestSource {
770    async fn load_manifest(
771        &self,
772        repo: &str,
773        tag: &str,
774        token: Option<&str>,
775    ) -> Result<Option<ToolchainManifest>>;
776}
777
778struct OciToolchainManifestSource;
779
780#[async_trait]
781impl ToolchainManifestSource for OciToolchainManifestSource {
782    async fn load_manifest(
783        &self,
784        repo: &str,
785        tag: &str,
786        token: Option<&str>,
787    ) -> Result<Option<ToolchainManifest>> {
788        let auth = optional_registry_auth(token)?;
789        let client = oci_client();
790        let reference = parse_reference(repo, tag)?;
791        let image = match client
792            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
793            .await
794        {
795            Ok(image) => image,
796            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
797                return Ok(None);
798            }
799            Err(err) => {
800                return Err(err)
801                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
802            }
803        };
804        let Some(layer) = image
805            .layers
806            .into_iter()
807            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
808        else {
809            return Ok(None);
810        };
811        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
812            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
813        validate_manifest(&manifest)?;
814        Ok(Some(manifest))
815    }
816}
817
818async fn load_source_manifest(
819    repo: &str,
820    tag: &str,
821    token: Option<&str>,
822) -> Result<Option<ToolchainManifest>> {
823    OciToolchainManifestSource
824        .load_manifest(repo, tag, token)
825        .await
826}
827
828fn oci_client() -> Client {
829    Client::new(ClientConfig {
830        protocol: ClientProtocol::Https,
831        ..Default::default()
832    })
833}
834
835fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
836    let token = resolve_registry_token(raw_token)?
837        .or_else(|| std::env::var("GHCR_TOKEN").ok())
838        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
839        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
840    if token.trim().is_empty() {
841        bail!("GHCR token is empty");
842    }
843    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
844}
845
846fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
847    match registry_auth(raw_token) {
848        Ok(auth) => Ok(auth),
849        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
850        Err(err) => Err(err),
851    }
852}
853
854fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
855    let Some(raw_token) = raw_token else {
856        return Ok(None);
857    };
858    if let Some(var) = raw_token.strip_prefix("env:") {
859        let token =
860            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
861        if token.trim().is_empty() {
862            bail!("env var {var} resolved to an empty token");
863        }
864        return Ok(Some(token));
865    }
866    if raw_token.trim().is_empty() {
867        bail!("GHCR token is empty");
868    }
869    Ok(Some(raw_token.to_string()))
870}
871
872fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
873    Reference::from_str(&toolchain_ref(repo, tag))
874        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
875}
876
877async fn manifest_exists(
878    client: &Client,
879    reference: &Reference,
880    auth: &RegistryAuth,
881) -> Result<bool> {
882    match client.pull_manifest(reference, auth).await {
883        Ok(_) => Ok(true),
884        Err(err) if is_missing_manifest_error(&err) => Ok(false),
885        Err(err) => Err(err).context("failed to check whether release tag exists"),
886    }
887}
888
889fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
890    let msg = err.to_string().to_ascii_lowercase();
891    msg.contains("manifest unknown")
892        || msg.contains("name unknown")
893        || msg.contains("not found")
894        || msg.contains("404")
895}
896
897fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
898    let msg = err.to_string().to_ascii_lowercase();
899    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
900}
901
902async fn push_manifest_layer(
903    client: &Client,
904    reference: &Reference,
905    auth: &RegistryAuth,
906    manifest: &ToolchainManifest,
907) -> Result<()> {
908    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
909    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
910    let config = Config::new(
911        br#"{"toolchain":"gtc"}"#.to_vec(),
912        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
913        None,
914    );
915    client
916        .push(reference, &[layer], config, auth, None)
917        .await
918        .context("failed to push toolchain manifest")?;
919    Ok(())
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    struct FixedResolver;
927
928    impl CrateVersionResolver for FixedResolver {
929        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
930            Ok(match crate_name {
931                "greentic-runner" => "0.5.10",
932                _ => "1.2.3",
933            }
934            .to_string())
935        }
936    }
937
938    struct FixedArtifactResolver;
939
940    impl ArtifactVersionResolver for FixedArtifactResolver {
941        fn resolve_latest(&self, package: &str) -> Result<String> {
942            Ok(match package {
943                "packs/messaging/messaging-webchat-gui" => "0.4.93",
944                "components/component-adaptive-card" => "0.5.8",
945                _ => "0.1.0",
946            }
947            .to_string())
948        }
949    }
950
951    #[test]
952    fn parses_cargo_search_version() {
953        let version = parse_cargo_search_version(
954            "greentic-dev",
955            r#"greentic-dev = "0.5.1"    # Developer CLI"#,
956        )
957        .unwrap();
958        assert_eq!(version, "0.5.1");
959    }
960
961    #[test]
962    fn selects_latest_semver_tag() {
963        let tags = vec![
964            "latest".to_string(),
965            "0.4.93".to_string(),
966            "0.4.9".to_string(),
967            "1.0.0-beta.1".to_string(),
968            "1.0.0".to_string(),
969        ];
970
971        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
972    }
973
974    #[test]
975    fn selects_latest_tag_when_no_semver_tags_exist() {
976        let tags = vec!["latest".to_string()];
977
978        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
979    }
980
981    #[test]
982    fn generates_manifest_from_catalogue() {
983        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
984        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
985        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
986        assert_eq!(manifest.version, "1.0.5");
987        assert_eq!(manifest.channel.as_deref(), Some("latest"));
988        assert!(
989            manifest
990                .packages
991                .iter()
992                .any(|package| package.crate_name == "greentic-bundle"
993                    && package.bins == ["greentic-bundle"])
994        );
995        assert!(
996            manifest
997                .packages
998                .iter()
999                .any(|package| package.crate_name == "greentic-runner"
1000                    && package.bins == ["greentic-runner"])
1001        );
1002        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 94);
1003        assert_eq!(manifest.components.as_ref().unwrap().len(), 9);
1004        assert!(
1005            manifest
1006                .extension_packs
1007                .as_ref()
1008                .unwrap()
1009                .iter()
1010                .all(|item| item.version == "1.0.5")
1011        );
1012        assert!(
1013            manifest
1014                .components
1015                .as_ref()
1016                .unwrap()
1017                .iter()
1018                .all(|item| item.version == "1.0.5")
1019        );
1020    }
1021
1022    #[test]
1023    fn generated_manifest_can_use_artifact_resolver_versions() {
1024        let manifest = generate_manifest_with_artifact_resolver(
1025            "1.0.17",
1026            "stable",
1027            None,
1028            &FixedResolver,
1029            &FixedArtifactResolver,
1030            None,
1031        )
1032        .unwrap();
1033
1034        assert!(
1035            manifest
1036                .extension_packs
1037                .as_ref()
1038                .unwrap()
1039                .iter()
1040                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1041                    && item.version == "0.4.93")
1042        );
1043        assert!(
1044            manifest
1045                .components
1046                .as_ref()
1047                .unwrap()
1048                .iter()
1049                .any(|item| item.id == "components/component-adaptive-card"
1050                    && item.version == "0.5.8")
1051        );
1052    }
1053
1054    #[test]
1055    fn source_manifest_can_pin_package_versions() {
1056        let source = ToolchainManifest {
1057            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1058            toolchain: TOOLCHAIN_NAME.to_string(),
1059            version: "latest".to_string(),
1060            channel: Some("latest".to_string()),
1061            created_at: None,
1062            packages: vec![ToolchainPackage {
1063                crate_name: "greentic-dev".to_string(),
1064                bins: vec!["greentic-dev".to_string()],
1065                version: "0.5.9".to_string(),
1066            }],
1067            extension_packs: None,
1068            components: None,
1069        };
1070        let manifest =
1071            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1072        let greentic_dev = manifest
1073            .packages
1074            .iter()
1075            .find(|package| package.crate_name == "greentic-dev")
1076            .unwrap();
1077        assert_eq!(greentic_dev.version, "0.5.9");
1078    }
1079
1080    #[test]
1081    fn from_argument_controls_generated_channel_over_source_manifest() {
1082        let source = ToolchainManifest {
1083            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1084            toolchain: TOOLCHAIN_NAME.to_string(),
1085            version: "latest".to_string(),
1086            channel: Some("stable".to_string()),
1087            created_at: None,
1088            packages: Vec::new(),
1089            extension_packs: None,
1090            components: None,
1091        };
1092        let manifest =
1093            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1094        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1095        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1096    }
1097
1098    #[test]
1099    fn generate_from_dev_uses_dev_binary_names() {
1100        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1101        assert!(
1102            manifest
1103                .packages
1104                .iter()
1105                .flat_map(|package| package.bins.iter())
1106                .all(|bin| bin.ends_with("-dev"))
1107        );
1108        assert!(manifest.packages.iter().any(|package| {
1109            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-dev"]
1110        }));
1111        assert!(manifest.packages.iter().any(|package| {
1112            package.crate_name == "greentic-component" && package.bins == ["greentic-component-dev"]
1113        }));
1114    }
1115
1116    #[test]
1117    fn generate_from_rnd_uses_rnd_binary_names() {
1118        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1119        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1120        assert!(
1121            manifest
1122                .packages
1123                .iter()
1124                .flat_map(|package| package.bins.iter())
1125                .all(|bin| bin.ends_with("-rnd"))
1126        );
1127        assert!(manifest.packages.iter().any(|package| {
1128            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1129        }));
1130    }
1131
1132    #[test]
1133    fn bootstrap_source_manifest_uses_source_tag_identity() {
1134        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1135        assert_eq!(manifest.version, "latest");
1136        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1137        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1138        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1139        assert!(
1140            manifest
1141                .packages
1142                .iter()
1143                .all(|package| package.version != "latest")
1144        );
1145    }
1146
1147    #[test]
1148    fn validates_schema_and_toolchain() {
1149        let mut manifest =
1150            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1151        assert!(validate_manifest(&manifest).is_ok());
1152        manifest.schema = "wrong".to_string();
1153        assert!(validate_manifest(&manifest).is_err());
1154    }
1155
1156    #[test]
1157    fn resolves_inline_registry_token() {
1158        assert_eq!(
1159            resolve_registry_token(Some("secret-token"))
1160                .unwrap()
1161                .as_deref(),
1162            Some("secret-token")
1163        );
1164    }
1165
1166    #[test]
1167    fn release_view_tag_prefers_release_or_tag() {
1168        let args = ReleaseViewArgs {
1169            release: Some("1.0.5".to_string()),
1170            tag: None,
1171            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1172            token: None,
1173        };
1174        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1175
1176        let args = ReleaseViewArgs {
1177            release: None,
1178            tag: Some("stable".to_string()),
1179            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1180            token: None,
1181        };
1182        assert_eq!(release_view_tag(&args).unwrap(), "stable");
1183    }
1184
1185    #[test]
1186    fn publish_manifest_input_uses_local_manifest_version() {
1187        let dir = tempfile::tempdir().unwrap();
1188        let path = dir.path().join("gtc-1.0.12.json");
1189        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1190        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1191        let args = ReleasePublishArgs {
1192            release: None,
1193            from: None,
1194            tag: Some("stable".to_string()),
1195            manifest: Some(path.clone()),
1196            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1197            token: None,
1198            out: dir.path().to_path_buf(),
1199            dry_run: true,
1200            force: true,
1201        };
1202        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1203        assert_eq!(release, "1.0.12");
1204        assert_eq!(loaded, manifest);
1205        assert_eq!(
1206            source_path,
1207            Some(PublishManifestSource::Local(path.clone()))
1208        );
1209    }
1210
1211    #[test]
1212    fn publish_manifest_input_allows_release_override_for_local_manifest() {
1213        let dir = tempfile::tempdir().unwrap();
1214        let path = dir.path().join("gtc-1.0.13.json");
1215        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1216        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1217        let args = ReleasePublishArgs {
1218            release: Some("1.0.13".to_string()),
1219            from: None,
1220            tag: Some("stable".to_string()),
1221            manifest: Some(path.clone()),
1222            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1223            token: None,
1224            out: dir.path().to_path_buf(),
1225            dry_run: true,
1226            force: true,
1227        };
1228        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1229        assert_eq!(release, "1.0.13");
1230        assert_eq!(loaded.version, "1.0.13");
1231        assert_eq!(
1232            source_path,
1233            Some(PublishManifestSource::Local(path.clone()))
1234        );
1235    }
1236
1237    #[test]
1238    fn manifest_file_name_omits_stable_channel() {
1239        let manifest = ToolchainManifest {
1240            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1241            toolchain: TOOLCHAIN_NAME.to_string(),
1242            version: "1.0.12".to_string(),
1243            channel: Some("stable".to_string()),
1244            created_at: None,
1245            packages: Vec::new(),
1246            extension_packs: None,
1247            components: None,
1248        };
1249        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1250    }
1251
1252    #[test]
1253    fn parses_manifest_without_extension_sections() {
1254        let manifest: ToolchainManifest = serde_json::from_str(
1255            r#"{
1256              "schema": "greentic.toolchain-manifest.v1",
1257              "toolchain": "gtc",
1258              "version": "1.0.16",
1259              "channel": "stable",
1260              "packages": []
1261            }"#,
1262        )
1263        .unwrap();
1264
1265        assert_eq!(manifest.extension_packs, None);
1266        assert_eq!(manifest.components, None);
1267    }
1268
1269    #[test]
1270    fn generated_manifest_includes_catalogue_extension_sections() {
1271        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
1272        let json = serde_json::to_value(&manifest).unwrap();
1273
1274        assert!(json.get("extension_packs").is_some());
1275        assert!(json.get("components").is_some());
1276        assert!(
1277            manifest
1278                .extension_packs
1279                .as_ref()
1280                .unwrap()
1281                .iter()
1282                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1283                    && item.version == "1.0.16")
1284        );
1285        assert!(
1286            manifest
1287                .extension_packs
1288                .as_ref()
1289                .unwrap()
1290                .iter()
1291                .any(|item| item.id == "greentic-bundle/providers" && item.version == "1.0.16")
1292        );
1293        assert!(
1294            manifest
1295                .components
1296                .as_ref()
1297                .unwrap()
1298                .iter()
1299                .any(|item| item.id == "component/component-llm-openai"
1300                    && item.version == "1.0.16")
1301        );
1302    }
1303
1304    #[test]
1305    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
1306        let source = ToolchainManifest {
1307            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1308            toolchain: TOOLCHAIN_NAME.to_string(),
1309            version: "dev".to_string(),
1310            channel: Some("dev".to_string()),
1311            created_at: None,
1312            packages: Vec::new(),
1313            extension_packs: Some(vec![ExtensionPackRef {
1314                id: "packs/messaging/messaging-webchat-gui".to_string(),
1315                version: "0.5.4".to_string(),
1316            }]),
1317            components: Some(vec![ComponentRef {
1318                id: "components/component-adaptive-card".to_string(),
1319                version: "0.5.8".to_string(),
1320            }]),
1321        };
1322
1323        let manifest =
1324            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
1325
1326        assert!(
1327            manifest
1328                .extension_packs
1329                .as_ref()
1330                .unwrap()
1331                .iter()
1332                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1333                    && item.version == "0.5.4")
1334        );
1335        assert!(
1336            manifest
1337                .components
1338                .as_ref()
1339                .unwrap()
1340                .iter()
1341                .any(|item| item.id == "components/component-adaptive-card"
1342                    && item.version == "0.5.8")
1343        );
1344    }
1345
1346    #[test]
1347    fn manifest_file_name_includes_non_stable_channel() {
1348        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1349        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1350
1351        manifest.channel = Some("customer-a".to_string());
1352        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1353    }
1354
1355    #[test]
1356    fn latest_manifest_uses_latest_dev_bins() {
1357        let manifest = latest_manifest(None);
1358        assert_eq!(manifest.version, "latest");
1359        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1360        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1361        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1362        assert!(!manifest.packages.is_empty());
1363        assert!(
1364            manifest
1365                .packages
1366                .iter()
1367                .all(|package| package.version == "latest")
1368        );
1369        assert!(
1370            manifest
1371                .packages
1372                .iter()
1373                .flat_map(|package| package.bins.iter())
1374                .all(|bin| bin.ends_with("-dev"))
1375        );
1376        assert!(
1377            manifest
1378                .packages
1379                .iter()
1380                .any(|package| { package.crate_name == "gtc" && package.bins == ["gtc-dev"] })
1381        );
1382        assert!(manifest.packages.iter().any(|package| {
1383            package.crate_name == "greentic-dev" && package.bins == ["greentic-dev-dev"]
1384        }));
1385        assert!(
1386            manifest
1387                .extension_packs
1388                .as_ref()
1389                .unwrap()
1390                .iter()
1391                .all(|item| item.version == "latest")
1392        );
1393        assert!(
1394            manifest
1395                .components
1396                .as_ref()
1397                .unwrap()
1398                .iter()
1399                .all(|item| item.version == "latest")
1400        );
1401    }
1402
1403    #[test]
1404    fn builds_toolchain_ref() {
1405        assert_eq!(
1406            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1407            "ghcr.io/greenticai/greentic-versions/gtc:stable"
1408        );
1409    }
1410}