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