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