1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use async_trait::async_trait;
8use oci_distribution::Reference;
9use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
10use oci_distribution::secrets::RegistryAuth;
11use semver::Version;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15
16use crate::cli::{
17 ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs,
18 ReleaseSnapshotArgs, 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 = default_resolver();
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 = default_resolver();
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 if let Some(source_manifest) = source.as_ref()
246 && source_manifest_has_concrete_pins(source_manifest)
247 {
248 eprintln!(
249 "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
250 of querying crates.io. To refresh a channel from the latest crates.io versions, use \
251 `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
252 re-resolving, use `release promote`. The conflated `--from` semantics will be \
253 removed in a future release.",
254 toolchain_ref(&args.repo, from),
255 );
256 }
257 let manifest = generate_manifest(
258 release,
259 from,
260 source.as_ref(),
261 &resolver,
262 Some(created_at_now()?),
263 )?;
264 let path = if args.dry_run {
265 println!("{}", serde_json::to_string_pretty(&manifest)?);
266 None
267 } else {
268 Some(PublishManifestSource::Generated(write_manifest(
269 &args.out, &manifest,
270 )?))
271 };
272 Ok((release.to_string(), manifest, path))
273}
274
275fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
280 manifest
281 .packages
282 .iter()
283 .any(|package| package.version != "latest")
284}
285
286fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
287 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
288 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
289}
290
291pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
292 if args.dry_run {
293 println!(
294 "Dry run: would promote {} to {}",
295 toolchain_ref(&args.repo, &args.release),
296 toolchain_ref(&args.repo, &args.tag)
297 );
298 return Ok(());
299 }
300
301 let auth = registry_auth(args.token.as_deref())?;
302 block_on_maybe_runtime(async {
303 let client = oci_client();
304 let source_ref = parse_reference(&args.repo, &args.release)?;
305 let target_ref = parse_reference(&args.repo, &args.tag)?;
306 let (manifest, _) = client
307 .pull_manifest(&source_ref, &auth)
308 .await
309 .with_context(|| {
310 format!(
311 "failed to resolve source release `{}`",
312 toolchain_ref(&args.repo, &args.release)
313 )
314 })?;
315 client
316 .push_manifest(&target_ref, &manifest)
317 .await
318 .with_context(|| {
319 format!(
320 "failed to update tag `{}`",
321 toolchain_ref(&args.repo, &args.tag)
322 )
323 })?;
324 Ok(())
325 })?;
326 println!(
327 "Promoted {} to {}",
328 toolchain_ref(&args.repo, &args.release),
329 toolchain_ref(&args.repo, &args.tag)
330 );
331 Ok(())
332}
333
334pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
342 let channel = parse_channel(&args.channel)?;
343 let resolver = CratesIoApiVersionResolver::default();
344 let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
345
346 if args.dry_run {
347 println!("{}", serde_json::to_string_pretty(&manifest)?);
348 println!(
349 "Dry run: would publish {}",
350 toolchain_ref(&args.repo, &args.release)
351 );
352 if let Some(tag) = &args.tag {
353 println!(
354 "Dry run: would tag {} as {}",
355 toolchain_ref(&args.repo, &args.release),
356 toolchain_ref(&args.repo, tag)
357 );
358 }
359 return Ok(());
360 }
361
362 let path = write_manifest(&args.out, &manifest)?;
363 println!("Wrote {}", path.display());
364
365 let auth = registry_auth(args.token.as_deref())?;
366 block_on_maybe_runtime(async {
367 let client = oci_client();
368 let release_ref = parse_reference(&args.repo, &args.release)?;
369 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
370 bail!(
371 "release tag `{}` already exists; pass --force to overwrite it",
372 toolchain_ref(&args.repo, &args.release)
373 );
374 }
375 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
376 if let Some(tag) = &args.tag {
377 let tag_ref = parse_reference(&args.repo, tag)?;
378 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
379 }
380 Ok(())
381 })?;
382 println!("Published {}", toolchain_ref(&args.repo, &args.release));
383 if let Some(tag) = &args.tag {
384 println!("Updated {}", toolchain_ref(&args.repo, tag));
385 }
386 Ok(())
387}
388
389fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
390 match channel {
391 "dev" | "development" => Ok(ToolchainChannel::Development),
392 "rnd" | "research" => Ok(ToolchainChannel::Rnd),
393 "stable" => Ok(ToolchainChannel::Stable),
394 other => bail!(
395 "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
396 pass --channel dev for the dev lane, --channel research for the research lane, or \
397 --channel stable for the stable lane"
398 ),
399 }
400}
401
402fn channel_tag(channel: ToolchainChannel) -> &'static str {
403 match channel {
404 ToolchainChannel::Stable => "stable",
405 ToolchainChannel::Development => "dev",
406 ToolchainChannel::Rnd => "rnd",
407 }
408}
409
410fn resolve_manifest_version<R: CrateVersionResolver>(
415 resolver: &R,
416 crate_in_manifest: &str,
417 channel: ToolchainChannel,
418) -> Result<Option<String>> {
419 if channel == ToolchainChannel::Rnd {
420 match resolver
421 .resolve_research_version(crate_in_manifest)
422 .with_context(|| {
423 format!("failed to resolve research version for `{crate_in_manifest}`")
424 })? {
425 ResearchVersion::Pinned(version) => Ok(Some(version)),
426 ResearchVersion::Absent => {
427 eprintln!(
428 "note: `{crate_in_manifest}` has no research build on crates.io; \
429 omitting it from the research toolchain manifest"
430 );
431 Ok(None)
432 }
433 }
434 } else {
435 resolver
436 .resolve_latest_for_channel(crate_in_manifest, channel)
437 .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
438 .map(Some)
439 }
440}
441
442pub fn snapshot_manifest<R: CrateVersionResolver>(
443 release: &str,
444 channel: ToolchainChannel,
445 resolver: &R,
446 created_at: Option<String>,
447) -> Result<ToolchainManifest> {
448 let from = channel_tag(channel);
449 let mut packages = Vec::new();
450 for package in GREENTIC_TOOLCHAIN_PACKAGES {
451 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
452 let Some(version) = resolve_manifest_version(resolver, &crate_in_manifest, channel)? else {
453 continue;
454 };
455 packages.push(ToolchainPackage {
456 crate_name: crate_in_manifest,
457 bins: manifest_bins_for_source(from, package.bins),
458 version,
459 });
460 }
461 Ok(ToolchainManifest {
462 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
463 toolchain: TOOLCHAIN_NAME.to_string(),
464 version: release.to_string(),
465 channel: Some(from.to_string()),
466 created_at,
467 packages,
468 extension_packs: None,
469 components: None,
470 })
471}
472
473pub fn view(args: ReleaseViewArgs) -> Result<()> {
474 let tag = release_view_tag(&args)?;
475 let manifest = block_on_maybe_runtime(load_source_manifest(
476 &args.repo,
477 &tag,
478 args.token.as_deref(),
479 ))
480 .with_context(|| {
481 format!(
482 "failed to resolve manifest `{}`",
483 toolchain_ref(&args.repo, &tag)
484 )
485 })?
486 .with_context(|| {
487 format!(
488 "manifest `{}` was not found or is not authorized for this token",
489 toolchain_ref(&args.repo, &tag)
490 )
491 })?;
492 println!("{}", serde_json::to_string_pretty(&manifest)?);
493 Ok(())
494}
495
496pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
497 let manifest = latest_manifest(Some(created_at_now()?));
498 if args.dry_run {
499 println!("{}", serde_json::to_string_pretty(&manifest)?);
500 println!(
501 "Dry run: would publish {}",
502 toolchain_ref(&args.repo, "latest")
503 );
504 return Ok(());
505 }
506
507 let auth = registry_auth(args.token.as_deref())?;
508 block_on_maybe_runtime(async {
509 let client = oci_client();
510 let latest_ref = parse_reference(&args.repo, "latest")?;
511 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
512 bail!(
513 "latest tag `{}` already exists; pass --force to overwrite it",
514 toolchain_ref(&args.repo, "latest")
515 );
516 }
517 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
518 })?;
519 println!("Published {}", toolchain_ref(&args.repo, "latest"));
520 Ok(())
521}
522
523fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
524 ToolchainManifest {
525 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
526 toolchain: TOOLCHAIN_NAME.to_string(),
527 version: "latest".to_string(),
528 channel: Some("latest".to_string()),
529 created_at,
530 packages: latest_manifest_packages(),
531 extension_packs: Some(
532 GREENTIC_EXTENSION_PACK_PACKAGES
533 .iter()
534 .map(|package| ExtensionPackRef {
535 id: package.package.to_string(),
536 version: "latest".to_string(),
537 })
538 .collect(),
539 ),
540 components: Some(
541 GREENTIC_COMPONENT_PACKAGES
542 .iter()
543 .map(|package| ComponentRef {
544 id: package.package.to_string(),
545 version: "latest".to_string(),
546 })
547 .collect(),
548 ),
549 }
550}
551
552fn latest_manifest_packages() -> Vec<ToolchainPackage> {
553 std::iter::once(ToolchainPackage {
554 crate_name: delegated_binary_name_for_channel(
555 TOOLCHAIN_NAME,
556 ToolchainChannel::Development,
557 ),
558 bins: vec![delegated_binary_name_for_channel(
559 TOOLCHAIN_NAME,
560 ToolchainChannel::Development,
561 )],
562 version: "latest".to_string(),
563 })
564 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
565 ToolchainPackage {
566 crate_name: delegated_binary_name_for_channel(
567 package.crate_name,
568 ToolchainChannel::Development,
569 ),
570 bins: package
571 .bins
572 .iter()
573 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
574 .collect(),
575 version: "latest".to_string(),
576 }
577 }))
578 .collect()
579}
580
581fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
582 match (&args.release, &args.tag) {
583 (Some(release), None) => Ok(release.clone()),
584 (None, Some(tag)) => Ok(tag.clone()),
585 _ => bail!("pass exactly one of --release or --tag"),
586 }
587}
588
589pub fn generate_manifest<R: CrateVersionResolver>(
590 release: &str,
591 from: &str,
592 source: Option<&ToolchainManifest>,
593 resolver: &R,
594 created_at: Option<String>,
595) -> Result<ToolchainManifest> {
596 let artifact_resolver = ReleaseArtifactVersionResolver { release };
597 generate_manifest_with_artifact_resolver(
598 release,
599 from,
600 source,
601 resolver,
602 &artifact_resolver,
603 created_at,
604 )
605}
606
607pub fn generate_manifest_with_artifact_resolver<R, A>(
608 release: &str,
609 from: &str,
610 source: Option<&ToolchainManifest>,
611 resolver: &R,
612 artifact_resolver: &A,
613 created_at: Option<String>,
614) -> Result<ToolchainManifest>
615where
616 R: CrateVersionResolver,
617 A: ArtifactVersionResolver,
618{
619 if let Some(source) = source {
620 validate_manifest(source)?;
621 }
622 let source_versions = source_version_map(source);
623 let mut packages = Vec::new();
624 for package in GREENTIC_TOOLCHAIN_PACKAGES {
625 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
626 let source_version = source_versions.get(&crate_in_manifest);
627 let version = match source_version.map(String::as_str) {
628 Some(version) if version != "latest" => Some(version.to_string()),
629 _ => resolve_manifest_version(
630 resolver,
631 &crate_in_manifest,
632 channel_from_source_tag(from),
633 )?,
634 };
635 let Some(version) = version else {
636 continue;
637 };
638 packages.push(ToolchainPackage {
639 crate_name: crate_in_manifest,
640 bins: manifest_bins_for_source(from, package.bins),
641 version,
642 });
643 }
644 Ok(ToolchainManifest {
645 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
646 toolchain: TOOLCHAIN_NAME.to_string(),
647 version: release.to_string(),
648 channel: Some(from.to_string()),
649 created_at,
650 packages,
651 extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
652 components: Some(component_refs_for_release(source, artifact_resolver)?),
653 })
654}
655
656fn channel_from_source_tag(from: &str) -> ToolchainChannel {
658 match from {
659 "dev" => ToolchainChannel::Development,
660 "rnd" => ToolchainChannel::Rnd,
661 _ => ToolchainChannel::Stable,
662 }
663}
664
665fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
666 let channel = channel_from_source_tag(from);
667 bins.iter()
668 .map(|bin| delegated_binary_name_for_channel(bin, channel))
669 .collect()
670}
671
672fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
673 source: Option<&ToolchainManifest>,
674 artifact_resolver: &A,
675) -> Result<Vec<ExtensionPackRef>> {
676 let source_versions = source_ref_version_map(source.and_then(|manifest| {
677 manifest
678 .extension_packs
679 .as_ref()
680 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
681 }));
682 GREENTIC_EXTENSION_PACK_PACKAGES
683 .iter()
684 .map(|package| {
685 Ok(ExtensionPackRef {
686 id: package.package.to_string(),
687 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
688 })
689 })
690 .collect()
691}
692
693fn component_refs_for_release<A: ArtifactVersionResolver>(
694 source: Option<&ToolchainManifest>,
695 artifact_resolver: &A,
696) -> Result<Vec<ComponentRef>> {
697 let source_versions = source_ref_version_map(source.and_then(|manifest| {
698 manifest
699 .components
700 .as_ref()
701 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
702 }));
703 GREENTIC_COMPONENT_PACKAGES
704 .iter()
705 .map(|package| {
706 Ok(ComponentRef {
707 id: package.package.to_string(),
708 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
709 })
710 })
711 .collect()
712}
713
714fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
715where
716 I: Iterator<Item = (&'a String, &'a String)>,
717{
718 let mut out = BTreeMap::new();
719 if let Some(refs) = refs {
720 for (id, version) in refs {
721 out.insert(id.clone(), version.clone());
722 }
723 }
724 out
725}
726
727fn ref_version_for_package(
728 package: &OciPackageSpec,
729 source_versions: &BTreeMap<String, String>,
730 artifact_resolver: &impl ArtifactVersionResolver,
731) -> Result<String> {
732 match source_versions.get(package.package).map(String::as_str) {
733 Some(version) if version != "latest" => Ok(version.to_string()),
734 _ => artifact_resolver
735 .resolve_latest(package.package)
736 .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
737 }
738}
739
740fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
748 if from == "dev" {
749 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
750 } else {
751 crate_name.to_string()
752 }
753}
754
755pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
756 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
757 bail!(
758 "unsupported toolchain manifest schema `{}`",
759 manifest.schema
760 );
761 }
762 if manifest.toolchain != TOOLCHAIN_NAME {
763 bail!("unsupported toolchain `{}`", manifest.toolchain);
764 }
765 Ok(())
766}
767
768pub fn toolchain_ref(repo: &str, tag: &str) -> String {
769 format!("{repo}:{tag}")
770}
771
772fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
773 let mut out = BTreeMap::new();
774 if let Some(source) = source {
775 for package in &source.packages {
776 out.insert(package.crate_name.clone(), package.version.clone());
777 }
778 }
779 out
780}
781
782fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
783 fs::create_dir_all(out_dir)
784 .with_context(|| format!("failed to create {}", out_dir.display()))?;
785 let path = out_dir.join(manifest_file_name(manifest));
786 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
787 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
788 Ok(path)
789}
790
791fn manifest_file_name(manifest: &ToolchainManifest) -> String {
792 match manifest.channel.as_deref() {
793 Some("stable") | None => format!("gtc-{}.json", manifest.version),
794 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
795 }
796}
797
798fn created_at_now() -> Result<String> {
799 OffsetDateTime::now_utc()
800 .format(&Rfc3339)
801 .context("failed to format current time")
802}
803
804pub enum ResearchVersion {
811 Pinned(String),
813 Absent,
817}
818
819pub trait CrateVersionResolver {
820 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
821
822 fn resolve_latest_for_channel(
829 &self,
830 crate_name: &str,
831 _channel: ToolchainChannel,
832 ) -> Result<String> {
833 self.resolve_latest(crate_name)
834 }
835
836 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
842 self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
843 .map(ResearchVersion::Pinned)
844 }
845}
846
847fn default_resolver() -> CratesIoApiVersionResolver {
851 CratesIoApiVersionResolver::default()
852}
853
854pub trait ArtifactVersionResolver {
855 fn resolve_latest(&self, package: &str) -> Result<String>;
856}
857
858const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
859const CRATES_IO_USER_AGENT: &str = concat!(
860 "greentic-dev/",
861 env!("CARGO_PKG_VERSION"),
862 " (https://github.com/greenticai/greentic-dev)"
863);
864
865pub struct CratesIoApiVersionResolver {
872 base_url: String,
873 client: reqwest::blocking::Client,
874}
875
876impl Default for CratesIoApiVersionResolver {
877 fn default() -> Self {
878 Self::new(CRATES_IO_API_BASE)
879 }
880}
881
882impl CratesIoApiVersionResolver {
883 pub fn new(base_url: impl Into<String>) -> Self {
884 let client = reqwest::blocking::Client::builder()
885 .user_agent(CRATES_IO_USER_AGENT)
886 .build()
887 .expect("failed to build crates.io API client");
888 Self {
889 base_url: base_url.into(),
890 client,
891 }
892 }
893
894 fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
899 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
900 let response = self
901 .client
902 .get(&url)
903 .send()
904 .with_context(|| format!("failed to GET {url}"))?;
905 let status = response.status();
906 let body = response
907 .text()
908 .with_context(|| format!("failed to read body of {url}"))?;
909 classify_crate_response(status, &url, body)
910 }
911}
912
913fn classify_crate_response(
917 status: reqwest::StatusCode,
918 url: &str,
919 body: String,
920) -> Result<Option<String>> {
921 if status == reqwest::StatusCode::NOT_FOUND {
922 return Ok(None);
923 }
924 if !status.is_success() {
925 bail!("crates.io API GET {url} returned {status}: {body}");
926 }
927 Ok(Some(body))
928}
929
930fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
936 match parse_crates_io_research_version(crate_name, body) {
937 Ok(version) => Ok(version),
938 Err(_) => {
939 let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
940 eprintln!(
941 "note: `{crate_name}` has no -research publish; the research \
942 toolchain falls back to latest `{fallback}`"
943 );
944 Ok(fallback)
945 }
946 }
947}
948
949struct ReleaseArtifactVersionResolver<'a> {
950 release: &'a str,
951}
952
953impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
954 fn resolve_latest(&self, _package: &str) -> Result<String> {
955 Ok(self.release.to_string())
956 }
957}
958
959struct GhcrArtifactVersionResolver {
960 client: reqwest::blocking::Client,
961 registry: String,
962 namespace: String,
963 basic_token: Option<String>,
964}
965
966impl GhcrArtifactVersionResolver {
967 fn new(raw_token: Option<&str>) -> Result<Self> {
968 Ok(Self {
969 client: reqwest::blocking::Client::builder()
970 .build()
971 .context("failed to build GHCR HTTP client")?,
972 registry: "ghcr.io".to_string(),
973 namespace: "greenticai".to_string(),
974 basic_token: resolve_registry_token(raw_token)?
975 .or_else(|| std::env::var("GHCR_TOKEN").ok())
976 .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
977 })
978 }
979
980 fn bearer_token(&self, repository: &str) -> Result<String> {
981 let scope = format!("repository:{repository}:pull");
982 let mut request = self
983 .client
984 .get(format!("https://{}/token", self.registry))
985 .query(&[
986 ("service", self.registry.as_str()),
987 ("scope", scope.as_str()),
988 ]);
989 if let Some(token) = &self.basic_token {
990 request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
991 }
992 let response = request
993 .send()
994 .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
995 .error_for_status()
996 .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
997 let body: GhcrTokenResponse = response
998 .json()
999 .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1000 Ok(body.token)
1001 }
1002
1003 fn tags(&self, repository: &str) -> Result<Vec<String>> {
1004 let token = self.bearer_token(repository)?;
1005 let response = self
1006 .client
1007 .get(format!(
1008 "https://{}/v2/{repository}/tags/list",
1009 self.registry
1010 ))
1011 .bearer_auth(token)
1012 .send()
1013 .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1014 .error_for_status()
1015 .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1016 let body: GhcrTagsResponse = response
1017 .json()
1018 .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1019 Ok(body.tags)
1020 }
1021}
1022
1023impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1024 fn resolve_latest(&self, package: &str) -> Result<String> {
1025 let repository = format!("{}/{}", self.namespace, package);
1026 let tags = self.tags(&repository)?;
1027 select_latest_artifact_tag(&tags)
1028 .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1029 }
1030}
1031
1032#[derive(Deserialize)]
1033struct GhcrTokenResponse {
1034 token: String,
1035}
1036
1037#[derive(Deserialize)]
1038struct GhcrTagsResponse {
1039 #[serde(default)]
1040 tags: Vec<String>,
1041}
1042
1043fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1044 tags.iter()
1045 .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1046 .max_by(|(left, _), (right, _)| left.cmp(right))
1047 .map(|(_, tag)| tag.clone())
1048 .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1049 .context("no semver or latest tags found")
1050}
1051
1052impl CrateVersionResolver for CratesIoApiVersionResolver {
1053 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1054 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1055 let response = self
1056 .client
1057 .get(&url)
1058 .send()
1059 .with_context(|| format!("failed to GET {url}"))?;
1060 let status = response.status();
1061 let body = response
1062 .text()
1063 .with_context(|| format!("failed to read body of {url}"))?;
1064 if !status.is_success() {
1065 bail!("crates.io API GET {url} returned {status}: {body}");
1066 }
1067 parse_crates_io_version(crate_name, &body)
1068 }
1069
1070 fn resolve_latest_for_channel(
1071 &self,
1072 crate_name: &str,
1073 channel: ToolchainChannel,
1074 ) -> Result<String> {
1075 if channel != ToolchainChannel::Rnd {
1076 return self.resolve_latest(crate_name);
1077 }
1078 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1079 let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1080 anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1081 })?;
1082 research_or_fallback(crate_name, &body)
1083 }
1084
1085 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1086 match self.fetch_crate_body(crate_name)? {
1091 None => Ok(ResearchVersion::Absent),
1092 Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1093 }
1094 }
1095}
1096
1097fn pick_highest_crates_io_version(
1104 crate_name: &str,
1105 body: &str,
1106 research_only: bool,
1107) -> Result<String> {
1108 let payload: serde_json::Value = serde_json::from_str(body)
1109 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1110 let versions = payload
1111 .get("versions")
1112 .and_then(|v| v.as_array())
1113 .ok_or_else(|| {
1114 anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1115 })?;
1116 let mut best: Option<Version> = None;
1117 for entry in versions {
1118 if entry
1119 .get("yanked")
1120 .and_then(serde_json::Value::as_bool)
1121 .unwrap_or(false)
1122 {
1123 continue;
1124 }
1125 let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1126 continue;
1127 };
1128 let Ok(parsed) = Version::parse(num) else {
1129 continue;
1130 };
1131 if research_only && !parsed.pre.as_str().starts_with("research") {
1132 continue;
1133 }
1134 if best.as_ref().is_none_or(|current| parsed > *current) {
1135 best = Some(parsed);
1136 }
1137 }
1138 best.map(|v| v.to_string()).ok_or_else(|| {
1139 let what = if research_only {
1140 "no non-yanked `-research` version"
1141 } else {
1142 "no non-yanked versions"
1143 };
1144 anyhow!("crates.io API for `{crate_name}` exposes {what}")
1145 })
1146}
1147
1148fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1149 pick_highest_crates_io_version(crate_name, body, true)
1150}
1151
1152fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1153 let payload: serde_json::Value = serde_json::from_str(body)
1154 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1155 let crate_obj = payload.get("crate").ok_or_else(|| {
1156 anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1157 })?;
1158 let version = crate_obj
1159 .get("max_stable_version")
1160 .and_then(|v| v.as_str())
1161 .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1162 .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1163 .ok_or_else(|| {
1164 anyhow!(
1165 "crates.io API for `{crate_name}` does not expose max_stable_version, \
1166 newest_version, or max_version"
1167 )
1168 })?;
1169 Version::parse(version).with_context(|| {
1170 format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1171 })?;
1172 Ok(version.to_string())
1173}
1174
1175#[async_trait]
1176trait ToolchainManifestSource {
1177 async fn load_manifest(
1178 &self,
1179 repo: &str,
1180 tag: &str,
1181 token: Option<&str>,
1182 ) -> Result<Option<ToolchainManifest>>;
1183}
1184
1185struct OciToolchainManifestSource;
1186
1187#[async_trait]
1188impl ToolchainManifestSource for OciToolchainManifestSource {
1189 async fn load_manifest(
1190 &self,
1191 repo: &str,
1192 tag: &str,
1193 token: Option<&str>,
1194 ) -> Result<Option<ToolchainManifest>> {
1195 let auth = optional_registry_auth(token)?;
1196 let client = oci_client();
1197 let reference = parse_reference(repo, tag)?;
1198 let image = match client
1199 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1200 .await
1201 {
1202 Ok(image) => image,
1203 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1204 return Ok(None);
1205 }
1206 Err(err) => {
1207 return Err(err)
1208 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1209 }
1210 };
1211 let Some(layer) = image
1212 .layers
1213 .into_iter()
1214 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1215 else {
1216 return Ok(None);
1217 };
1218 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1219 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1220 validate_manifest(&manifest)?;
1221 Ok(Some(manifest))
1222 }
1223}
1224
1225async fn load_source_manifest(
1226 repo: &str,
1227 tag: &str,
1228 token: Option<&str>,
1229) -> Result<Option<ToolchainManifest>> {
1230 OciToolchainManifestSource
1231 .load_manifest(repo, tag, token)
1232 .await
1233}
1234
1235fn oci_client() -> Client {
1236 Client::new(ClientConfig {
1237 protocol: ClientProtocol::Https,
1238 ..Default::default()
1239 })
1240}
1241
1242fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1243 let token = resolve_registry_token(raw_token)?
1244 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1245 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1246 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1247 if token.trim().is_empty() {
1248 bail!("GHCR token is empty");
1249 }
1250 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1251}
1252
1253fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1254 match registry_auth(raw_token) {
1255 Ok(auth) => Ok(auth),
1256 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1257 Err(err) => Err(err),
1258 }
1259}
1260
1261fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1262 let Some(raw_token) = raw_token else {
1263 return Ok(None);
1264 };
1265 if let Some(var) = raw_token.strip_prefix("env:") {
1266 let token =
1267 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1268 if token.trim().is_empty() {
1269 bail!("env var {var} resolved to an empty token");
1270 }
1271 return Ok(Some(token));
1272 }
1273 if raw_token.trim().is_empty() {
1274 bail!("GHCR token is empty");
1275 }
1276 Ok(Some(raw_token.to_string()))
1277}
1278
1279fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1280 Reference::from_str(&toolchain_ref(repo, tag))
1281 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1282}
1283
1284async fn manifest_exists(
1285 client: &Client,
1286 reference: &Reference,
1287 auth: &RegistryAuth,
1288) -> Result<bool> {
1289 match client.pull_manifest(reference, auth).await {
1290 Ok(_) => Ok(true),
1291 Err(err) if is_missing_manifest_error(&err) => Ok(false),
1292 Err(err) => Err(err).context("failed to check whether release tag exists"),
1293 }
1294}
1295
1296fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1297 let msg = err.to_string().to_ascii_lowercase();
1298 msg.contains("manifest unknown")
1299 || msg.contains("name unknown")
1300 || msg.contains("not found")
1301 || msg.contains("404")
1302}
1303
1304fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1305 let msg = err.to_string().to_ascii_lowercase();
1306 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1307}
1308
1309async fn push_manifest_layer(
1310 client: &Client,
1311 reference: &Reference,
1312 auth: &RegistryAuth,
1313 manifest: &ToolchainManifest,
1314) -> Result<()> {
1315 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1316 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1317 let config = Config::new(
1318 br#"{"toolchain":"gtc"}"#.to_vec(),
1319 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1320 None,
1321 );
1322 client
1323 .push(reference, &[layer], config, auth, None)
1324 .await
1325 .context("failed to push toolchain manifest")?;
1326 Ok(())
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use once_cell::sync::Lazy;
1333 use std::sync::Mutex;
1334
1335 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1336
1337 struct FixedResolver;
1338
1339 impl CrateVersionResolver for FixedResolver {
1340 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1341 Ok(match crate_name {
1342 "greentic-runner" => "0.5.10",
1343 _ => "1.2.3",
1344 }
1345 .to_string())
1346 }
1347 }
1348
1349 struct FixedArtifactResolver;
1350
1351 impl ArtifactVersionResolver for FixedArtifactResolver {
1352 fn resolve_latest(&self, package: &str) -> Result<String> {
1353 Ok(match package {
1354 "packs/messaging/messaging-webchat-gui" => "0.4.93",
1355 "components/component-adaptive-card" => "0.5.8",
1356 _ => "0.1.0",
1357 }
1358 .to_string())
1359 }
1360 }
1361
1362 #[test]
1363 fn parses_crates_io_max_stable_version() {
1364 let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1365 let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1366 assert_eq!(version, "0.5.123");
1367 }
1368
1369 #[test]
1370 fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1371 let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1374 "versions":[
1375 {"num":"0.5.48","yanked":false},
1376 {"num":"1.2.0-research.0","yanked":false},
1377 {"num":"1.2.0-research.1","yanked":false},
1378 {"num":"1.2.0-research.2","yanked":true}
1379 ]}"#;
1380 let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1381 assert_eq!(version, "1.2.0-research.1");
1382 }
1383
1384 #[test]
1385 fn research_resolver_errors_when_no_research_version() {
1386 let body = r#"{"crate":{"id":"greentic-setup"},
1387 "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1388 let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1389 assert!(err.to_string().contains("no non-yanked `-research`"));
1390 }
1391
1392 struct ResearchSkipResolver;
1396
1397 impl CrateVersionResolver for ResearchSkipResolver {
1398 fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1399 Ok("1.2.0-research.4".to_string())
1400 }
1401
1402 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1403 if crate_name.contains("operator") {
1404 Ok(ResearchVersion::Absent)
1405 } else {
1406 Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1407 }
1408 }
1409 }
1410
1411 #[test]
1412 fn classify_crate_response_maps_404_to_absent() {
1413 let outcome =
1414 classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1415 .unwrap();
1416 assert!(outcome.is_none(), "404 must classify as absent (None)");
1417 }
1418
1419 #[test]
1420 fn classify_crate_response_returns_body_on_success() {
1421 let outcome =
1422 classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1423 assert_eq!(outcome.as_deref(), Some("payload"));
1424 }
1425
1426 #[test]
1427 fn classify_crate_response_errors_on_other_status() {
1428 let err = classify_crate_response(
1429 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1430 "url",
1431 "boom".to_string(),
1432 )
1433 .unwrap_err();
1434 assert!(err.to_string().contains("500"));
1435 }
1436
1437 #[test]
1438 fn snapshot_manifest_skips_crates_without_research_build() {
1439 let manifest = snapshot_manifest(
1442 "1.2.0-research.4",
1443 ToolchainChannel::Rnd,
1444 &ResearchSkipResolver,
1445 None,
1446 )
1447 .unwrap();
1448 assert!(
1449 !manifest.packages.is_empty(),
1450 "research-built tools must remain in the manifest"
1451 );
1452 assert!(
1453 manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1454 "at least one tool without a research build must be skipped"
1455 );
1456 assert!(
1457 manifest
1458 .packages
1459 .iter()
1460 .all(|package| !package.crate_name.contains("operator")),
1461 "the absent `operator` crate must be omitted"
1462 );
1463 assert!(
1464 manifest
1465 .packages
1466 .iter()
1467 .all(|package| package.version == "1.2.0-research.4"),
1468 "remaining research tools pin their resolved -research version"
1469 );
1470 }
1471
1472 #[test]
1473 fn parses_crates_io_falls_back_to_newest_version() {
1474 let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1475 let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1476 assert_eq!(version, "0.6.7");
1477 }
1478
1479 #[test]
1480 fn parses_crates_io_falls_back_to_max_version() {
1481 let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1482 let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1483 assert_eq!(version, "0.4.99");
1484 }
1485
1486 #[test]
1487 fn rejects_crates_io_payload_without_versions() {
1488 let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1489 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1490 assert!(
1491 err.to_string()
1492 .contains("does not expose max_stable_version")
1493 );
1494 }
1495
1496 #[test]
1497 fn rejects_crates_io_payload_with_unparseable_version() {
1498 let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1499 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1500 assert!(err.to_string().contains("unparseable version"));
1501 }
1502
1503 #[test]
1504 fn rejects_crates_io_payload_without_crate_object() {
1505 let body = r#"{"errors":[{"detail":"not found"}]}"#;
1506 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1507 assert!(
1508 err.to_string()
1509 .contains("missing the top-level `crate` object")
1510 );
1511 }
1512
1513 #[test]
1514 fn selects_latest_semver_tag() {
1515 let tags = vec![
1516 "latest".to_string(),
1517 "0.4.93".to_string(),
1518 "0.4.9".to_string(),
1519 "1.0.0-beta.1".to_string(),
1520 "1.0.0".to_string(),
1521 ];
1522
1523 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1524 }
1525
1526 #[test]
1527 fn selects_latest_tag_when_no_semver_tags_exist() {
1528 let tags = vec!["latest".to_string()];
1529
1530 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1531 }
1532
1533 #[test]
1534 fn generates_manifest_from_catalogue() {
1535 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1536 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1537 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1538 assert_eq!(manifest.version, "1.0.5");
1539 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1540 assert!(
1541 manifest
1542 .packages
1543 .iter()
1544 .any(|package| package.crate_name == "greentic-bundle"
1545 && package.bins == ["greentic-bundle"])
1546 );
1547 assert!(
1548 manifest
1549 .packages
1550 .iter()
1551 .any(|package| package.crate_name == "greentic-runner"
1552 && package.bins == ["greentic-runner"])
1553 );
1554 assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1555 assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1556 assert!(
1557 manifest
1558 .extension_packs
1559 .as_ref()
1560 .unwrap()
1561 .iter()
1562 .all(|item| item.version == "1.0.5")
1563 );
1564 assert!(
1565 manifest
1566 .components
1567 .as_ref()
1568 .unwrap()
1569 .iter()
1570 .all(|item| item.version == "1.0.5")
1571 );
1572 }
1573
1574 #[test]
1575 fn generated_manifest_can_use_artifact_resolver_versions() {
1576 let manifest = generate_manifest_with_artifact_resolver(
1577 "1.0.17",
1578 "stable",
1579 None,
1580 &FixedResolver,
1581 &FixedArtifactResolver,
1582 None,
1583 )
1584 .unwrap();
1585
1586 assert!(
1587 manifest
1588 .extension_packs
1589 .as_ref()
1590 .unwrap()
1591 .iter()
1592 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1593 && item.version == "0.4.93")
1594 );
1595 assert!(
1596 manifest
1597 .components
1598 .as_ref()
1599 .unwrap()
1600 .iter()
1601 .any(|item| item.id == "components/component-adaptive-card"
1602 && item.version == "0.5.8")
1603 );
1604 }
1605
1606 #[test]
1607 fn source_manifest_can_pin_package_versions() {
1608 let source = ToolchainManifest {
1609 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1610 toolchain: TOOLCHAIN_NAME.to_string(),
1611 version: "latest".to_string(),
1612 channel: Some("latest".to_string()),
1613 created_at: None,
1614 packages: vec![ToolchainPackage {
1615 crate_name: "greentic-dev".to_string(),
1616 bins: vec!["greentic-dev".to_string()],
1617 version: "0.5.9".to_string(),
1618 }],
1619 extension_packs: None,
1620 components: None,
1621 };
1622 let manifest =
1623 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1624 let greentic_dev = manifest
1625 .packages
1626 .iter()
1627 .find(|package| package.crate_name == "greentic-dev")
1628 .unwrap();
1629 assert_eq!(greentic_dev.version, "0.5.9");
1630 }
1631
1632 #[test]
1633 fn from_argument_controls_generated_channel_over_source_manifest() {
1634 let source = ToolchainManifest {
1635 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1636 toolchain: TOOLCHAIN_NAME.to_string(),
1637 version: "latest".to_string(),
1638 channel: Some("stable".to_string()),
1639 created_at: None,
1640 packages: Vec::new(),
1641 extension_packs: None,
1642 components: None,
1643 };
1644 let manifest =
1645 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1646 assert_eq!(manifest.channel.as_deref(), Some("dev"));
1647 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1648 }
1649
1650 #[test]
1651 fn generate_from_dev_uses_dev_crate_and_binary_names() {
1652 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1653 assert!(
1654 manifest
1655 .packages
1656 .iter()
1657 .flat_map(|package| package.bins.iter())
1658 .all(|bin| bin.ends_with("-dev"))
1659 );
1660 assert!(
1661 manifest
1662 .packages
1663 .iter()
1664 .all(|package| package.crate_name.ends_with("-dev")),
1665 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1666 );
1667 assert!(manifest.packages.iter().any(|package| {
1668 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1669 }));
1670 assert!(manifest.packages.iter().any(|package| {
1671 package.crate_name == "greentic-component-dev"
1672 && package.bins == ["greentic-component-dev"]
1673 }));
1674 assert!(manifest.packages.iter().any(|package| {
1675 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1676 }));
1677 }
1678
1679 #[test]
1680 fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1681 let manifest =
1682 snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1683 .unwrap();
1684 assert_eq!(manifest.version, "1.1.5");
1685 assert_eq!(manifest.channel.as_deref(), Some("dev"));
1686 for package in &manifest.packages {
1687 assert!(
1688 package.crate_name.ends_with("-dev"),
1689 "dev snapshot must pin -dev crate names; got {}",
1690 package.crate_name
1691 );
1692 assert!(
1693 package.bins.iter().all(|bin| bin.ends_with("-dev")),
1694 "dev snapshot must pin -dev bin names; got {:?}",
1695 package.bins
1696 );
1697 assert_ne!(
1698 package.version, "latest",
1699 "snapshot must always resolve concrete versions"
1700 );
1701 }
1702 assert!(
1703 manifest
1704 .packages
1705 .iter()
1706 .any(|package| package.crate_name == "greentic-operator-dev")
1707 );
1708 }
1709
1710 #[test]
1711 fn snapshot_manifest_stable_channel_keeps_plain_names() {
1712 let manifest =
1713 snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1714 assert_eq!(manifest.channel.as_deref(), Some("stable"));
1715 let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1719 .iter()
1720 .map(|spec| spec.crate_name)
1721 .collect();
1722 for package in &manifest.packages {
1723 assert!(
1724 catalogue_names.contains(package.crate_name.as_str()),
1725 "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1726 package.crate_name
1727 );
1728 }
1729 }
1730
1731 #[test]
1732 fn snapshot_manifest_resolves_via_resolver() {
1733 let manifest =
1734 snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1735 .unwrap();
1736 for package in &manifest.packages {
1742 assert_eq!(
1743 package.version, "1.2.3",
1744 "resolver must be hit for {}",
1745 package.crate_name
1746 );
1747 }
1748 }
1749
1750 #[test]
1751 fn parses_dev_channel_argument() {
1752 assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1753 assert_eq!(
1754 parse_channel("development").unwrap(),
1755 ToolchainChannel::Development
1756 );
1757 assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1758 assert!(parse_channel("rc").is_err());
1759 }
1760
1761 #[test]
1762 fn detects_concrete_pins_for_publish_deprecation_warning() {
1763 let with_pins = ToolchainManifest {
1764 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1765 toolchain: TOOLCHAIN_NAME.to_string(),
1766 version: "0.0.1".to_string(),
1767 channel: Some("dev".to_string()),
1768 created_at: None,
1769 packages: vec![ToolchainPackage {
1770 crate_name: "greentic-operator-dev".to_string(),
1771 bins: vec!["greentic-operator-dev".to_string()],
1772 version: "0.5.123".to_string(),
1773 }],
1774 extension_packs: None,
1775 components: None,
1776 };
1777 assert!(source_manifest_has_concrete_pins(&with_pins));
1778
1779 let only_latest = ToolchainManifest {
1780 packages: vec![ToolchainPackage {
1781 crate_name: "greentic-operator".to_string(),
1782 bins: vec!["greentic-operator".to_string()],
1783 version: "latest".to_string(),
1784 }],
1785 ..with_pins
1786 };
1787 assert!(!source_manifest_has_concrete_pins(&only_latest));
1788 }
1789
1790 #[test]
1791 fn generate_from_rnd_uses_rnd_binary_names() {
1792 let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1793 assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1794 assert!(
1795 manifest
1796 .packages
1797 .iter()
1798 .flat_map(|package| package.bins.iter())
1799 .all(|bin| bin.ends_with("-rnd"))
1800 );
1801 assert!(manifest.packages.iter().any(|package| {
1802 package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1803 }));
1804 }
1805
1806 #[test]
1807 fn bootstrap_source_manifest_uses_source_tag_identity() {
1808 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1809 assert_eq!(manifest.version, "latest");
1810 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1811 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1812 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1813 assert!(
1814 manifest
1815 .packages
1816 .iter()
1817 .all(|package| package.version != "latest")
1818 );
1819 }
1820
1821 #[test]
1822 fn validates_schema_and_toolchain() {
1823 let mut manifest =
1824 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1825 assert!(validate_manifest(&manifest).is_ok());
1826 manifest.schema = "wrong".to_string();
1827 assert!(validate_manifest(&manifest).is_err());
1828 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1829 manifest.toolchain = "other".to_string();
1830 assert!(validate_manifest(&manifest).is_err());
1831 }
1832
1833 #[test]
1834 fn resolves_inline_registry_token() {
1835 assert_eq!(
1836 resolve_registry_token(Some("secret-token"))
1837 .unwrap()
1838 .as_deref(),
1839 Some("secret-token")
1840 );
1841 }
1842
1843 #[test]
1844 fn resolves_registry_token_from_environment_reference() {
1845 let _guard = ENV_LOCK.lock().unwrap();
1846 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1847 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1848
1849 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1850 assert_eq!(resolved.as_deref(), Some("env-secret"));
1851
1852 match previous {
1853 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1854 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1855 }
1856 }
1857
1858 #[test]
1859 fn rejects_empty_registry_token_from_environment_reference() {
1860 let _guard = ENV_LOCK.lock().unwrap();
1861 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1862 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
1863
1864 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1865 assert!(err.to_string().contains("resolved to an empty token"));
1866
1867 match previous {
1868 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1869 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1870 }
1871 }
1872
1873 #[test]
1874 fn registry_auth_uses_environment_fallbacks() {
1875 let _guard = ENV_LOCK.lock().unwrap();
1876 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1877 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1878 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1879 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1880
1881 let auth = registry_auth(None).unwrap();
1882 match auth {
1883 RegistryAuth::Basic(user, token) => {
1884 assert_eq!(user, DEFAULT_OAUTH_USER);
1885 assert_eq!(token, "ghcr-secret");
1886 }
1887 _ => panic!("expected basic auth"),
1888 }
1889
1890 match previous_ghcr {
1891 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1892 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1893 }
1894 match previous_github {
1895 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1896 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1897 }
1898 }
1899
1900 #[test]
1901 fn optional_registry_auth_allows_missing_implicit_token() {
1902 let _guard = ENV_LOCK.lock().unwrap();
1903 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1904 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1905 unsafe { std::env::remove_var("GHCR_TOKEN") };
1906 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1907
1908 let auth = optional_registry_auth(None).unwrap();
1909 assert!(matches!(auth, RegistryAuth::Anonymous));
1910
1911 match previous_ghcr {
1912 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1913 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1914 }
1915 match previous_github {
1916 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1917 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1918 }
1919 }
1920
1921 #[test]
1922 fn release_view_tag_prefers_release_or_tag() {
1923 let args = ReleaseViewArgs {
1924 release: Some("1.0.5".to_string()),
1925 tag: None,
1926 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1927 token: None,
1928 };
1929 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1930
1931 let args = ReleaseViewArgs {
1932 release: None,
1933 tag: Some("stable".to_string()),
1934 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1935 token: None,
1936 };
1937 assert_eq!(release_view_tag(&args).unwrap(), "stable");
1938 }
1939
1940 #[test]
1941 fn release_view_tag_rejects_invalid_argument_combinations() {
1942 let err = release_view_tag(&ReleaseViewArgs {
1943 release: Some("1.0.5".to_string()),
1944 tag: Some("stable".to_string()),
1945 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1946 token: None,
1947 })
1948 .unwrap_err();
1949 assert!(
1950 err.to_string()
1951 .contains("pass exactly one of --release or --tag")
1952 );
1953
1954 let err = release_view_tag(&ReleaseViewArgs {
1955 release: None,
1956 tag: None,
1957 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1958 token: None,
1959 })
1960 .unwrap_err();
1961 assert!(
1962 err.to_string()
1963 .contains("pass exactly one of --release or --tag")
1964 );
1965 }
1966
1967 #[test]
1968 fn publish_manifest_input_uses_local_manifest_version() {
1969 let dir = tempfile::tempdir().unwrap();
1970 let path = dir.path().join("gtc-1.0.12.json");
1971 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1972 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1973 let args = ReleasePublishArgs {
1974 release: None,
1975 from: None,
1976 tag: Some("stable".to_string()),
1977 manifest: Some(path.clone()),
1978 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1979 token: None,
1980 out: dir.path().to_path_buf(),
1981 dry_run: true,
1982 force: true,
1983 };
1984 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1985 assert_eq!(release, "1.0.12");
1986 assert_eq!(loaded, manifest);
1987 assert_eq!(
1988 source_path,
1989 Some(PublishManifestSource::Local(path.clone()))
1990 );
1991 }
1992
1993 #[test]
1994 fn publish_manifest_input_allows_release_override_for_local_manifest() {
1995 let dir = tempfile::tempdir().unwrap();
1996 let path = dir.path().join("gtc-1.0.13.json");
1997 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1998 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1999 let args = ReleasePublishArgs {
2000 release: Some("1.0.13".to_string()),
2001 from: None,
2002 tag: Some("stable".to_string()),
2003 manifest: Some(path.clone()),
2004 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2005 token: None,
2006 out: dir.path().to_path_buf(),
2007 dry_run: true,
2008 force: true,
2009 };
2010 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2011 assert_eq!(release, "1.0.13");
2012 assert_eq!(loaded.version, "1.0.13");
2013 assert_eq!(
2014 source_path,
2015 Some(PublishManifestSource::Local(path.clone()))
2016 );
2017 }
2018
2019 #[test]
2020 fn manifest_file_name_omits_stable_channel() {
2021 let manifest = ToolchainManifest {
2022 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2023 toolchain: TOOLCHAIN_NAME.to_string(),
2024 version: "1.0.12".to_string(),
2025 channel: Some("stable".to_string()),
2026 created_at: None,
2027 packages: Vec::new(),
2028 extension_packs: None,
2029 components: None,
2030 };
2031 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2032 }
2033
2034 #[test]
2035 fn parses_manifest_without_extension_sections() {
2036 let manifest: ToolchainManifest = serde_json::from_str(
2037 r#"{
2038 "schema": "greentic.toolchain-manifest.v1",
2039 "toolchain": "gtc",
2040 "version": "1.0.16",
2041 "channel": "stable",
2042 "packages": []
2043 }"#,
2044 )
2045 .unwrap();
2046
2047 assert_eq!(manifest.extension_packs, None);
2048 assert_eq!(manifest.components, None);
2049 }
2050
2051 #[test]
2052 fn generated_manifest_includes_catalogue_extension_sections() {
2053 let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2054 let json = serde_json::to_value(&manifest).unwrap();
2055
2056 assert!(json.get("extension_packs").is_some());
2057 assert!(json.get("components").is_some());
2058 assert!(
2059 manifest
2060 .extension_packs
2061 .as_ref()
2062 .unwrap()
2063 .iter()
2064 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2065 && item.version == "1.0.16")
2066 );
2067 assert!(
2068 manifest
2069 .extension_packs
2070 .as_ref()
2071 .unwrap()
2072 .iter()
2073 .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2074 && item.version == "1.0.16")
2075 );
2076 assert!(
2077 manifest
2078 .components
2079 .as_ref()
2080 .unwrap()
2081 .iter()
2082 .any(|item| item.id == "component/component-llm-openai"
2083 && item.version == "1.0.16")
2084 );
2085 }
2086
2087 #[test]
2088 fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2089 let source = ToolchainManifest {
2090 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2091 toolchain: TOOLCHAIN_NAME.to_string(),
2092 version: "dev".to_string(),
2093 channel: Some("dev".to_string()),
2094 created_at: None,
2095 packages: Vec::new(),
2096 extension_packs: Some(vec![ExtensionPackRef {
2097 id: "packs/messaging/messaging-webchat-gui".to_string(),
2098 version: "0.5.4".to_string(),
2099 }]),
2100 components: Some(vec![ComponentRef {
2101 id: "components/component-adaptive-card".to_string(),
2102 version: "0.5.8".to_string(),
2103 }]),
2104 };
2105
2106 let manifest =
2107 generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2108
2109 assert!(
2110 manifest
2111 .extension_packs
2112 .as_ref()
2113 .unwrap()
2114 .iter()
2115 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2116 && item.version == "0.5.4")
2117 );
2118 assert!(
2119 manifest
2120 .components
2121 .as_ref()
2122 .unwrap()
2123 .iter()
2124 .any(|item| item.id == "components/component-adaptive-card"
2125 && item.version == "0.5.8")
2126 );
2127 }
2128
2129 #[test]
2130 fn manifest_file_name_includes_non_stable_channel() {
2131 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2132 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2133
2134 manifest.channel = Some("customer-a".to_string());
2135 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2136 }
2137
2138 #[test]
2139 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2140 assert_eq!(
2141 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2142 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2143 );
2144 assert_eq!(
2145 manifest_bins_for_source("dev", &["greentic-dev"]),
2146 vec!["greentic-dev-dev".to_string()]
2147 );
2148 assert_eq!(
2149 manifest_crate_name_for_source("latest", "greentic-runner"),
2150 "greentic-runner"
2151 );
2152 assert_eq!(
2153 manifest_crate_name_for_source("dev", "greentic-runner"),
2154 "greentic-runner-dev"
2155 );
2156 }
2157
2158 #[test]
2159 fn source_version_map_handles_missing_and_present_sources() {
2160 assert!(source_version_map(None).is_empty());
2161
2162 let source = ToolchainManifest {
2163 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2164 toolchain: TOOLCHAIN_NAME.to_string(),
2165 version: "latest".to_string(),
2166 channel: Some("latest".to_string()),
2167 created_at: None,
2168 packages: vec![ToolchainPackage {
2169 crate_name: "greentic-dev".to_string(),
2170 bins: vec!["greentic-dev".to_string()],
2171 version: "0.6.0".to_string(),
2172 }],
2173 extension_packs: None,
2174 components: None,
2175 };
2176
2177 let versions = source_version_map(Some(&source));
2178 assert_eq!(
2179 versions.get("greentic-dev").map(String::as_str),
2180 Some("0.6.0")
2181 );
2182 }
2183
2184 #[test]
2185 fn write_manifest_persists_json_to_expected_file_name() {
2186 let dir = tempfile::tempdir().unwrap();
2187 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2188
2189 let path = write_manifest(dir.path(), &manifest).unwrap();
2190 assert_eq!(
2191 path.file_name().and_then(|name| name.to_str()),
2192 Some("gtc-dev-1.0.12.json")
2193 );
2194
2195 let roundtrip = read_manifest_file(&path).unwrap();
2196 assert_eq!(roundtrip, manifest);
2197 }
2198
2199 #[test]
2200 fn latest_manifest_uses_latest_dev_bins() {
2201 let manifest = latest_manifest(None);
2202 assert_eq!(manifest.version, "latest");
2203 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2204 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2205 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2206 assert!(!manifest.packages.is_empty());
2207 assert!(
2208 manifest
2209 .packages
2210 .iter()
2211 .all(|package| package.version == "latest")
2212 );
2213 assert!(
2214 manifest
2215 .packages
2216 .iter()
2217 .flat_map(|package| package.bins.iter())
2218 .all(|bin| bin.ends_with("-dev"))
2219 );
2220 assert!(
2221 manifest
2222 .packages
2223 .iter()
2224 .all(|package| package.crate_name.ends_with("-dev")),
2225 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2226 );
2227 assert!(
2228 manifest
2229 .packages
2230 .iter()
2231 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2232 );
2233 assert!(manifest.packages.iter().any(|package| {
2234 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2235 }));
2236 assert!(
2237 manifest
2238 .extension_packs
2239 .as_ref()
2240 .unwrap()
2241 .iter()
2242 .all(|item| item.version == "latest")
2243 );
2244 assert!(
2245 manifest
2246 .components
2247 .as_ref()
2248 .unwrap()
2249 .iter()
2250 .all(|item| item.version == "latest")
2251 );
2252 }
2253
2254 #[test]
2255 fn publish_dry_run_with_local_manifest_succeeds() {
2256 let dir = tempfile::tempdir().unwrap();
2257 let path = dir.path().join("gtc-1.0.12.json");
2258 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2259 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2260
2261 publish(ReleasePublishArgs {
2262 release: None,
2263 from: None,
2264 tag: Some("stable".to_string()),
2265 manifest: Some(path),
2266 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2267 token: None,
2268 out: dir.path().to_path_buf(),
2269 dry_run: true,
2270 force: false,
2271 })
2272 .unwrap();
2273 }
2274
2275 #[test]
2276 fn latest_dry_run_succeeds() {
2277 latest(ReleaseLatestArgs {
2278 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2279 token: None,
2280 dry_run: true,
2281 force: false,
2282 })
2283 .unwrap();
2284 }
2285
2286 #[test]
2287 fn promote_dry_run_succeeds() {
2288 promote(ReleasePromoteArgs {
2289 release: "1.0.12".to_string(),
2290 tag: "stable".to_string(),
2291 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2292 token: None,
2293 dry_run: true,
2294 })
2295 .unwrap();
2296 }
2297
2298 #[test]
2299 fn builds_toolchain_ref() {
2300 assert_eq!(
2301 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
2302 "ghcr.io/greenticai/greentic-versions/gtc:stable"
2303 );
2304 }
2305}