1use std::collections::{BTreeMap, BTreeSet};
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 greentic_distributor_client::oci_client::Reference;
9use greentic_distributor_client::oci_client::client::{
10 Client, ClientConfig, ClientProtocol, Config, ImageLayer,
11};
12use greentic_distributor_client::oci_client::secrets::RegistryAuth;
13use semver::Version;
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16use time::format_description::well_known::Rfc3339;
17
18use crate::cli::{
19 ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs,
20 ReleaseSnapshotArgs, ReleaseViewArgs, SnapshotSource,
21};
22use crate::install::block_on_maybe_runtime;
23use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
24use crate::toolchain_catalogue::{
25 GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
26 OciPackageSpec,
27};
28
29const DEFAULT_OAUTH_USER: &str = "oauth2";
30pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
31pub const TOOLCHAIN_NAME: &str = "gtc";
32pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
33const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct ToolchainManifest {
37 pub schema: String,
38 pub toolchain: String,
39 pub version: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub channel: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub created_at: Option<String>,
44 pub packages: Vec<ToolchainPackage>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub extension_packs: Option<Vec<ExtensionPackRef>>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub components: Option<Vec<ComponentRef>>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub gtc: Option<Vec<GtcArtifactRef>>,
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
62pub struct GtcArtifactRef {
63 pub target: String,
64 pub url: String,
65 pub sha256: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct ToolchainPackage {
71 #[serde(rename = "crate")]
72 pub crate_name: String,
73 pub bins: Vec<String>,
74 pub version: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub artifacts: Option<Vec<PackageArtifactRef>>,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
87pub struct PackageArtifactRef {
88 pub target: String,
89 pub url: String,
90 pub sha256: String,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
95pub struct ExtensionPackRef {
96 pub id: String,
97 pub version: String,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
101pub struct ComponentRef {
102 pub id: String,
103 pub version: String,
104}
105
106pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
107 let resolver = default_resolver();
108 let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
109 let source = block_on_maybe_runtime(load_source_manifest(
110 &args.repo,
111 &args.from,
112 args.token.as_deref(),
113 ))
114 .with_context(|| {
115 format!(
116 "failed to resolve source manifest `{}`",
117 toolchain_ref(&args.repo, &args.from)
118 )
119 })?;
120 let source = match source {
121 Some(source) => Some(source),
122 None => bootstrap_source_manifest_if_needed(
123 &args.repo,
124 &args.from,
125 args.token.as_deref(),
126 args.dry_run,
127 &resolver,
128 )?,
129 };
130 let manifest = generate_manifest_with_artifact_resolver(
131 &args.release,
132 &args.from,
133 source.as_ref(),
134 &resolver,
135 &artifact_resolver,
136 Some(created_at_now()?),
137 )?;
138 if args.dry_run {
139 println!("{}", serde_json::to_string_pretty(&manifest)?);
140 return Ok(());
141 }
142 let path = write_manifest(&args.out, &manifest)?;
143 println!("Wrote {}", path.display());
144 Ok(())
145}
146
147fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
148 repo: &str,
149 tag: &str,
150 token: Option<&str>,
151 dry_run: bool,
152 resolver: &R,
153) -> Result<Option<ToolchainManifest>> {
154 let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
155 if dry_run {
156 eprintln!(
157 "Dry run: would bootstrap missing source manifest {}",
158 toolchain_ref(repo, tag)
159 );
160 return Ok(Some(manifest));
161 }
162
163 let auth = match optional_registry_auth(token)? {
164 RegistryAuth::Anonymous => {
165 eprintln!(
166 "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
167 toolchain_ref(repo, tag)
168 );
169 return Ok(Some(manifest));
170 }
171 auth => auth,
172 };
173 block_on_maybe_runtime(async {
174 let client = oci_client();
175 let source_ref = parse_reference(repo, tag)?;
176 push_manifest_layer(&client, &source_ref, &auth, &manifest).await
177 })
178 .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
179 println!("Bootstrapped {}", toolchain_ref(repo, tag));
180 Ok(Some(manifest))
181}
182
183fn bootstrap_source_manifest<R: CrateVersionResolver>(
184 tag: &str,
185 resolver: &R,
186 created_at: Option<String>,
187) -> Result<ToolchainManifest> {
188 generate_manifest(tag, tag, None, resolver, created_at)
189}
190
191pub fn publish(args: ReleasePublishArgs) -> Result<()> {
192 let checker = default_release_checker(args.token.as_deref());
193 publish_with_checker(args, &checker)
194}
195
196fn publish_with_checker(args: ReleasePublishArgs, checker: &dyn ReleaseAssetChecker) -> Result<()> {
197 let (release, mut manifest, source) = publish_manifest_input(&args)?;
198
199 verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
203
204 if manifest.gtc.is_none() {
211 manifest.gtc = gtc_artifacts_for(&manifest.version, checker)?;
212 }
213
214 if args.dry_run {
215 println!(
216 "Dry run: would publish {}",
217 toolchain_ref(&args.repo, &release)
218 );
219 if let Some(tag) = &args.tag {
220 println!(
221 "Dry run: would tag {} as {}",
222 toolchain_ref(&args.repo, &release),
223 toolchain_ref(&args.repo, tag)
224 );
225 }
226 return Ok(());
227 }
228
229 let auth = registry_auth(args.token.as_deref())?;
230 block_on_maybe_runtime(async {
231 let client = oci_client();
232 let release_ref = parse_reference(&args.repo, &release)?;
233 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
234 bail!(
235 "release tag `{}` already exists; pass --force to overwrite it",
236 toolchain_ref(&args.repo, &release)
237 );
238 }
239 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
240 if let Some(tag) = &args.tag {
241 let tag_ref = parse_reference(&args.repo, tag)?;
242 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
243 }
244 Ok(())
245 })?;
246
247 if let Some(source) = source {
248 match source {
249 PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
250 PublishManifestSource::Local(path) => println!("Read {}", path.display()),
251 }
252 }
253 println!("Published {}", toolchain_ref(&args.repo, &release));
254 if let Some(tag) = &args.tag {
255 println!("Updated {}", toolchain_ref(&args.repo, tag));
256 }
257
258 if !args.no_notify_updater {
259 let channel = manifest.channel.as_deref().unwrap_or("");
260 notify_updater_dispatch(&release, channel, args.token.as_deref());
261 }
262
263 Ok(())
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267enum PublishManifestSource {
268 Generated(PathBuf),
269 Local(PathBuf),
270}
271
272fn publish_manifest_input(
273 args: &ReleasePublishArgs,
274) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
275 if let Some(path) = &args.manifest {
276 let mut manifest = read_manifest_file(path)?;
277 validate_manifest(&manifest)?;
278 let release = if let Some(release) = &args.release {
279 manifest.version = release.clone();
280 release.clone()
281 } else {
282 manifest.version.clone()
283 };
284 return Ok((
285 release,
286 manifest,
287 Some(PublishManifestSource::Local(path.clone())),
288 ));
289 }
290
291 let release = args
292 .release
293 .as_deref()
294 .context("pass --release or --manifest")?;
295 let from = args.from.as_deref().unwrap_or("latest");
296 let resolver = default_resolver();
297 let source = block_on_maybe_runtime(load_source_manifest(
298 &args.repo,
299 from,
300 args.token.as_deref(),
301 ))
302 .with_context(|| {
303 format!(
304 "failed to resolve source manifest `{}`",
305 toolchain_ref(&args.repo, from)
306 )
307 })?;
308 if let Some(source_manifest) = source.as_ref()
309 && source_manifest_has_concrete_pins(source_manifest)
310 {
311 eprintln!(
312 "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
313 of querying crates.io. To refresh a channel from the latest crates.io versions, use \
314 `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
315 re-resolving, use `release promote`. The conflated `--from` semantics will be \
316 removed in a future release.",
317 toolchain_ref(&args.repo, from),
318 );
319 }
320 let manifest = generate_manifest(
321 release,
322 from,
323 source.as_ref(),
324 &resolver,
325 Some(created_at_now()?),
326 )?;
327 let path = if args.dry_run {
328 println!("{}", serde_json::to_string_pretty(&manifest)?);
329 None
330 } else {
331 Some(PublishManifestSource::Generated(write_manifest(
332 &args.out, &manifest,
333 )?))
334 };
335 Ok((release.to_string(), manifest, path))
336}
337
338fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
343 manifest
344 .packages
345 .iter()
346 .any(|package| package.version != "latest")
347}
348
349fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
350 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
351 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
352}
353
354pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
355 if args.dry_run {
356 println!(
357 "Dry run: would promote {} to {}",
358 toolchain_ref(&args.repo, &args.release),
359 toolchain_ref(&args.repo, &args.tag)
360 );
361 return Ok(());
362 }
363
364 let source = block_on_maybe_runtime(load_source_manifest(
369 &args.repo,
370 &args.release,
371 args.token.as_deref(),
372 ))
373 .ok()
374 .flatten();
375 match source.as_ref() {
376 Some(manifest) => {
377 let checker = default_release_checker(args.token.as_deref());
378 verify_manifest_releases(manifest, Some(args.tag.as_str()), &checker)?;
379 }
380 None if args.tag == "stable" => bail!(
384 "cannot read the toolchain pins of `{}` — refusing to move `:stable` unverified",
385 toolchain_ref(&args.repo, &args.release)
386 ),
387 None => {}
388 }
389
390 let auth = registry_auth(args.token.as_deref())?;
391 block_on_maybe_runtime(async {
392 let client = oci_client();
393 let source_ref = parse_reference(&args.repo, &args.release)?;
394 let target_ref = parse_reference(&args.repo, &args.tag)?;
395 let (manifest, _) = client
396 .pull_manifest(&source_ref, &auth)
397 .await
398 .with_context(|| {
399 format!(
400 "failed to resolve source release `{}`",
401 toolchain_ref(&args.repo, &args.release)
402 )
403 })?;
404 client
405 .push_manifest(&target_ref, &manifest)
406 .await
407 .with_context(|| {
408 format!(
409 "failed to update tag `{}`",
410 toolchain_ref(&args.repo, &args.tag)
411 )
412 })?;
413 Ok(())
414 })?;
415 println!(
416 "Promoted {} to {}",
417 toolchain_ref(&args.repo, &args.release),
418 toolchain_ref(&args.repo, &args.tag)
419 );
420
421 if !args.no_notify_updater {
422 let channel = source.and_then(|m| m.channel);
425 match channel.as_deref() {
426 Some(ch) => notify_updater_dispatch(&args.release, ch, args.token.as_deref()),
427 None => {
428 eprintln!(
429 "Skipping updater dispatch: could not determine channel \
430 for {} (use workflow_dispatch as fallback)",
431 toolchain_ref(&args.repo, &args.release)
432 );
433 }
434 }
435 }
436
437 Ok(())
438}
439
440pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
448 let checker = default_release_checker(args.token.as_deref());
449 snapshot_with_checker(args, &checker)
450}
451
452fn snapshot_with_checker(
453 args: ReleaseSnapshotArgs,
454 checker: &dyn ReleaseAssetChecker,
455) -> Result<()> {
456 let channel = parse_channel(&args.channel)?;
457 let mut manifest = match args.source {
458 SnapshotSource::CratesIo => {
459 let resolver = CratesIoApiVersionResolver::default();
460 snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?
461 }
462 SnapshotSource::GithubReleases => {
463 if channel != ToolchainChannel::Development {
468 bail!(
469 "--source github-releases resolves the dev channel only, not `{}`",
470 args.channel
471 );
472 }
473 let source =
474 crate::release_github_source::GithubDevReleaseSource::new(args.token.as_deref())?;
475 crate::release_github_source::snapshot_manifest_from_github_releases(
476 &args.release,
477 &source,
478 Some(created_at_now()?),
479 )?
480 }
481 };
482
483 verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
489
490 if manifest.gtc.is_none() {
491 manifest.gtc = gtc_artifacts_for(&manifest.version, checker)?;
492 }
493
494 if args.dry_run {
495 println!("{}", serde_json::to_string_pretty(&manifest)?);
496 println!(
497 "Dry run: would publish {}",
498 toolchain_ref(&args.repo, &args.release)
499 );
500 if let Some(tag) = &args.tag {
501 println!(
502 "Dry run: would tag {} as {}",
503 toolchain_ref(&args.repo, &args.release),
504 toolchain_ref(&args.repo, tag)
505 );
506 }
507 return Ok(());
508 }
509
510 let path = write_manifest(&args.out, &manifest)?;
511 println!("Wrote {}", path.display());
512
513 let auth = registry_auth(args.token.as_deref())?;
514 block_on_maybe_runtime(async {
515 let client = oci_client();
516 let release_ref = parse_reference(&args.repo, &args.release)?;
517 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
518 bail!(
519 "release tag `{}` already exists; pass --force to overwrite it",
520 toolchain_ref(&args.repo, &args.release)
521 );
522 }
523 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
524 if let Some(tag) = &args.tag {
525 let tag_ref = parse_reference(&args.repo, tag)?;
526 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
527 }
528 Ok(())
529 })?;
530 println!("Published {}", toolchain_ref(&args.repo, &args.release));
531 if let Some(tag) = &args.tag {
532 println!("Updated {}", toolchain_ref(&args.repo, tag));
533 }
534
535 if !args.no_notify_updater {
536 let ch = channel_tag(channel);
537 notify_updater_dispatch(&args.release, ch, args.token.as_deref());
538 }
539
540 Ok(())
541}
542
543fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
544 match channel {
545 "dev" | "development" => Ok(ToolchainChannel::Development),
546 "rnd" | "research" => Ok(ToolchainChannel::Rnd),
547 "stable" => Ok(ToolchainChannel::Stable),
548 other => bail!(
549 "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
550 pass --channel dev for the dev lane, --channel research for the research lane, or \
551 --channel stable for the stable lane"
552 ),
553 }
554}
555
556fn channel_tag(channel: ToolchainChannel) -> &'static str {
557 match channel {
558 ToolchainChannel::Stable => "stable",
559 ToolchainChannel::Development => "dev",
560 ToolchainChannel::Rnd => "rnd",
561 }
562}
563
564fn resolve_manifest_version<R: CrateVersionResolver>(
569 resolver: &R,
570 crate_in_manifest: &str,
571 channel: ToolchainChannel,
572 lane: Option<(u64, u64)>,
573) -> Result<Option<String>> {
574 if channel == ToolchainChannel::Rnd {
575 match resolver
576 .resolve_research_version(crate_in_manifest)
577 .with_context(|| {
578 format!("failed to resolve research version for `{crate_in_manifest}`")
579 })? {
580 ResearchVersion::Pinned(version) => Ok(Some(version)),
581 ResearchVersion::Absent => {
582 eprintln!(
583 "note: `{crate_in_manifest}` has no research build on crates.io; \
584 omitting it from the research toolchain manifest"
585 );
586 Ok(None)
587 }
588 }
589 } else if let (ToolchainChannel::Development, Some(lane)) = (channel, lane) {
590 resolver
595 .resolve_latest_in_lane(crate_in_manifest, lane)
596 .with_context(|| {
597 format!(
598 "failed to resolve a {}.{} version for `{crate_in_manifest}`",
599 lane.0, lane.1
600 )
601 })
602 .map(Some)
603 } else {
604 resolver
605 .resolve_latest_for_channel(crate_in_manifest, channel)
606 .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
607 .map(Some)
608 }
609}
610
611pub fn snapshot_manifest<R: CrateVersionResolver>(
612 release: &str,
613 channel: ToolchainChannel,
614 resolver: &R,
615 created_at: Option<String>,
616) -> Result<ToolchainManifest> {
617 let from = channel_tag(channel);
618 let mut packages = Vec::new();
619 for package in GREENTIC_TOOLCHAIN_PACKAGES {
620 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
621 let Some(version) =
622 resolve_manifest_version(resolver, &crate_in_manifest, channel, lane_of(release))?
623 else {
624 continue;
625 };
626 packages.push(ToolchainPackage {
627 crate_name: crate_in_manifest,
628 bins: manifest_bins_for_source(from, package.bins),
629 version,
630 artifacts: None,
631 });
632 }
633 Ok(ToolchainManifest {
634 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
635 toolchain: TOOLCHAIN_NAME.to_string(),
636 version: release.to_string(),
637 channel: Some(from.to_string()),
638 created_at,
639 packages,
640 extension_packs: None,
641 components: None,
642 gtc: None,
643 })
644}
645
646pub fn view(args: ReleaseViewArgs) -> Result<()> {
647 let tag = release_view_tag(&args)?;
648 let manifest = block_on_maybe_runtime(load_source_manifest(
649 &args.repo,
650 &tag,
651 args.token.as_deref(),
652 ))
653 .with_context(|| {
654 format!(
655 "failed to resolve manifest `{}`",
656 toolchain_ref(&args.repo, &tag)
657 )
658 })?
659 .with_context(|| {
660 format!(
661 "manifest `{}` was not found or is not authorized for this token",
662 toolchain_ref(&args.repo, &tag)
663 )
664 })?;
665 println!("{}", serde_json::to_string_pretty(&manifest)?);
666 Ok(())
667}
668
669pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
670 let manifest = latest_manifest(Some(created_at_now()?));
671 if args.dry_run {
672 println!("{}", serde_json::to_string_pretty(&manifest)?);
673 println!(
674 "Dry run: would publish {}",
675 toolchain_ref(&args.repo, "latest")
676 );
677 return Ok(());
678 }
679
680 let auth = registry_auth(args.token.as_deref())?;
681 block_on_maybe_runtime(async {
682 let client = oci_client();
683 let latest_ref = parse_reference(&args.repo, "latest")?;
684 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
685 bail!(
686 "latest tag `{}` already exists; pass --force to overwrite it",
687 toolchain_ref(&args.repo, "latest")
688 );
689 }
690 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
691 })?;
692 println!("Published {}", toolchain_ref(&args.repo, "latest"));
693 Ok(())
694}
695
696fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
697 ToolchainManifest {
698 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
699 toolchain: TOOLCHAIN_NAME.to_string(),
700 version: "latest".to_string(),
701 channel: Some("latest".to_string()),
702 created_at,
703 packages: latest_manifest_packages(),
704 extension_packs: Some(
705 GREENTIC_EXTENSION_PACK_PACKAGES
706 .iter()
707 .map(|package| ExtensionPackRef {
708 id: package.package.to_string(),
709 version: "latest".to_string(),
710 })
711 .collect(),
712 ),
713 components: Some(
714 GREENTIC_COMPONENT_PACKAGES
715 .iter()
716 .map(|package| ComponentRef {
717 id: package.package.to_string(),
718 version: "latest".to_string(),
719 })
720 .collect(),
721 ),
722 gtc: None,
723 }
724}
725
726fn latest_manifest_packages() -> Vec<ToolchainPackage> {
727 std::iter::once(ToolchainPackage {
728 crate_name: delegated_binary_name_for_channel(
729 TOOLCHAIN_NAME,
730 ToolchainChannel::Development,
731 ),
732 bins: vec![delegated_binary_name_for_channel(
733 TOOLCHAIN_NAME,
734 ToolchainChannel::Development,
735 )],
736 version: "latest".to_string(),
737 artifacts: None,
738 })
739 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
740 ToolchainPackage {
741 crate_name: delegated_binary_name_for_channel(
742 package.crate_name,
743 ToolchainChannel::Development,
744 ),
745 bins: package
746 .bins
747 .iter()
748 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
749 .collect(),
750 version: "latest".to_string(),
751 artifacts: None,
752 }
753 }))
754 .collect()
755}
756
757fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
758 match (&args.release, &args.tag) {
759 (Some(release), None) => Ok(release.clone()),
760 (None, Some(tag)) => Ok(tag.clone()),
761 _ => bail!("pass exactly one of --release or --tag"),
762 }
763}
764
765pub fn generate_manifest<R: CrateVersionResolver>(
766 release: &str,
767 from: &str,
768 source: Option<&ToolchainManifest>,
769 resolver: &R,
770 created_at: Option<String>,
771) -> Result<ToolchainManifest> {
772 let artifact_resolver = ReleaseArtifactVersionResolver { release };
773 generate_manifest_with_artifact_resolver(
774 release,
775 from,
776 source,
777 resolver,
778 &artifact_resolver,
779 created_at,
780 )
781}
782
783pub fn generate_manifest_with_artifact_resolver<R, A>(
784 release: &str,
785 from: &str,
786 source: Option<&ToolchainManifest>,
787 resolver: &R,
788 artifact_resolver: &A,
789 created_at: Option<String>,
790) -> Result<ToolchainManifest>
791where
792 R: CrateVersionResolver,
793 A: ArtifactVersionResolver,
794{
795 if let Some(source) = source {
796 validate_manifest(source)?;
797 }
798 let source_versions = source_version_map(source);
799 let mut packages = Vec::new();
800 for package in GREENTIC_TOOLCHAIN_PACKAGES {
801 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
802 let source_version = source_versions.get(&crate_in_manifest);
803 let version = match source_version.map(String::as_str) {
804 Some(version) if version != "latest" => Some(version.to_string()),
805 _ => resolve_manifest_version(
806 resolver,
807 &crate_in_manifest,
808 channel_from_source_tag(from),
809 lane_of(release),
810 )?,
811 };
812 let Some(version) = version else {
813 continue;
814 };
815 packages.push(ToolchainPackage {
816 crate_name: crate_in_manifest,
817 bins: manifest_bins_for_source(from, package.bins),
818 version,
819 artifacts: None,
820 });
821 }
822 Ok(ToolchainManifest {
823 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
824 toolchain: TOOLCHAIN_NAME.to_string(),
825 version: release.to_string(),
826 channel: Some(from.to_string()),
827 created_at,
828 packages,
829 extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
830 components: Some(component_refs_for_release(source, artifact_resolver)?),
831 gtc: None,
832 })
833}
834
835fn channel_from_source_tag(from: &str) -> ToolchainChannel {
837 match from {
838 "dev" => ToolchainChannel::Development,
839 "rnd" => ToolchainChannel::Rnd,
840 _ => ToolchainChannel::Stable,
841 }
842}
843
844pub(crate) fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
845 let channel = channel_from_source_tag(from);
846 bins.iter()
847 .map(|bin| delegated_binary_name_for_channel(bin, channel))
848 .collect()
849}
850
851fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
852 source: Option<&ToolchainManifest>,
853 artifact_resolver: &A,
854) -> Result<Vec<ExtensionPackRef>> {
855 let source_versions = source_ref_version_map(source.and_then(|manifest| {
856 manifest
857 .extension_packs
858 .as_ref()
859 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
860 }));
861 GREENTIC_EXTENSION_PACK_PACKAGES
862 .iter()
863 .map(|package| {
864 Ok(ExtensionPackRef {
865 id: package.package.to_string(),
866 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
867 })
868 })
869 .collect()
870}
871
872fn component_refs_for_release<A: ArtifactVersionResolver>(
873 source: Option<&ToolchainManifest>,
874 artifact_resolver: &A,
875) -> Result<Vec<ComponentRef>> {
876 let source_versions = source_ref_version_map(source.and_then(|manifest| {
877 manifest
878 .components
879 .as_ref()
880 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
881 }));
882 GREENTIC_COMPONENT_PACKAGES
883 .iter()
884 .map(|package| {
885 Ok(ComponentRef {
886 id: package.package.to_string(),
887 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
888 })
889 })
890 .collect()
891}
892
893fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
894where
895 I: Iterator<Item = (&'a String, &'a String)>,
896{
897 let mut out = BTreeMap::new();
898 if let Some(refs) = refs {
899 for (id, version) in refs {
900 out.insert(id.clone(), version.clone());
901 }
902 }
903 out
904}
905
906fn ref_version_for_package(
907 package: &OciPackageSpec,
908 source_versions: &BTreeMap<String, String>,
909 artifact_resolver: &impl ArtifactVersionResolver,
910) -> Result<String> {
911 match source_versions.get(package.package).map(String::as_str) {
912 Some(version) if version != "latest" => Ok(version.to_string()),
913 _ => artifact_resolver
914 .resolve_latest(package.package)
915 .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
916 }
917}
918
919pub(crate) fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
927 if from == "dev" {
928 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
929 } else {
930 crate_name.to_string()
931 }
932}
933
934pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
935 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
936 bail!(
937 "unsupported toolchain manifest schema `{}`",
938 manifest.schema
939 );
940 }
941 if manifest.toolchain != TOOLCHAIN_NAME {
942 bail!("unsupported toolchain `{}`", manifest.toolchain);
943 }
944 Ok(())
945}
946
947pub fn toolchain_ref(repo: &str, tag: &str) -> String {
948 format!("{repo}:{tag}")
949}
950
951const GITHUB_API_BASE: &str = "https://api.github.com";
957const TOOLCHAIN_RELEASE_OWNER: &str = "greenticai";
959const RELEASE_ARCHIVE_SUFFIXES: [&str; 2] = [".tgz", ".zip"];
961
962trait ReleaseAssetChecker {
965 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>>;
967
968 fn release_artifacts(&self, _repo: &str, _tag: &str) -> Result<Option<Vec<ReleaseArtifact>>> {
974 Ok(None)
975 }
976}
977
978#[derive(Deserialize)]
979struct GithubReleaseAssets {
980 #[serde(default)]
981 assets: Vec<GithubReleaseAsset>,
982}
983
984#[derive(Deserialize)]
985struct GithubReleaseAsset {
986 name: String,
987 #[serde(default)]
989 digest: Option<String>,
990 #[serde(default)]
991 browser_download_url: Option<String>,
992}
993
994pub(crate) struct ReleaseArtifact {
997 pub name: String,
998 pub url: Option<String>,
999 pub sha256: Option<String>,
1000}
1001
1002struct GithubReleaseAssetChecker {
1003 base_url: String,
1004 token: Option<String>,
1005 client: reqwest::blocking::Client,
1006}
1007
1008impl GithubReleaseAssetChecker {
1009 fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
1010 let client = reqwest::blocking::Client::builder()
1011 .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1012 .build()
1013 .expect("failed to build GitHub API client");
1014 Self {
1015 base_url: base_url.into(),
1016 token,
1017 client,
1018 }
1019 }
1020}
1021
1022impl GithubReleaseAssetChecker {
1023 fn fetch_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<GithubReleaseAsset>>> {
1025 let url = format!(
1026 "{}/repos/{TOOLCHAIN_RELEASE_OWNER}/{repo}/releases/tags/{tag}",
1027 self.base_url.trim_end_matches('/')
1028 );
1029 let mut request = self
1030 .client
1031 .get(&url)
1032 .header(reqwest::header::ACCEPT, "application/vnd.github+json");
1033 if let Some(token) = &self.token {
1034 request = request.bearer_auth(token);
1035 }
1036 let response = request
1037 .send()
1038 .with_context(|| format!("failed to GET {url}"))?;
1039 let status = response.status();
1040 let body = response
1041 .text()
1042 .with_context(|| format!("failed to read body of {url}"))?;
1043 let Some(body) = classify_release_response(status, &url, body)? else {
1044 return Ok(None);
1045 };
1046 let release: GithubReleaseAssets = serde_json::from_str(&body)
1047 .with_context(|| format!("failed to parse release metadata from {url}"))?;
1048 Ok(Some(release.assets))
1049 }
1050}
1051
1052impl ReleaseAssetChecker for GithubReleaseAssetChecker {
1053 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
1054 Ok(self
1055 .fetch_assets(repo, tag)?
1056 .map(|assets| assets.into_iter().map(|asset| asset.name).collect()))
1057 }
1058
1059 fn release_artifacts(&self, repo: &str, tag: &str) -> Result<Option<Vec<ReleaseArtifact>>> {
1060 Ok(self.fetch_assets(repo, tag)?.map(|assets| {
1061 assets
1062 .into_iter()
1063 .map(|asset| ReleaseArtifact {
1064 name: asset.name,
1065 url: asset.browser_download_url,
1066 sha256: asset.digest,
1067 })
1068 .collect()
1069 }))
1070 }
1071}
1072
1073fn default_release_checker(raw_token: Option<&str>) -> GithubReleaseAssetChecker {
1077 GithubReleaseAssetChecker::new(GITHUB_API_BASE, ambient_github_token(raw_token))
1078}
1079
1080fn classify_release_response(
1084 status: reqwest::StatusCode,
1085 url: &str,
1086 body: String,
1087) -> Result<Option<String>> {
1088 if status == reqwest::StatusCode::NOT_FOUND {
1089 return Ok(None);
1090 }
1091 if !status.is_success() {
1092 bail!("GitHub API GET {url} returned {status}: {body}");
1093 }
1094 Ok(Some(body))
1095}
1096
1097fn affects_stable_channel(manifest: &ToolchainManifest, target_tag: Option<&str>) -> bool {
1105 manifest.channel.as_deref() == Some("stable") || target_tag == Some("stable")
1106}
1107
1108const GTC_RELEASE_REPO: &str = "greentic";
1117
1118const GTC_TARGETS: &[&str] = &[
1122 "x86_64-unknown-linux-gnu",
1123 "aarch64-unknown-linux-gnu",
1124 "x86_64-apple-darwin",
1125 "aarch64-apple-darwin",
1126 "x86_64-pc-windows-msvc",
1127 "aarch64-pc-windows-msvc",
1128];
1129
1130fn gtc_artifacts_for(
1136 version: &str,
1137 checker: &dyn ReleaseAssetChecker,
1138) -> Result<Option<Vec<GtcArtifactRef>>> {
1139 let tag = format!("v{version}");
1140 let Some(artifacts) = checker.release_artifacts(GTC_RELEASE_REPO, &tag)? else {
1141 return Ok(None);
1142 };
1143 let mut named = Vec::new();
1144 for target in GTC_TARGETS {
1145 let tgz = format!("-{target}.tgz");
1146 let zip = format!("-{target}.zip");
1147 let Some(found) = artifacts
1148 .iter()
1149 .find(|artifact| artifact.name.ends_with(&tgz) || artifact.name.ends_with(&zip))
1150 else {
1151 continue;
1152 };
1153 let (Some(url), Some(digest)) = (found.url.as_deref(), found.sha256.as_deref()) else {
1154 continue;
1155 };
1156 named.push(GtcArtifactRef {
1157 target: (*target).to_string(),
1158 url: url.to_string(),
1159 sha256: digest.trim_start_matches("sha256:").to_string(),
1160 });
1161 }
1162 Ok((!named.is_empty()).then_some(named))
1163}
1164
1165fn verify_manifest_releases(
1166 manifest: &ToolchainManifest,
1167 target_tag: Option<&str>,
1168 checker: &dyn ReleaseAssetChecker,
1169) -> Result<()> {
1170 if !affects_stable_channel(manifest, target_tag) {
1171 return Ok(());
1172 }
1173 let mut problems = Vec::new();
1174 for package in &manifest.packages {
1175 let tag = format!("v{}", package.version);
1176 match checker.release_assets(&package.crate_name, &tag)? {
1177 None => problems.push(format!(
1178 "{} {tag}: no GitHub release (build not finished)",
1179 package.crate_name
1180 )),
1181 Some(assets) => {
1182 if let Err(reason) = check_release_assets(&assets, &package.version) {
1183 problems.push(format!("{} {tag}: {reason}", package.crate_name));
1184 }
1185 }
1186 }
1187 }
1188 if !problems.is_empty() {
1189 bail!(
1190 "refusing to publish toolchain manifest {}: {} pinned package(s) are not \
1191 downloadable yet:\n {}\nWait for the release builds to finish, then retry.",
1192 manifest.version,
1193 problems.len(),
1194 problems.join("\n ")
1195 );
1196 }
1197 Ok(())
1198}
1199
1200pub(crate) fn check_release_assets(assets: &[String], version: &str) -> Result<(), String> {
1221 let names: BTreeSet<&str> = assets.iter().map(String::as_str).collect();
1222 let version_marker = format!("-v{version}-");
1223 let archives: Vec<&str> = names
1224 .iter()
1225 .copied()
1226 .filter(|name| {
1227 name.contains(&version_marker)
1228 && RELEASE_ARCHIVE_SUFFIXES
1229 .iter()
1230 .any(|suffix| name.ends_with(suffix))
1231 })
1232 .collect();
1233 if archives.is_empty() {
1234 return Err(format!(
1235 "release has no v{version} archives yet (upload in progress)"
1236 ));
1237 }
1238 let unchecksummed: Vec<&str> = archives
1239 .iter()
1240 .copied()
1241 .filter(|name| !names.contains(format!("{name}.sha256").as_str()))
1242 .collect();
1243 if !unchecksummed.is_empty() {
1244 return Err(format!(
1245 "archives missing .sha256 (upload in progress): {}",
1246 unchecksummed.join(", ")
1247 ));
1248 }
1249 Ok(())
1250}
1251
1252fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
1253 let mut out = BTreeMap::new();
1254 if let Some(source) = source {
1255 for package in &source.packages {
1256 out.insert(package.crate_name.clone(), package.version.clone());
1257 }
1258 }
1259 out
1260}
1261
1262fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
1263 fs::create_dir_all(out_dir)
1264 .with_context(|| format!("failed to create {}", out_dir.display()))?;
1265 let path = out_dir.join(manifest_file_name(manifest));
1266 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1267 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
1268 Ok(path)
1269}
1270
1271fn manifest_file_name(manifest: &ToolchainManifest) -> String {
1272 match manifest.channel.as_deref() {
1273 Some("stable") | None => format!("gtc-{}.json", manifest.version),
1274 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
1275 }
1276}
1277
1278fn created_at_now() -> Result<String> {
1279 OffsetDateTime::now_utc()
1280 .format(&Rfc3339)
1281 .context("failed to format current time")
1282}
1283
1284pub enum ResearchVersion {
1291 Pinned(String),
1293 Absent,
1297}
1298
1299pub trait CrateVersionResolver {
1300 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
1301
1302 fn resolve_latest_for_channel(
1309 &self,
1310 crate_name: &str,
1311 _channel: ToolchainChannel,
1312 ) -> Result<String> {
1313 self.resolve_latest(crate_name)
1314 }
1315
1316 fn resolve_latest_in_lane(&self, crate_name: &str, _lane: (u64, u64)) -> Result<String> {
1323 self.resolve_latest(crate_name)
1324 }
1325
1326 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1332 self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
1333 .map(ResearchVersion::Pinned)
1334 }
1335}
1336
1337fn default_resolver() -> CratesIoApiVersionResolver {
1341 CratesIoApiVersionResolver::default()
1342}
1343
1344pub trait ArtifactVersionResolver {
1345 fn resolve_latest(&self, package: &str) -> Result<String>;
1346}
1347
1348const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
1349const CRATES_IO_USER_AGENT: &str = concat!(
1350 "greentic-dev/",
1351 env!("CARGO_PKG_VERSION"),
1352 " (https://github.com/greenticai/greentic-dev)"
1353);
1354
1355pub struct CratesIoApiVersionResolver {
1362 base_url: String,
1363 client: reqwest::blocking::Client,
1364}
1365
1366impl Default for CratesIoApiVersionResolver {
1367 fn default() -> Self {
1368 Self::new(CRATES_IO_API_BASE)
1369 }
1370}
1371
1372impl CratesIoApiVersionResolver {
1373 pub fn new(base_url: impl Into<String>) -> Self {
1374 let client = reqwest::blocking::Client::builder()
1375 .user_agent(CRATES_IO_USER_AGENT)
1376 .build()
1377 .expect("failed to build crates.io API client");
1378 Self {
1379 base_url: base_url.into(),
1380 client,
1381 }
1382 }
1383
1384 fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
1389 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1390 let response = self
1391 .client
1392 .get(&url)
1393 .send()
1394 .with_context(|| format!("failed to GET {url}"))?;
1395 let status = response.status();
1396 let body = response
1397 .text()
1398 .with_context(|| format!("failed to read body of {url}"))?;
1399 classify_crate_response(status, &url, body)
1400 }
1401}
1402
1403fn classify_crate_response(
1407 status: reqwest::StatusCode,
1408 url: &str,
1409 body: String,
1410) -> Result<Option<String>> {
1411 if status == reqwest::StatusCode::NOT_FOUND {
1412 return Ok(None);
1413 }
1414 if !status.is_success() {
1415 bail!("crates.io API GET {url} returned {status}: {body}");
1416 }
1417 Ok(Some(body))
1418}
1419
1420fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
1426 match parse_crates_io_research_version(crate_name, body) {
1427 Ok(version) => Ok(version),
1428 Err(_) => {
1429 let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
1430 eprintln!(
1431 "note: `{crate_name}` has no -research publish; the research \
1432 toolchain falls back to latest `{fallback}`"
1433 );
1434 Ok(fallback)
1435 }
1436 }
1437}
1438
1439struct ReleaseArtifactVersionResolver<'a> {
1440 release: &'a str,
1441}
1442
1443impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
1444 fn resolve_latest(&self, _package: &str) -> Result<String> {
1445 Ok(self.release.to_string())
1446 }
1447}
1448
1449struct GhcrArtifactVersionResolver {
1450 client: reqwest::blocking::Client,
1451 registry: String,
1452 namespace: String,
1453 basic_token: Option<String>,
1454}
1455
1456impl GhcrArtifactVersionResolver {
1457 fn new(raw_token: Option<&str>) -> Result<Self> {
1458 Ok(Self {
1459 client: reqwest::blocking::Client::builder()
1460 .build()
1461 .context("failed to build GHCR HTTP client")?,
1462 registry: "ghcr.io".to_string(),
1463 namespace: "greenticai".to_string(),
1464 basic_token: resolve_registry_token(raw_token)?
1465 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1466 .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
1467 })
1468 }
1469
1470 fn bearer_token(&self, repository: &str) -> Result<String> {
1471 let scope = format!("repository:{repository}:pull");
1472 let mut request = self
1473 .client
1474 .get(format!("https://{}/token", self.registry))
1475 .query(&[
1476 ("service", self.registry.as_str()),
1477 ("scope", scope.as_str()),
1478 ]);
1479 if let Some(token) = &self.basic_token {
1480 request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
1481 }
1482 let response = request
1483 .send()
1484 .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
1485 .error_for_status()
1486 .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
1487 let body: GhcrTokenResponse = response
1488 .json()
1489 .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1490 Ok(body.token)
1491 }
1492
1493 fn tags(&self, repository: &str) -> Result<Vec<String>> {
1494 let token = self.bearer_token(repository)?;
1495 let response = self
1496 .client
1497 .get(format!(
1498 "https://{}/v2/{repository}/tags/list",
1499 self.registry
1500 ))
1501 .bearer_auth(token)
1502 .send()
1503 .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1504 .error_for_status()
1505 .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1506 let body: GhcrTagsResponse = response
1507 .json()
1508 .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1509 Ok(body.tags)
1510 }
1511}
1512
1513impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1514 fn resolve_latest(&self, package: &str) -> Result<String> {
1515 let repository = format!("{}/{}", self.namespace, package);
1516 let tags = self.tags(&repository)?;
1517 select_latest_artifact_tag(&tags)
1518 .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1519 }
1520}
1521
1522#[derive(Deserialize)]
1523struct GhcrTokenResponse {
1524 token: String,
1525}
1526
1527#[derive(Deserialize)]
1528struct GhcrTagsResponse {
1529 #[serde(default)]
1530 tags: Vec<String>,
1531}
1532
1533fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1534 tags.iter()
1535 .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1536 .max_by(|(left, _), (right, _)| left.cmp(right))
1537 .map(|(_, tag)| tag.clone())
1538 .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1539 .context("no semver or latest tags found")
1540}
1541
1542impl CrateVersionResolver for CratesIoApiVersionResolver {
1543 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1544 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1545 let response = self
1546 .client
1547 .get(&url)
1548 .send()
1549 .with_context(|| format!("failed to GET {url}"))?;
1550 let status = response.status();
1551 let body = response
1552 .text()
1553 .with_context(|| format!("failed to read body of {url}"))?;
1554 if !status.is_success() {
1555 bail!("crates.io API GET {url} returned {status}: {body}");
1556 }
1557 parse_crates_io_version(crate_name, &body)
1558 }
1559
1560 fn resolve_latest_for_channel(
1561 &self,
1562 crate_name: &str,
1563 channel: ToolchainChannel,
1564 ) -> Result<String> {
1565 if channel != ToolchainChannel::Rnd {
1566 return self.resolve_latest(crate_name);
1567 }
1568 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1569 let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1570 anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1571 })?;
1572 research_or_fallback(crate_name, &body)
1573 }
1574
1575 fn resolve_latest_in_lane(&self, crate_name: &str, lane: (u64, u64)) -> Result<String> {
1576 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1577 let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1578 anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1579 })?;
1580 pick_highest_in_lane(crate_name, &body, lane)
1581 }
1582
1583 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1584 match self.fetch_crate_body(crate_name)? {
1589 None => Ok(ResearchVersion::Absent),
1590 Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1591 }
1592 }
1593}
1594
1595pub(crate) fn lane_of(release: &str) -> Option<(u64, u64)> {
1604 let mut parts = release.split('.');
1605 let major = parts.next()?.parse().ok()?;
1606 let minor = parts.next()?.parse().ok()?;
1607 Some((major, minor))
1608}
1609
1610fn pick_highest_in_lane(crate_name: &str, body: &str, lane: (u64, u64)) -> Result<String> {
1621 let payload: serde_json::Value = serde_json::from_str(body)
1622 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1623 let versions = payload
1624 .get("versions")
1625 .and_then(|v| v.as_array())
1626 .ok_or_else(|| {
1627 anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1628 })?;
1629 let mut best: Option<Version> = None;
1630 for entry in versions {
1631 if entry
1632 .get("yanked")
1633 .and_then(serde_json::Value::as_bool)
1634 .unwrap_or(false)
1635 {
1636 continue;
1637 }
1638 let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1639 continue;
1640 };
1641 let Ok(parsed) = Version::parse(num) else {
1642 continue;
1643 };
1644 if (parsed.major, parsed.minor) != lane {
1645 continue;
1646 }
1647 if best.as_ref().is_none_or(|current| parsed > *current) {
1648 best = Some(parsed);
1649 }
1650 }
1651 best.map(|v| v.to_string()).ok_or_else(|| {
1652 anyhow!(
1653 "crates.io has no non-yanked `{crate_name}` in the {}.{} lane",
1654 lane.0,
1655 lane.1
1656 )
1657 })
1658}
1659
1660fn pick_highest_crates_io_version(
1661 crate_name: &str,
1662 body: &str,
1663 research_only: bool,
1664) -> Result<String> {
1665 let payload: serde_json::Value = serde_json::from_str(body)
1666 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1667 let versions = payload
1668 .get("versions")
1669 .and_then(|v| v.as_array())
1670 .ok_or_else(|| {
1671 anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1672 })?;
1673 let mut best: Option<Version> = None;
1674 for entry in versions {
1675 if entry
1676 .get("yanked")
1677 .and_then(serde_json::Value::as_bool)
1678 .unwrap_or(false)
1679 {
1680 continue;
1681 }
1682 let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1683 continue;
1684 };
1685 let Ok(parsed) = Version::parse(num) else {
1686 continue;
1687 };
1688 if research_only && !parsed.pre.as_str().starts_with("research") {
1689 continue;
1690 }
1691 if best.as_ref().is_none_or(|current| parsed > *current) {
1692 best = Some(parsed);
1693 }
1694 }
1695 best.map(|v| v.to_string()).ok_or_else(|| {
1696 let what = if research_only {
1697 "no non-yanked `-research` version"
1698 } else {
1699 "no non-yanked versions"
1700 };
1701 anyhow!("crates.io API for `{crate_name}` exposes {what}")
1702 })
1703}
1704
1705fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1706 pick_highest_crates_io_version(crate_name, body, true)
1707}
1708
1709fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1710 let payload: serde_json::Value = serde_json::from_str(body)
1711 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1712 let crate_obj = payload.get("crate").ok_or_else(|| {
1713 anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1714 })?;
1715 let version = crate_obj
1716 .get("max_stable_version")
1717 .and_then(|v| v.as_str())
1718 .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1719 .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1720 .ok_or_else(|| {
1721 anyhow!(
1722 "crates.io API for `{crate_name}` does not expose max_stable_version, \
1723 newest_version, or max_version"
1724 )
1725 })?;
1726 Version::parse(version).with_context(|| {
1727 format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1728 })?;
1729 Ok(version.to_string())
1730}
1731
1732#[async_trait]
1733trait ToolchainManifestSource {
1734 async fn load_manifest(
1735 &self,
1736 repo: &str,
1737 tag: &str,
1738 token: Option<&str>,
1739 ) -> Result<Option<ToolchainManifest>>;
1740}
1741
1742struct OciToolchainManifestSource;
1743
1744#[async_trait]
1745impl ToolchainManifestSource for OciToolchainManifestSource {
1746 async fn load_manifest(
1747 &self,
1748 repo: &str,
1749 tag: &str,
1750 token: Option<&str>,
1751 ) -> Result<Option<ToolchainManifest>> {
1752 let auth = optional_registry_auth(token)?;
1753 let client = oci_client();
1754 let reference = parse_reference(repo, tag)?;
1755 let image = match client
1756 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1757 .await
1758 {
1759 Ok(image) => image,
1760 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1761 return Ok(None);
1762 }
1763 Err(err) => {
1764 return Err(err)
1765 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1766 }
1767 };
1768 let Some(layer) = image
1769 .layers
1770 .into_iter()
1771 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1772 else {
1773 return Ok(None);
1774 };
1775 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1776 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1777 validate_manifest(&manifest)?;
1778 Ok(Some(manifest))
1779 }
1780}
1781
1782async fn load_source_manifest(
1783 repo: &str,
1784 tag: &str,
1785 token: Option<&str>,
1786) -> Result<Option<ToolchainManifest>> {
1787 OciToolchainManifestSource
1788 .load_manifest(repo, tag, token)
1789 .await
1790}
1791
1792fn oci_client() -> Client {
1793 Client::new(ClientConfig {
1794 protocol: ClientProtocol::Https,
1795 ..Default::default()
1796 })
1797}
1798
1799fn should_notify_updater(version: &str, channel: &str) -> bool {
1808 if channel != "stable" {
1809 return false;
1810 }
1811 match Version::parse(version) {
1812 Ok(v) => v.pre.is_empty() && v.build.is_empty(),
1813 Err(_) => false,
1814 }
1815}
1816
1817pub(crate) fn ambient_github_token(raw_token: Option<&str>) -> Option<String> {
1821 resolve_registry_token(raw_token)
1822 .ok()
1823 .flatten()
1824 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1825 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1826 .filter(|token| !token.trim().is_empty())
1827}
1828
1829fn notify_updater_dispatch(version: &str, channel: &str, raw_token: Option<&str>) {
1834 if !should_notify_updater(version, channel) {
1835 eprintln!(
1836 "Skipping updater dispatch: version `{version}` / channel `{channel}` \
1837 is not a stable-lane release"
1838 );
1839 return;
1840 }
1841
1842 let Some(token) = ambient_github_token(raw_token) else {
1843 eprintln!(
1844 "Warning: skipping updater dispatch — no token available \
1845 (pass --token or set GHCR_TOKEN/GITHUB_TOKEN)"
1846 );
1847 return;
1848 };
1849
1850 let client = match reqwest::blocking::Client::builder()
1851 .user_agent("greentic-dev-cli")
1852 .build()
1853 {
1854 Ok(c) => c,
1855 Err(e) => {
1856 eprintln!("Warning: failed to build HTTP client for updater dispatch: {e}");
1857 return;
1858 }
1859 };
1860
1861 let body = serde_json::json!({
1862 "event_type": "toolchain-release-published",
1863 "client_payload": {
1864 "release": version,
1865 "channel": channel,
1866 }
1867 });
1868
1869 let url = "https://api.github.com/repos/greenticai/greentic-dev/dispatches";
1870 match client
1871 .post(url)
1872 .header("Accept", "application/vnd.github+json")
1873 .bearer_auth(&token)
1874 .json(&body)
1875 .send()
1876 {
1877 Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
1878 eprintln!("Dispatched toolchain-release-published for {version} (channel={channel})");
1879 }
1880 Ok(resp) => {
1881 let status = resp.status();
1882 let text = resp.text().unwrap_or_default();
1883 eprintln!("Warning: updater dispatch returned HTTP {status}: {text}");
1884 }
1885 Err(e) => {
1886 eprintln!("Warning: updater dispatch failed: {e}");
1887 }
1888 }
1889}
1890
1891fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1892 let token = resolve_registry_token(raw_token)?
1893 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1894 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1895 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1896 if token.trim().is_empty() {
1897 bail!("GHCR token is empty");
1898 }
1899 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1900}
1901
1902fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1903 match registry_auth(raw_token) {
1904 Ok(auth) => Ok(auth),
1905 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1906 Err(err) => Err(err),
1907 }
1908}
1909
1910fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1911 let Some(raw_token) = raw_token else {
1912 return Ok(None);
1913 };
1914 if let Some(var) = raw_token.strip_prefix("env:") {
1915 let token =
1916 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1917 if token.trim().is_empty() {
1918 bail!("env var {var} resolved to an empty token");
1919 }
1920 return Ok(Some(token));
1921 }
1922 if raw_token.trim().is_empty() {
1923 bail!("GHCR token is empty");
1924 }
1925 Ok(Some(raw_token.to_string()))
1926}
1927
1928fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1929 Reference::from_str(&toolchain_ref(repo, tag))
1930 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1931}
1932
1933async fn manifest_exists(
1934 client: &Client,
1935 reference: &Reference,
1936 auth: &RegistryAuth,
1937) -> Result<bool> {
1938 match client.pull_manifest(reference, auth).await {
1939 Ok(_) => Ok(true),
1940 Err(err) if is_missing_manifest_error(&err) => Ok(false),
1941 Err(err) => Err(err).context("failed to check whether release tag exists"),
1942 }
1943}
1944
1945fn is_missing_manifest_error(
1946 err: &greentic_distributor_client::oci_client::errors::OciDistributionError,
1947) -> bool {
1948 let msg = err.to_string().to_ascii_lowercase();
1949 msg.contains("manifest unknown")
1950 || msg.contains("name unknown")
1951 || msg.contains("not found")
1952 || msg.contains("404")
1953}
1954
1955fn is_unauthorized_error(
1956 err: &greentic_distributor_client::oci_client::errors::OciDistributionError,
1957) -> bool {
1958 let msg = err.to_string().to_ascii_lowercase();
1959 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1960}
1961
1962async fn push_manifest_layer(
1963 client: &Client,
1964 reference: &Reference,
1965 auth: &RegistryAuth,
1966 manifest: &ToolchainManifest,
1967) -> Result<()> {
1968 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1969 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1970 let config = Config::new(
1971 br#"{"toolchain":"gtc"}"#.to_vec(),
1972 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1973 None,
1974 );
1975 client
1976 .push(reference, &[layer], config, auth, None)
1977 .await
1978 .context("failed to push toolchain manifest")?;
1979 Ok(())
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984 use super::*;
1985 use once_cell::sync::Lazy;
1986 use std::sync::Mutex;
1987
1988 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1989
1990 struct FixedResolver;
1991
1992 impl CrateVersionResolver for FixedResolver {
1993 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1994 Ok(match crate_name {
1995 "greentic-runner" => "0.5.10",
1996 _ => "1.2.3",
1997 }
1998 .to_string())
1999 }
2000 }
2001
2002 struct FixedArtifactResolver;
2003
2004 impl ArtifactVersionResolver for FixedArtifactResolver {
2005 fn resolve_latest(&self, package: &str) -> Result<String> {
2006 Ok(match package {
2007 "packs/messaging/messaging-webchat-gui" => "0.4.93",
2008 "components/component-adaptive-card" => "0.5.8",
2009 _ => "0.1.0",
2010 }
2011 .to_string())
2012 }
2013 }
2014
2015 #[test]
2016 fn parses_crates_io_max_stable_version() {
2017 let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
2018 let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
2019 assert_eq!(version, "0.5.123");
2020 }
2021
2022 #[test]
2023 fn research_resolver_picks_highest_non_yanked_research_prerelease() {
2024 let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
2027 "versions":[
2028 {"num":"0.5.48","yanked":false},
2029 {"num":"1.2.0-research.0","yanked":false},
2030 {"num":"1.2.0-research.1","yanked":false},
2031 {"num":"1.2.0-research.2","yanked":true}
2032 ]}"#;
2033 let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
2034 assert_eq!(version, "1.2.0-research.1");
2035 }
2036
2037 #[test]
2038 fn research_resolver_errors_when_no_research_version() {
2039 let body = r#"{"crate":{"id":"greentic-setup"},
2040 "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
2041 let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
2042 assert!(err.to_string().contains("no non-yanked `-research`"));
2043 }
2044
2045 struct ResearchSkipResolver;
2049
2050 impl CrateVersionResolver for ResearchSkipResolver {
2051 fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
2052 Ok("1.2.0-research.4".to_string())
2053 }
2054
2055 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
2056 if crate_name.contains("operator") {
2057 Ok(ResearchVersion::Absent)
2058 } else {
2059 Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
2060 }
2061 }
2062 }
2063
2064 #[test]
2065 fn classify_crate_response_maps_404_to_absent() {
2066 let outcome =
2067 classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
2068 .unwrap();
2069 assert!(outcome.is_none(), "404 must classify as absent (None)");
2070 }
2071
2072 #[test]
2073 fn classify_crate_response_returns_body_on_success() {
2074 let outcome =
2075 classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
2076 assert_eq!(outcome.as_deref(), Some("payload"));
2077 }
2078
2079 #[test]
2080 fn classify_crate_response_errors_on_other_status() {
2081 let err = classify_crate_response(
2082 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2083 "url",
2084 "boom".to_string(),
2085 )
2086 .unwrap_err();
2087 assert!(err.to_string().contains("500"));
2088 }
2089
2090 #[test]
2091 fn snapshot_manifest_skips_crates_without_research_build() {
2092 let manifest = snapshot_manifest(
2095 "1.2.0-research.4",
2096 ToolchainChannel::Rnd,
2097 &ResearchSkipResolver,
2098 None,
2099 )
2100 .unwrap();
2101 assert!(
2102 !manifest.packages.is_empty(),
2103 "research-built tools must remain in the manifest"
2104 );
2105 assert!(
2106 manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
2107 "at least one tool without a research build must be skipped"
2108 );
2109 assert!(
2110 manifest
2111 .packages
2112 .iter()
2113 .all(|package| !package.crate_name.contains("operator")),
2114 "the absent `operator` crate must be omitted"
2115 );
2116 assert!(
2117 manifest
2118 .packages
2119 .iter()
2120 .all(|package| package.version == "1.2.0-research.4"),
2121 "remaining research tools pin their resolved -research version"
2122 );
2123 }
2124
2125 #[test]
2126 fn parses_crates_io_falls_back_to_newest_version() {
2127 let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
2128 let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
2129 assert_eq!(version, "0.6.7");
2130 }
2131
2132 #[test]
2133 fn parses_crates_io_falls_back_to_max_version() {
2134 let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
2135 let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
2136 assert_eq!(version, "0.4.99");
2137 }
2138
2139 #[test]
2140 fn rejects_crates_io_payload_without_versions() {
2141 let body = r#"{"crate":{"id":"greentic-dev"}}"#;
2142 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
2143 assert!(
2144 err.to_string()
2145 .contains("does not expose max_stable_version")
2146 );
2147 }
2148
2149 #[test]
2150 fn rejects_crates_io_payload_with_unparseable_version() {
2151 let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
2152 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
2153 assert!(err.to_string().contains("unparseable version"));
2154 }
2155
2156 #[test]
2157 fn rejects_crates_io_payload_without_crate_object() {
2158 let body = r#"{"errors":[{"detail":"not found"}]}"#;
2159 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
2160 assert!(
2161 err.to_string()
2162 .contains("missing the top-level `crate` object")
2163 );
2164 }
2165
2166 #[test]
2167 fn selects_latest_semver_tag() {
2168 let tags = vec![
2169 "latest".to_string(),
2170 "0.4.93".to_string(),
2171 "0.4.9".to_string(),
2172 "1.0.0-beta.1".to_string(),
2173 "1.0.0".to_string(),
2174 ];
2175
2176 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
2177 }
2178
2179 #[test]
2180 fn selects_latest_tag_when_no_semver_tags_exist() {
2181 let tags = vec!["latest".to_string()];
2182
2183 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
2184 }
2185
2186 #[test]
2187 fn generates_manifest_from_catalogue() {
2188 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2189 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2190 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2191 assert_eq!(manifest.version, "1.0.5");
2192 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2193 assert!(
2194 manifest
2195 .packages
2196 .iter()
2197 .any(|package| package.crate_name == "greentic-bundle"
2198 && package.bins == ["greentic-bundle"])
2199 );
2200 assert!(
2201 manifest
2202 .packages
2203 .iter()
2204 .any(|package| package.crate_name == "greentic-runner"
2205 && package.bins == ["greentic-runner"])
2206 );
2207 assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
2208 assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
2209 assert!(
2210 manifest
2211 .extension_packs
2212 .as_ref()
2213 .unwrap()
2214 .iter()
2215 .all(|item| item.version == "1.0.5")
2216 );
2217 assert!(
2218 manifest
2219 .components
2220 .as_ref()
2221 .unwrap()
2222 .iter()
2223 .all(|item| item.version == "1.0.5")
2224 );
2225 }
2226
2227 #[test]
2228 fn generated_manifest_can_use_artifact_resolver_versions() {
2229 let manifest = generate_manifest_with_artifact_resolver(
2230 "1.0.17",
2231 "stable",
2232 None,
2233 &FixedResolver,
2234 &FixedArtifactResolver,
2235 None,
2236 )
2237 .unwrap();
2238
2239 assert!(
2240 manifest
2241 .extension_packs
2242 .as_ref()
2243 .unwrap()
2244 .iter()
2245 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2246 && item.version == "0.4.93")
2247 );
2248 assert!(
2249 manifest
2250 .components
2251 .as_ref()
2252 .unwrap()
2253 .iter()
2254 .any(|item| item.id == "components/component-adaptive-card"
2255 && item.version == "0.5.8")
2256 );
2257 }
2258
2259 #[test]
2260 fn source_manifest_can_pin_package_versions() {
2261 let source = ToolchainManifest {
2262 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2263 toolchain: TOOLCHAIN_NAME.to_string(),
2264 version: "latest".to_string(),
2265 channel: Some("latest".to_string()),
2266 created_at: None,
2267 packages: vec![ToolchainPackage {
2268 crate_name: "greentic-dev".to_string(),
2269 bins: vec!["greentic-dev".to_string()],
2270 version: "0.5.9".to_string(),
2271 artifacts: None,
2272 }],
2273 extension_packs: None,
2274 components: None,
2275 gtc: None,
2276 };
2277 let manifest =
2278 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
2279 let greentic_dev = manifest
2280 .packages
2281 .iter()
2282 .find(|package| package.crate_name == "greentic-dev")
2283 .unwrap();
2284 assert_eq!(greentic_dev.version, "0.5.9");
2285 }
2286
2287 #[test]
2288 fn from_argument_controls_generated_channel_over_source_manifest() {
2289 let source = ToolchainManifest {
2290 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2291 toolchain: TOOLCHAIN_NAME.to_string(),
2292 version: "latest".to_string(),
2293 channel: Some("stable".to_string()),
2294 created_at: None,
2295 packages: Vec::new(),
2296 extension_packs: None,
2297 components: None,
2298 gtc: None,
2299 };
2300 let manifest =
2301 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
2302 assert_eq!(manifest.channel.as_deref(), Some("dev"));
2303 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
2304 }
2305
2306 #[test]
2307 fn generate_from_dev_uses_dev_crate_and_binary_names() {
2308 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
2309 assert!(
2310 manifest
2311 .packages
2312 .iter()
2313 .flat_map(|package| package.bins.iter())
2314 .all(|bin| bin.ends_with("-dev"))
2315 );
2316 assert!(
2317 manifest
2318 .packages
2319 .iter()
2320 .all(|package| package.crate_name.ends_with("-dev")),
2321 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
2322 );
2323 assert!(manifest.packages.iter().any(|package| {
2324 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
2325 }));
2326 assert!(manifest.packages.iter().any(|package| {
2327 package.crate_name == "greentic-component-dev"
2328 && package.bins == ["greentic-component-dev"]
2329 }));
2330 assert!(manifest.packages.iter().any(|package| {
2331 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2332 }));
2333 }
2334
2335 #[test]
2336 fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
2337 let manifest =
2338 snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
2339 .unwrap();
2340 assert_eq!(manifest.version, "1.1.5");
2341 assert_eq!(manifest.channel.as_deref(), Some("dev"));
2342 for package in &manifest.packages {
2343 assert!(
2344 package.crate_name.ends_with("-dev"),
2345 "dev snapshot must pin -dev crate names; got {}",
2346 package.crate_name
2347 );
2348 assert!(
2349 package.bins.iter().all(|bin| bin.ends_with("-dev")),
2350 "dev snapshot must pin -dev bin names; got {:?}",
2351 package.bins
2352 );
2353 assert_ne!(
2354 package.version, "latest",
2355 "snapshot must always resolve concrete versions"
2356 );
2357 }
2358 assert!(
2359 manifest
2360 .packages
2361 .iter()
2362 .any(|package| package.crate_name == "greentic-operator-dev")
2363 );
2364 }
2365
2366 #[test]
2367 fn snapshot_manifest_stable_channel_keeps_plain_names() {
2368 let manifest =
2369 snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
2370 assert_eq!(manifest.channel.as_deref(), Some("stable"));
2371 let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
2375 .iter()
2376 .map(|spec| spec.crate_name)
2377 .collect();
2378 for package in &manifest.packages {
2379 assert!(
2380 catalogue_names.contains(package.crate_name.as_str()),
2381 "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
2382 package.crate_name
2383 );
2384 }
2385 }
2386
2387 #[test]
2388 fn snapshot_manifest_resolves_via_resolver() {
2389 let manifest =
2390 snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
2391 .unwrap();
2392 for package in &manifest.packages {
2398 assert_eq!(
2399 package.version, "1.2.3",
2400 "resolver must be hit for {}",
2401 package.crate_name
2402 );
2403 }
2404 }
2405
2406 #[test]
2407 fn parses_dev_channel_argument() {
2408 assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
2409 assert_eq!(
2410 parse_channel("development").unwrap(),
2411 ToolchainChannel::Development
2412 );
2413 assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
2414 assert!(parse_channel("rc").is_err());
2415 }
2416
2417 #[test]
2426 fn the_dev_lane_ignores_a_higher_research_minor() {
2427 let body = r#"{"versions":[
2428 {"num":"1.2.32329835532","yanked":false},
2429 {"num":"1.2.32374877786","yanked":false},
2430 {"num":"1.3.29293243074","yanked":false},
2431 {"num":"1.3.29488015798","yanked":false}
2432 ]}"#;
2433
2434 assert_eq!(
2435 pick_highest_in_lane("greentic-setup-dev", body, (1, 2)).unwrap(),
2436 "1.2.32374877786",
2437 "the newest 1.2 build must win over any 1.3"
2438 );
2439 assert_eq!(
2440 pick_highest_in_lane("greentic-setup-dev", body, (1, 3)).unwrap(),
2441 "1.3.29488015798",
2442 "asking for the 1.3 lane still resolves inside 1.3"
2443 );
2444 }
2445
2446 #[test]
2448 fn a_yanked_build_is_not_pinned_in_lane() {
2449 let body = r#"{"versions":[
2450 {"num":"1.2.100","yanked":false},
2451 {"num":"1.2.200","yanked":true}
2452 ]}"#;
2453 assert_eq!(pick_highest_in_lane("c", body, (1, 2)).unwrap(), "1.2.100");
2454 }
2455
2456 #[test]
2459 fn an_empty_lane_is_an_error_not_a_fallback() {
2460 let body = r#"{"versions":[{"num":"1.3.5","yanked":false}]}"#;
2461 let err = pick_highest_in_lane("c", body, (1, 2)).unwrap_err();
2462 assert!(
2463 err.to_string().contains("1.2"),
2464 "the error must name the lane it searched; got {err}"
2465 );
2466 }
2467
2468 #[test]
2470 fn the_lane_comes_from_the_release_being_generated() {
2471 assert_eq!(lane_of("1.2.1"), Some((1, 2)));
2472 assert_eq!(lane_of("1.2.32374413367"), Some((1, 2)));
2473 assert_eq!(lane_of("nonsense"), None);
2474 }
2475
2476 #[test]
2477 fn detects_concrete_pins_for_publish_deprecation_warning() {
2478 let with_pins = ToolchainManifest {
2479 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2480 toolchain: TOOLCHAIN_NAME.to_string(),
2481 version: "0.0.1".to_string(),
2482 channel: Some("dev".to_string()),
2483 created_at: None,
2484 packages: vec![ToolchainPackage {
2485 crate_name: "greentic-operator-dev".to_string(),
2486 bins: vec!["greentic-operator-dev".to_string()],
2487 version: "0.5.123".to_string(),
2488 artifacts: None,
2489 }],
2490 extension_packs: None,
2491 components: None,
2492 gtc: None,
2493 };
2494 assert!(source_manifest_has_concrete_pins(&with_pins));
2495
2496 let only_latest = ToolchainManifest {
2497 packages: vec![ToolchainPackage {
2498 crate_name: "greentic-operator".to_string(),
2499 bins: vec!["greentic-operator".to_string()],
2500 version: "latest".to_string(),
2501 artifacts: None,
2502 }],
2503 ..with_pins
2504 };
2505 assert!(!source_manifest_has_concrete_pins(&only_latest));
2506 }
2507
2508 #[test]
2509 fn generate_from_rnd_uses_rnd_binary_names() {
2510 let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
2511 assert_eq!(manifest.channel.as_deref(), Some("rnd"));
2512 assert!(
2513 manifest
2514 .packages
2515 .iter()
2516 .flat_map(|package| package.bins.iter())
2517 .all(|bin| bin.ends_with("-rnd"))
2518 );
2519 assert!(manifest.packages.iter().any(|package| {
2520 package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
2521 }));
2522 }
2523
2524 #[test]
2525 fn bootstrap_source_manifest_uses_source_tag_identity() {
2526 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
2527 assert_eq!(manifest.version, "latest");
2528 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2529 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2530 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2531 assert!(
2532 manifest
2533 .packages
2534 .iter()
2535 .all(|package| package.version != "latest")
2536 );
2537 }
2538
2539 #[test]
2540 fn validates_schema_and_toolchain() {
2541 let mut manifest =
2542 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2543 assert!(validate_manifest(&manifest).is_ok());
2544 manifest.schema = "wrong".to_string();
2545 assert!(validate_manifest(&manifest).is_err());
2546 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
2547 manifest.toolchain = "other".to_string();
2548 assert!(validate_manifest(&manifest).is_err());
2549 }
2550
2551 #[test]
2552 fn resolves_inline_registry_token() {
2553 assert_eq!(
2554 resolve_registry_token(Some("secret-token"))
2555 .unwrap()
2556 .as_deref(),
2557 Some("secret-token")
2558 );
2559 }
2560
2561 #[test]
2562 fn resolves_registry_token_from_environment_reference() {
2563 let _guard = ENV_LOCK.lock().unwrap();
2564 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2565 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
2566
2567 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
2568 assert_eq!(resolved.as_deref(), Some("env-secret"));
2569
2570 match previous {
2571 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2572 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2573 }
2574 }
2575
2576 #[test]
2577 fn rejects_empty_registry_token_from_environment_reference() {
2578 let _guard = ENV_LOCK.lock().unwrap();
2579 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2580 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
2581
2582 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
2583 assert!(err.to_string().contains("resolved to an empty token"));
2584
2585 match previous {
2586 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2587 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2588 }
2589 }
2590
2591 #[test]
2592 fn registry_auth_uses_environment_fallbacks() {
2593 let _guard = ENV_LOCK.lock().unwrap();
2594 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2595 let previous_github = std::env::var("GITHUB_TOKEN").ok();
2596 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
2597 unsafe { std::env::remove_var("GITHUB_TOKEN") };
2598
2599 let auth = registry_auth(None).unwrap();
2600 match auth {
2601 RegistryAuth::Basic(user, token) => {
2602 assert_eq!(user, DEFAULT_OAUTH_USER);
2603 assert_eq!(token, "ghcr-secret");
2604 }
2605 _ => panic!("expected basic auth"),
2606 }
2607
2608 match previous_ghcr {
2609 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2610 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2611 }
2612 match previous_github {
2613 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2614 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2615 }
2616 }
2617
2618 #[test]
2619 fn optional_registry_auth_allows_missing_implicit_token() {
2620 let _guard = ENV_LOCK.lock().unwrap();
2621 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2622 let previous_github = std::env::var("GITHUB_TOKEN").ok();
2623 unsafe { std::env::remove_var("GHCR_TOKEN") };
2624 unsafe { std::env::remove_var("GITHUB_TOKEN") };
2625
2626 let auth = optional_registry_auth(None).unwrap();
2627 assert!(matches!(auth, RegistryAuth::Anonymous));
2628
2629 match previous_ghcr {
2630 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2631 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2632 }
2633 match previous_github {
2634 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2635 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2636 }
2637 }
2638
2639 #[test]
2640 fn release_view_tag_prefers_release_or_tag() {
2641 let args = ReleaseViewArgs {
2642 release: Some("1.0.5".to_string()),
2643 tag: None,
2644 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2645 token: None,
2646 };
2647 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
2648
2649 let args = ReleaseViewArgs {
2650 release: None,
2651 tag: Some("stable".to_string()),
2652 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2653 token: None,
2654 };
2655 assert_eq!(release_view_tag(&args).unwrap(), "stable");
2656 }
2657
2658 #[test]
2659 fn release_view_tag_rejects_invalid_argument_combinations() {
2660 let err = release_view_tag(&ReleaseViewArgs {
2661 release: Some("1.0.5".to_string()),
2662 tag: Some("stable".to_string()),
2663 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2664 token: None,
2665 })
2666 .unwrap_err();
2667 assert!(
2668 err.to_string()
2669 .contains("pass exactly one of --release or --tag")
2670 );
2671
2672 let err = release_view_tag(&ReleaseViewArgs {
2673 release: None,
2674 tag: None,
2675 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2676 token: None,
2677 })
2678 .unwrap_err();
2679 assert!(
2680 err.to_string()
2681 .contains("pass exactly one of --release or --tag")
2682 );
2683 }
2684
2685 #[test]
2686 fn publish_manifest_input_uses_local_manifest_version() {
2687 let dir = tempfile::tempdir().unwrap();
2688 let path = dir.path().join("gtc-1.0.12.json");
2689 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2690 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2691 let args = ReleasePublishArgs {
2692 release: None,
2693 from: None,
2694 tag: Some("stable".to_string()),
2695 manifest: Some(path.clone()),
2696 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2697 token: None,
2698 out: dir.path().to_path_buf(),
2699 dry_run: true,
2700 force: true,
2701 no_notify_updater: true,
2702 };
2703 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2704 assert_eq!(release, "1.0.12");
2705 assert_eq!(loaded, manifest);
2706 assert_eq!(
2707 source_path,
2708 Some(PublishManifestSource::Local(path.clone()))
2709 );
2710 }
2711
2712 #[test]
2713 fn publish_manifest_input_allows_release_override_for_local_manifest() {
2714 let dir = tempfile::tempdir().unwrap();
2715 let path = dir.path().join("gtc-1.0.13.json");
2716 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2717 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2718 let args = ReleasePublishArgs {
2719 release: Some("1.0.13".to_string()),
2720 from: None,
2721 tag: Some("stable".to_string()),
2722 manifest: Some(path.clone()),
2723 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2724 token: None,
2725 out: dir.path().to_path_buf(),
2726 dry_run: true,
2727 force: true,
2728 no_notify_updater: true,
2729 };
2730 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2731 assert_eq!(release, "1.0.13");
2732 assert_eq!(loaded.version, "1.0.13");
2733 assert_eq!(
2734 source_path,
2735 Some(PublishManifestSource::Local(path.clone()))
2736 );
2737 }
2738
2739 #[test]
2740 fn manifest_file_name_omits_stable_channel() {
2741 let manifest = ToolchainManifest {
2742 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2743 toolchain: TOOLCHAIN_NAME.to_string(),
2744 version: "1.0.12".to_string(),
2745 channel: Some("stable".to_string()),
2746 created_at: None,
2747 packages: Vec::new(),
2748 extension_packs: None,
2749 components: None,
2750 gtc: None,
2751 };
2752 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2753 }
2754
2755 #[test]
2756 fn parses_manifest_without_extension_sections() {
2757 let manifest: ToolchainManifest = serde_json::from_str(
2758 r#"{
2759 "schema": "greentic.toolchain-manifest.v1",
2760 "toolchain": "gtc",
2761 "version": "1.0.16",
2762 "channel": "stable",
2763 "packages": []
2764 }"#,
2765 )
2766 .unwrap();
2767
2768 assert_eq!(manifest.extension_packs, None);
2769 assert_eq!(manifest.components, None);
2770 }
2771
2772 #[test]
2773 fn generated_manifest_includes_catalogue_extension_sections() {
2774 let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2775 let json = serde_json::to_value(&manifest).unwrap();
2776
2777 assert!(json.get("extension_packs").is_some());
2778 assert!(json.get("components").is_some());
2779 assert!(
2780 manifest
2781 .extension_packs
2782 .as_ref()
2783 .unwrap()
2784 .iter()
2785 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2786 && item.version == "1.0.16")
2787 );
2788 assert!(
2789 manifest
2790 .extension_packs
2791 .as_ref()
2792 .unwrap()
2793 .iter()
2794 .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2795 && item.version == "1.0.16")
2796 );
2797 assert!(
2798 manifest
2799 .components
2800 .as_ref()
2801 .unwrap()
2802 .iter()
2803 .any(|item| item.id == "component/component-llm-openai"
2804 && item.version == "1.0.16")
2805 );
2806 }
2807
2808 #[test]
2809 fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2810 let source = ToolchainManifest {
2811 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2812 toolchain: TOOLCHAIN_NAME.to_string(),
2813 version: "dev".to_string(),
2814 channel: Some("dev".to_string()),
2815 created_at: None,
2816 packages: Vec::new(),
2817 extension_packs: Some(vec![ExtensionPackRef {
2818 id: "packs/messaging/messaging-webchat-gui".to_string(),
2819 version: "0.5.4".to_string(),
2820 }]),
2821 components: Some(vec![ComponentRef {
2822 id: "components/component-adaptive-card".to_string(),
2823 version: "0.5.8".to_string(),
2824 }]),
2825 gtc: None,
2826 };
2827
2828 let manifest =
2829 generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2830
2831 assert!(
2832 manifest
2833 .extension_packs
2834 .as_ref()
2835 .unwrap()
2836 .iter()
2837 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2838 && item.version == "0.5.4")
2839 );
2840 assert!(
2841 manifest
2842 .components
2843 .as_ref()
2844 .unwrap()
2845 .iter()
2846 .any(|item| item.id == "components/component-adaptive-card"
2847 && item.version == "0.5.8")
2848 );
2849 }
2850
2851 #[test]
2852 fn manifest_file_name_includes_non_stable_channel() {
2853 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2854 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2855
2856 manifest.channel = Some("customer-a".to_string());
2857 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2858 }
2859
2860 #[test]
2861 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2862 assert_eq!(
2863 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2864 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2865 );
2866 assert_eq!(
2867 manifest_bins_for_source("dev", &["greentic-dev"]),
2868 vec!["greentic-dev-dev".to_string()]
2869 );
2870 assert_eq!(
2871 manifest_crate_name_for_source("latest", "greentic-runner"),
2872 "greentic-runner"
2873 );
2874 assert_eq!(
2875 manifest_crate_name_for_source("dev", "greentic-runner"),
2876 "greentic-runner-dev"
2877 );
2878 }
2879
2880 #[test]
2881 fn source_version_map_handles_missing_and_present_sources() {
2882 assert!(source_version_map(None).is_empty());
2883
2884 let source = ToolchainManifest {
2885 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2886 toolchain: TOOLCHAIN_NAME.to_string(),
2887 version: "latest".to_string(),
2888 channel: Some("latest".to_string()),
2889 created_at: None,
2890 packages: vec![ToolchainPackage {
2891 crate_name: "greentic-dev".to_string(),
2892 bins: vec!["greentic-dev".to_string()],
2893 version: "0.6.0".to_string(),
2894 artifacts: None,
2895 }],
2896 extension_packs: None,
2897 components: None,
2898 gtc: None,
2899 };
2900
2901 let versions = source_version_map(Some(&source));
2902 assert_eq!(
2903 versions.get("greentic-dev").map(String::as_str),
2904 Some("0.6.0")
2905 );
2906 }
2907
2908 #[test]
2909 fn write_manifest_persists_json_to_expected_file_name() {
2910 let dir = tempfile::tempdir().unwrap();
2911 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2912
2913 let path = write_manifest(dir.path(), &manifest).unwrap();
2914 assert_eq!(
2915 path.file_name().and_then(|name| name.to_str()),
2916 Some("gtc-dev-1.0.12.json")
2917 );
2918
2919 let roundtrip = read_manifest_file(&path).unwrap();
2920 assert_eq!(roundtrip, manifest);
2921 }
2922
2923 #[test]
2924 fn latest_manifest_uses_latest_dev_bins() {
2925 let manifest = latest_manifest(None);
2926 assert_eq!(manifest.version, "latest");
2927 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2928 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2929 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2930 assert!(!manifest.packages.is_empty());
2931 assert!(
2932 manifest
2933 .packages
2934 .iter()
2935 .all(|package| package.version == "latest")
2936 );
2937 assert!(
2938 manifest
2939 .packages
2940 .iter()
2941 .flat_map(|package| package.bins.iter())
2942 .all(|bin| bin.ends_with("-dev"))
2943 );
2944 assert!(
2945 manifest
2946 .packages
2947 .iter()
2948 .all(|package| package.crate_name.ends_with("-dev")),
2949 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2950 );
2951 assert!(
2952 manifest
2953 .packages
2954 .iter()
2955 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2956 );
2957 assert!(manifest.packages.iter().any(|package| {
2958 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2959 }));
2960 assert!(
2961 manifest
2962 .extension_packs
2963 .as_ref()
2964 .unwrap()
2965 .iter()
2966 .all(|item| item.version == "latest")
2967 );
2968 assert!(
2969 manifest
2970 .components
2971 .as_ref()
2972 .unwrap()
2973 .iter()
2974 .all(|item| item.version == "latest")
2975 );
2976 }
2977
2978 #[test]
2979 fn publish_dry_run_with_local_manifest_succeeds() {
2980 let dir = tempfile::tempdir().unwrap();
2981 let path = dir.path().join("gtc-1.0.12.json");
2982 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2983 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2984
2985 let checker = StubReleaseChecker::complete_for(&manifest);
2988 publish_with_checker(
2989 ReleasePublishArgs {
2990 release: None,
2991 from: None,
2992 tag: Some("stable".to_string()),
2993 manifest: Some(path),
2994 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2995 token: None,
2996 out: dir.path().to_path_buf(),
2997 dry_run: true,
2998 force: false,
2999 no_notify_updater: true,
3000 },
3001 &checker,
3002 )
3003 .unwrap();
3004 }
3005
3006 struct StubReleaseChecker(BTreeMap<String, Vec<String>>);
3013
3014 impl ReleaseAssetChecker for StubReleaseChecker {
3015 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
3016 Ok(self.0.get(&format!("{repo}@{tag}")).cloned())
3017 }
3018 }
3019
3020 struct StubArtifactChecker(Vec<ReleaseArtifact>);
3023
3024 impl ReleaseAssetChecker for StubArtifactChecker {
3025 fn release_assets(&self, _repo: &str, _tag: &str) -> Result<Option<Vec<String>>> {
3026 Ok(Some(self.0.iter().map(|a| a.name.clone()).collect()))
3027 }
3028
3029 fn release_artifacts(&self, repo: &str, tag: &str) -> Result<Option<Vec<ReleaseArtifact>>> {
3030 assert_eq!(repo, GTC_RELEASE_REPO, "gtc is looked up in its own repo");
3031 assert_eq!(tag, "v1.2.3");
3032 Ok(Some(
3033 self.0
3034 .iter()
3035 .map(|a| ReleaseArtifact {
3036 name: a.name.clone(),
3037 url: a.url.clone(),
3038 sha256: a.sha256.clone(),
3039 })
3040 .collect(),
3041 ))
3042 }
3043 }
3044
3045 fn artifact(name: &str, digest: Option<&str>) -> ReleaseArtifact {
3046 ReleaseArtifact {
3047 name: name.to_string(),
3048 url: Some(format!(
3049 "https://github.com/greenticai/greentic/releases/download/v1.2.3/{name}"
3050 )),
3051 sha256: digest.map(str::to_string),
3052 }
3053 }
3054
3055 #[test]
3056 fn gtc_artifacts_are_taken_from_the_release_not_rebuilt() {
3057 let checker = StubArtifactChecker(vec![
3059 artifact(
3060 "gtc-dev-v1.2.3-x86_64-unknown-linux-gnu.tgz",
3061 Some(&format!("sha256:{}", "a".repeat(64))),
3062 ),
3063 artifact(
3064 "gtc-dev-v1.2.3-x86_64-unknown-linux-gnu.tgz.sha256",
3065 Some(&format!("sha256:{}", "b".repeat(64))),
3066 ),
3067 ]);
3068
3069 let named = gtc_artifacts_for("1.2.3", &checker)
3070 .expect("lookup")
3071 .expect("some");
3072 assert_eq!(named.len(), 1, "the .sha256 sidecar is not an artifact");
3073 assert_eq!(named[0].target, "x86_64-unknown-linux-gnu");
3074 assert!(
3075 named[0]
3076 .url
3077 .ends_with("gtc-dev-v1.2.3-x86_64-unknown-linux-gnu.tgz")
3078 );
3079 assert_eq!(named[0].sha256, "a".repeat(64));
3081 }
3082
3083 #[test]
3084 fn an_asset_without_a_digest_is_skipped_rather_than_published_unverifiable() {
3085 let checker = StubArtifactChecker(vec![artifact(
3086 "gtc-dev-v1.2.3-aarch64-apple-darwin.tgz",
3087 None,
3088 )]);
3089 assert!(
3090 gtc_artifacts_for("1.2.3", &checker)
3091 .expect("lookup")
3092 .is_none()
3093 );
3094 }
3095
3096 #[test]
3097 fn a_checker_that_reports_no_artifacts_leaves_the_field_absent() {
3098 let manifest = latest_manifest(None);
3101 let stub = StubReleaseChecker::complete_for(&manifest);
3102 assert!(gtc_artifacts_for("1.2.3", &stub).expect("lookup").is_none());
3103 }
3104
3105 impl StubReleaseChecker {
3106 fn complete_for(manifest: &ToolchainManifest) -> Self {
3108 Self(
3109 manifest
3110 .packages
3111 .iter()
3112 .map(|package| {
3113 (
3114 format!("{}@v{}", package.crate_name, package.version),
3115 complete_assets(&package.crate_name, &package.version),
3116 )
3117 })
3118 .collect(),
3119 )
3120 }
3121
3122 fn with(entries: &[(&str, Vec<String>)]) -> Self {
3123 Self(
3124 entries
3125 .iter()
3126 .map(|(key, assets)| ((*key).to_string(), assets.clone()))
3127 .collect(),
3128 )
3129 }
3130 }
3131
3132 fn complete_assets(crate_name: &str, version: &str) -> Vec<String> {
3134 let archive = format!("{crate_name}-v{version}-x86_64-unknown-linux-gnu.tgz");
3135 vec![format!("{archive}.sha256"), archive]
3136 }
3137
3138 fn manifest_with_channel(
3139 channel: Option<&str>,
3140 packages: &[(&str, &str)],
3141 ) -> ToolchainManifest {
3142 ToolchainManifest {
3143 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
3144 toolchain: TOOLCHAIN_NAME.to_string(),
3145 version: "1.1.13".to_string(),
3146 channel: channel.map(str::to_string),
3147 created_at: None,
3148 packages: packages
3149 .iter()
3150 .map(|(crate_name, version)| ToolchainPackage {
3151 crate_name: (*crate_name).to_string(),
3152 bins: vec![(*crate_name).to_string()],
3153 version: (*version).to_string(),
3154 artifacts: None,
3155 })
3156 .collect(),
3157 extension_packs: None,
3158 components: None,
3159 gtc: None,
3160 }
3161 }
3162
3163 #[test]
3164 fn release_gate_skips_dev_channel() {
3165 let manifest = manifest_with_channel(Some("dev"), &[("greentic-setup", "1.2.30516109579")]);
3166 verify_manifest_releases(&manifest, Some("dev"), &StubReleaseChecker::with(&[])).unwrap();
3168 }
3169
3170 #[test]
3171 fn release_gate_skips_manifest_without_channel_or_stable_tag() {
3172 let manifest = manifest_with_channel(None, &[("greentic-setup", "1.1.31")]);
3173 verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[])).unwrap();
3174 }
3175
3176 #[test]
3177 fn release_gate_accepts_complete_stable_releases() {
3178 let manifest = manifest_with_channel(
3179 Some("stable"),
3180 &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
3181 );
3182 let checker = StubReleaseChecker::complete_for(&manifest);
3183 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
3184 }
3185
3186 #[test]
3190 fn release_gate_rejects_pin_whose_release_does_not_exist() {
3191 let manifest = manifest_with_channel(
3192 Some("stable"),
3193 &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
3194 );
3195 let checker = StubReleaseChecker::with(&[(
3196 "greentic-start@v1.1.38",
3197 complete_assets("greentic-start", "1.1.38"),
3198 )]);
3199 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
3200 .unwrap_err()
3201 .to_string();
3202 assert!(error.contains("greentic-setup v1.1.31"), "{error}");
3203 assert!(error.contains("no GitHub release"), "{error}");
3204 assert!(!error.contains("greentic-start"), "{error}");
3205 }
3206
3207 #[test]
3210 fn release_gate_fires_on_stable_tag_despite_latest_channel() {
3211 let manifest = manifest_with_channel(Some("latest"), &[("greentic-setup", "1.1.31")]);
3212 let error =
3213 verify_manifest_releases(&manifest, Some("stable"), &StubReleaseChecker::with(&[]))
3214 .unwrap_err()
3215 .to_string();
3216 assert!(error.contains("greentic-setup v1.1.31"), "{error}");
3217 }
3218
3219 #[test]
3220 fn release_gate_rejects_release_with_no_archives_yet() {
3221 let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
3222 let checker = StubReleaseChecker::with(&[("greentic-setup@v1.1.31", Vec::new())]);
3223 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
3224 .unwrap_err()
3225 .to_string();
3226 assert!(error.contains("no v1.1.31 archives yet"), "{error}");
3227 }
3228
3229 #[test]
3230 fn release_gate_rejects_archive_missing_its_checksum() {
3231 let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
3232 let checker = StubReleaseChecker::with(&[(
3233 "greentic-setup@v1.1.31",
3234 vec![
3235 "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz".to_string(),
3236 "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
3237 "greentic-setup-v1.1.31-aarch64-apple-darwin.tgz".to_string(),
3238 ],
3239 )]);
3240 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
3241 .unwrap_err()
3242 .to_string();
3243 assert!(error.contains("missing .sha256"), "{error}");
3244 assert!(error.contains("aarch64-apple-darwin"), "{error}");
3245 }
3246
3247 #[test]
3250 fn release_gate_accepts_multi_binary_release() {
3251 let manifest = manifest_with_channel(Some("stable"), &[("greentic-mcp", "1.1.1")]);
3252 let checker = StubReleaseChecker::with(&[(
3253 "greentic-mcp@v1.1.1",
3254 vec![
3255 "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz".to_string(),
3256 "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
3257 "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip".to_string(),
3258 "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip.sha256".to_string(),
3259 ],
3260 )]);
3261 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
3262 }
3263
3264 #[test]
3269 fn release_gate_fires_on_stable_channel_without_a_target_tag() {
3270 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
3271 let error = verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[]))
3272 .unwrap_err()
3273 .to_string();
3274 assert!(error.contains("greentic-pack v1.1.5"), "{error}");
3275 assert!(error.contains("no GitHub release"), "{error}");
3276 }
3277
3278 #[test]
3282 fn release_gate_ignores_unversioned_binstall_aliases() {
3283 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
3284 let checker = StubReleaseChecker::with(&[(
3285 "greentic-pack@v1.1.5",
3286 vec![
3287 "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz".to_string(),
3288 "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
3289 "greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string(),
3291 ],
3292 )]);
3293 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
3294 }
3295
3296 #[test]
3299 fn release_gate_rejects_release_with_only_unversioned_aliases() {
3300 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
3301 let checker = StubReleaseChecker::with(&[(
3302 "greentic-pack@v1.1.5",
3303 vec!["greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string()],
3304 )]);
3305 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
3306 .unwrap_err()
3307 .to_string();
3308 assert!(error.contains("no v1.1.5 archives yet"), "{error}");
3309 }
3310
3311 #[test]
3312 fn release_response_404_means_absent() {
3313 assert_eq!(
3314 classify_release_response(reqwest::StatusCode::NOT_FOUND, "url", "{}".to_string())
3315 .unwrap(),
3316 None
3317 );
3318 }
3319
3320 #[test]
3321 fn release_response_success_returns_body() {
3322 assert_eq!(
3323 classify_release_response(reqwest::StatusCode::OK, "url", "{}".to_string()).unwrap(),
3324 Some("{}".to_string())
3325 );
3326 }
3327
3328 #[test]
3329 fn release_response_server_error_is_fatal() {
3330 assert!(
3333 classify_release_response(
3334 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
3335 "url",
3336 "boom".to_string()
3337 )
3338 .is_err()
3339 );
3340 }
3341
3342 #[test]
3343 fn latest_dry_run_succeeds() {
3344 latest(ReleaseLatestArgs {
3345 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
3346 token: None,
3347 dry_run: true,
3348 force: false,
3349 })
3350 .unwrap();
3351 }
3352
3353 #[test]
3354 fn promote_dry_run_succeeds() {
3355 promote(ReleasePromoteArgs {
3356 release: "1.0.12".to_string(),
3357 tag: "stable".to_string(),
3358 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
3359 token: None,
3360 dry_run: true,
3361 no_notify_updater: true,
3362 })
3363 .unwrap();
3364 }
3365
3366 #[test]
3367 fn builds_toolchain_ref() {
3368 assert_eq!(
3369 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
3370 "ghcr.io/greenticai/greentic-versions/gtc:stable"
3371 );
3372 }
3373
3374 #[test]
3379 fn notify_gate_accepts_stable_plain_version() {
3380 assert!(should_notify_updater("1.1.2", "stable"));
3381 }
3382
3383 #[test]
3384 fn notify_gate_accepts_stable_zero_version() {
3385 assert!(should_notify_updater("0.1.0", "stable"));
3386 }
3387
3388 #[test]
3389 fn notify_gate_rejects_dev_channel() {
3390 assert!(!should_notify_updater("1.1.2", "dev"));
3391 }
3392
3393 #[test]
3394 fn notify_gate_rejects_rnd_channel() {
3395 assert!(!should_notify_updater("1.1.2", "rnd"));
3396 }
3397
3398 #[test]
3399 fn notify_gate_rejects_prerelease_version() {
3400 assert!(!should_notify_updater("1.2.0-dev.3", "stable"));
3401 }
3402
3403 #[test]
3404 fn notify_gate_rejects_run_id_version_on_dev_channel() {
3405 assert!(!should_notify_updater("1.1.14995680637", "dev"));
3408 }
3409
3410 #[test]
3411 fn notify_gate_rejects_research_prerelease() {
3412 assert!(!should_notify_updater("1.3.0-research.1", "stable"));
3413 }
3414
3415 #[test]
3416 fn notify_gate_rejects_build_metadata() {
3417 assert!(!should_notify_updater("1.1.2+build.42", "stable"));
3418 }
3419
3420 #[test]
3421 fn notify_gate_rejects_empty_channel() {
3422 assert!(!should_notify_updater("1.1.2", ""));
3423 }
3424
3425 #[test]
3426 fn notify_gate_rejects_unparseable_version() {
3427 assert!(!should_notify_updater("not-a-version", "stable"));
3428 }
3429}