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