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}