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