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) -> Result<Option<String>> {
499 if channel == ToolchainChannel::Rnd {
500 match resolver
501 .resolve_research_version(crate_in_manifest)
502 .with_context(|| {
503 format!("failed to resolve research version for `{crate_in_manifest}`")
504 })? {
505 ResearchVersion::Pinned(version) => Ok(Some(version)),
506 ResearchVersion::Absent => {
507 eprintln!(
508 "note: `{crate_in_manifest}` has no research build on crates.io; \
509 omitting it from the research toolchain manifest"
510 );
511 Ok(None)
512 }
513 }
514 } else {
515 resolver
516 .resolve_latest_for_channel(crate_in_manifest, channel)
517 .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
518 .map(Some)
519 }
520}
521
522pub fn snapshot_manifest<R: CrateVersionResolver>(
523 release: &str,
524 channel: ToolchainChannel,
525 resolver: &R,
526 created_at: Option<String>,
527) -> Result<ToolchainManifest> {
528 let from = channel_tag(channel);
529 let mut packages = Vec::new();
530 for package in GREENTIC_TOOLCHAIN_PACKAGES {
531 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
532 let Some(version) = resolve_manifest_version(resolver, &crate_in_manifest, channel)? else {
533 continue;
534 };
535 packages.push(ToolchainPackage {
536 crate_name: crate_in_manifest,
537 bins: manifest_bins_for_source(from, package.bins),
538 version,
539 });
540 }
541 Ok(ToolchainManifest {
542 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
543 toolchain: TOOLCHAIN_NAME.to_string(),
544 version: release.to_string(),
545 channel: Some(from.to_string()),
546 created_at,
547 packages,
548 extension_packs: None,
549 components: None,
550 })
551}
552
553pub fn view(args: ReleaseViewArgs) -> Result<()> {
554 let tag = release_view_tag(&args)?;
555 let manifest = block_on_maybe_runtime(load_source_manifest(
556 &args.repo,
557 &tag,
558 args.token.as_deref(),
559 ))
560 .with_context(|| {
561 format!(
562 "failed to resolve manifest `{}`",
563 toolchain_ref(&args.repo, &tag)
564 )
565 })?
566 .with_context(|| {
567 format!(
568 "manifest `{}` was not found or is not authorized for this token",
569 toolchain_ref(&args.repo, &tag)
570 )
571 })?;
572 println!("{}", serde_json::to_string_pretty(&manifest)?);
573 Ok(())
574}
575
576pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
577 let manifest = latest_manifest(Some(created_at_now()?));
578 if args.dry_run {
579 println!("{}", serde_json::to_string_pretty(&manifest)?);
580 println!(
581 "Dry run: would publish {}",
582 toolchain_ref(&args.repo, "latest")
583 );
584 return Ok(());
585 }
586
587 let auth = registry_auth(args.token.as_deref())?;
588 block_on_maybe_runtime(async {
589 let client = oci_client();
590 let latest_ref = parse_reference(&args.repo, "latest")?;
591 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
592 bail!(
593 "latest tag `{}` already exists; pass --force to overwrite it",
594 toolchain_ref(&args.repo, "latest")
595 );
596 }
597 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
598 })?;
599 println!("Published {}", toolchain_ref(&args.repo, "latest"));
600 Ok(())
601}
602
603fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
604 ToolchainManifest {
605 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
606 toolchain: TOOLCHAIN_NAME.to_string(),
607 version: "latest".to_string(),
608 channel: Some("latest".to_string()),
609 created_at,
610 packages: latest_manifest_packages(),
611 extension_packs: Some(
612 GREENTIC_EXTENSION_PACK_PACKAGES
613 .iter()
614 .map(|package| ExtensionPackRef {
615 id: package.package.to_string(),
616 version: "latest".to_string(),
617 })
618 .collect(),
619 ),
620 components: Some(
621 GREENTIC_COMPONENT_PACKAGES
622 .iter()
623 .map(|package| ComponentRef {
624 id: package.package.to_string(),
625 version: "latest".to_string(),
626 })
627 .collect(),
628 ),
629 }
630}
631
632fn latest_manifest_packages() -> Vec<ToolchainPackage> {
633 std::iter::once(ToolchainPackage {
634 crate_name: delegated_binary_name_for_channel(
635 TOOLCHAIN_NAME,
636 ToolchainChannel::Development,
637 ),
638 bins: vec![delegated_binary_name_for_channel(
639 TOOLCHAIN_NAME,
640 ToolchainChannel::Development,
641 )],
642 version: "latest".to_string(),
643 })
644 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
645 ToolchainPackage {
646 crate_name: delegated_binary_name_for_channel(
647 package.crate_name,
648 ToolchainChannel::Development,
649 ),
650 bins: package
651 .bins
652 .iter()
653 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
654 .collect(),
655 version: "latest".to_string(),
656 }
657 }))
658 .collect()
659}
660
661fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
662 match (&args.release, &args.tag) {
663 (Some(release), None) => Ok(release.clone()),
664 (None, Some(tag)) => Ok(tag.clone()),
665 _ => bail!("pass exactly one of --release or --tag"),
666 }
667}
668
669pub fn generate_manifest<R: CrateVersionResolver>(
670 release: &str,
671 from: &str,
672 source: Option<&ToolchainManifest>,
673 resolver: &R,
674 created_at: Option<String>,
675) -> Result<ToolchainManifest> {
676 let artifact_resolver = ReleaseArtifactVersionResolver { release };
677 generate_manifest_with_artifact_resolver(
678 release,
679 from,
680 source,
681 resolver,
682 &artifact_resolver,
683 created_at,
684 )
685}
686
687pub fn generate_manifest_with_artifact_resolver<R, A>(
688 release: &str,
689 from: &str,
690 source: Option<&ToolchainManifest>,
691 resolver: &R,
692 artifact_resolver: &A,
693 created_at: Option<String>,
694) -> Result<ToolchainManifest>
695where
696 R: CrateVersionResolver,
697 A: ArtifactVersionResolver,
698{
699 if let Some(source) = source {
700 validate_manifest(source)?;
701 }
702 let source_versions = source_version_map(source);
703 let mut packages = Vec::new();
704 for package in GREENTIC_TOOLCHAIN_PACKAGES {
705 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
706 let source_version = source_versions.get(&crate_in_manifest);
707 let version = match source_version.map(String::as_str) {
708 Some(version) if version != "latest" => Some(version.to_string()),
709 _ => resolve_manifest_version(
710 resolver,
711 &crate_in_manifest,
712 channel_from_source_tag(from),
713 )?,
714 };
715 let Some(version) = version else {
716 continue;
717 };
718 packages.push(ToolchainPackage {
719 crate_name: crate_in_manifest,
720 bins: manifest_bins_for_source(from, package.bins),
721 version,
722 });
723 }
724 Ok(ToolchainManifest {
725 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
726 toolchain: TOOLCHAIN_NAME.to_string(),
727 version: release.to_string(),
728 channel: Some(from.to_string()),
729 created_at,
730 packages,
731 extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
732 components: Some(component_refs_for_release(source, artifact_resolver)?),
733 })
734}
735
736fn channel_from_source_tag(from: &str) -> ToolchainChannel {
738 match from {
739 "dev" => ToolchainChannel::Development,
740 "rnd" => ToolchainChannel::Rnd,
741 _ => ToolchainChannel::Stable,
742 }
743}
744
745fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
746 let channel = channel_from_source_tag(from);
747 bins.iter()
748 .map(|bin| delegated_binary_name_for_channel(bin, channel))
749 .collect()
750}
751
752fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
753 source: Option<&ToolchainManifest>,
754 artifact_resolver: &A,
755) -> Result<Vec<ExtensionPackRef>> {
756 let source_versions = source_ref_version_map(source.and_then(|manifest| {
757 manifest
758 .extension_packs
759 .as_ref()
760 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
761 }));
762 GREENTIC_EXTENSION_PACK_PACKAGES
763 .iter()
764 .map(|package| {
765 Ok(ExtensionPackRef {
766 id: package.package.to_string(),
767 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
768 })
769 })
770 .collect()
771}
772
773fn component_refs_for_release<A: ArtifactVersionResolver>(
774 source: Option<&ToolchainManifest>,
775 artifact_resolver: &A,
776) -> Result<Vec<ComponentRef>> {
777 let source_versions = source_ref_version_map(source.and_then(|manifest| {
778 manifest
779 .components
780 .as_ref()
781 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
782 }));
783 GREENTIC_COMPONENT_PACKAGES
784 .iter()
785 .map(|package| {
786 Ok(ComponentRef {
787 id: package.package.to_string(),
788 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
789 })
790 })
791 .collect()
792}
793
794fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
795where
796 I: Iterator<Item = (&'a String, &'a String)>,
797{
798 let mut out = BTreeMap::new();
799 if let Some(refs) = refs {
800 for (id, version) in refs {
801 out.insert(id.clone(), version.clone());
802 }
803 }
804 out
805}
806
807fn ref_version_for_package(
808 package: &OciPackageSpec,
809 source_versions: &BTreeMap<String, String>,
810 artifact_resolver: &impl ArtifactVersionResolver,
811) -> Result<String> {
812 match source_versions.get(package.package).map(String::as_str) {
813 Some(version) if version != "latest" => Ok(version.to_string()),
814 _ => artifact_resolver
815 .resolve_latest(package.package)
816 .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
817 }
818}
819
820fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
828 if from == "dev" {
829 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
830 } else {
831 crate_name.to_string()
832 }
833}
834
835pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
836 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
837 bail!(
838 "unsupported toolchain manifest schema `{}`",
839 manifest.schema
840 );
841 }
842 if manifest.toolchain != TOOLCHAIN_NAME {
843 bail!("unsupported toolchain `{}`", manifest.toolchain);
844 }
845 Ok(())
846}
847
848pub fn toolchain_ref(repo: &str, tag: &str) -> String {
849 format!("{repo}:{tag}")
850}
851
852const GITHUB_API_BASE: &str = "https://api.github.com";
858const TOOLCHAIN_RELEASE_OWNER: &str = "greenticai";
860const RELEASE_ARCHIVE_SUFFIXES: [&str; 2] = [".tgz", ".zip"];
862
863trait ReleaseAssetChecker {
866 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>>;
868}
869
870#[derive(Deserialize)]
871struct GithubReleaseAssets {
872 #[serde(default)]
873 assets: Vec<GithubReleaseAsset>,
874}
875
876#[derive(Deserialize)]
877struct GithubReleaseAsset {
878 name: String,
879}
880
881struct GithubReleaseAssetChecker {
882 base_url: String,
883 token: Option<String>,
884 client: reqwest::blocking::Client,
885}
886
887impl GithubReleaseAssetChecker {
888 fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
889 let client = reqwest::blocking::Client::builder()
890 .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
891 .build()
892 .expect("failed to build GitHub API client");
893 Self {
894 base_url: base_url.into(),
895 token,
896 client,
897 }
898 }
899}
900
901impl ReleaseAssetChecker for GithubReleaseAssetChecker {
902 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
903 let url = format!(
904 "{}/repos/{TOOLCHAIN_RELEASE_OWNER}/{repo}/releases/tags/{tag}",
905 self.base_url.trim_end_matches('/')
906 );
907 let mut request = self
908 .client
909 .get(&url)
910 .header(reqwest::header::ACCEPT, "application/vnd.github+json");
911 if let Some(token) = &self.token {
912 request = request.bearer_auth(token);
913 }
914 let response = request
915 .send()
916 .with_context(|| format!("failed to GET {url}"))?;
917 let status = response.status();
918 let body = response
919 .text()
920 .with_context(|| format!("failed to read body of {url}"))?;
921 let Some(body) = classify_release_response(status, &url, body)? else {
922 return Ok(None);
923 };
924 let release: GithubReleaseAssets = serde_json::from_str(&body)
925 .with_context(|| format!("failed to parse release metadata from {url}"))?;
926 Ok(Some(
927 release.assets.into_iter().map(|asset| asset.name).collect(),
928 ))
929 }
930}
931
932fn default_release_checker(raw_token: Option<&str>) -> GithubReleaseAssetChecker {
936 GithubReleaseAssetChecker::new(GITHUB_API_BASE, ambient_github_token(raw_token))
937}
938
939fn classify_release_response(
943 status: reqwest::StatusCode,
944 url: &str,
945 body: String,
946) -> Result<Option<String>> {
947 if status == reqwest::StatusCode::NOT_FOUND {
948 return Ok(None);
949 }
950 if !status.is_success() {
951 bail!("GitHub API GET {url} returned {status}: {body}");
952 }
953 Ok(Some(body))
954}
955
956fn affects_stable_channel(manifest: &ToolchainManifest, target_tag: Option<&str>) -> bool {
964 manifest.channel.as_deref() == Some("stable") || target_tag == Some("stable")
965}
966
967fn verify_manifest_releases(
974 manifest: &ToolchainManifest,
975 target_tag: Option<&str>,
976 checker: &dyn ReleaseAssetChecker,
977) -> Result<()> {
978 if !affects_stable_channel(manifest, target_tag) {
979 return Ok(());
980 }
981 let mut problems = Vec::new();
982 for package in &manifest.packages {
983 let tag = format!("v{}", package.version);
984 match checker.release_assets(&package.crate_name, &tag)? {
985 None => problems.push(format!(
986 "{} {tag}: no GitHub release (build not finished)",
987 package.crate_name
988 )),
989 Some(assets) => {
990 if let Err(reason) = check_release_assets(&assets, &package.version) {
991 problems.push(format!("{} {tag}: {reason}", package.crate_name));
992 }
993 }
994 }
995 }
996 if !problems.is_empty() {
997 bail!(
998 "refusing to publish toolchain manifest {}: {} pinned package(s) are not \
999 downloadable yet:\n {}\nWait for the release builds to finish, then retry.",
1000 manifest.version,
1001 problems.len(),
1002 problems.join("\n ")
1003 );
1004 }
1005 Ok(())
1006}
1007
1008fn check_release_assets(assets: &[String], version: &str) -> Result<(), String> {
1029 let names: BTreeSet<&str> = assets.iter().map(String::as_str).collect();
1030 let version_marker = format!("-v{version}-");
1031 let archives: Vec<&str> = names
1032 .iter()
1033 .copied()
1034 .filter(|name| {
1035 name.contains(&version_marker)
1036 && RELEASE_ARCHIVE_SUFFIXES
1037 .iter()
1038 .any(|suffix| name.ends_with(suffix))
1039 })
1040 .collect();
1041 if archives.is_empty() {
1042 return Err(format!(
1043 "release has no v{version} archives yet (upload in progress)"
1044 ));
1045 }
1046 let unchecksummed: Vec<&str> = archives
1047 .iter()
1048 .copied()
1049 .filter(|name| !names.contains(format!("{name}.sha256").as_str()))
1050 .collect();
1051 if !unchecksummed.is_empty() {
1052 return Err(format!(
1053 "archives missing .sha256 (upload in progress): {}",
1054 unchecksummed.join(", ")
1055 ));
1056 }
1057 Ok(())
1058}
1059
1060fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
1061 let mut out = BTreeMap::new();
1062 if let Some(source) = source {
1063 for package in &source.packages {
1064 out.insert(package.crate_name.clone(), package.version.clone());
1065 }
1066 }
1067 out
1068}
1069
1070fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
1071 fs::create_dir_all(out_dir)
1072 .with_context(|| format!("failed to create {}", out_dir.display()))?;
1073 let path = out_dir.join(manifest_file_name(manifest));
1074 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1075 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
1076 Ok(path)
1077}
1078
1079fn manifest_file_name(manifest: &ToolchainManifest) -> String {
1080 match manifest.channel.as_deref() {
1081 Some("stable") | None => format!("gtc-{}.json", manifest.version),
1082 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
1083 }
1084}
1085
1086fn created_at_now() -> Result<String> {
1087 OffsetDateTime::now_utc()
1088 .format(&Rfc3339)
1089 .context("failed to format current time")
1090}
1091
1092pub enum ResearchVersion {
1099 Pinned(String),
1101 Absent,
1105}
1106
1107pub trait CrateVersionResolver {
1108 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
1109
1110 fn resolve_latest_for_channel(
1117 &self,
1118 crate_name: &str,
1119 _channel: ToolchainChannel,
1120 ) -> Result<String> {
1121 self.resolve_latest(crate_name)
1122 }
1123
1124 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1130 self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
1131 .map(ResearchVersion::Pinned)
1132 }
1133}
1134
1135fn default_resolver() -> CratesIoApiVersionResolver {
1139 CratesIoApiVersionResolver::default()
1140}
1141
1142pub trait ArtifactVersionResolver {
1143 fn resolve_latest(&self, package: &str) -> Result<String>;
1144}
1145
1146const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
1147const CRATES_IO_USER_AGENT: &str = concat!(
1148 "greentic-dev/",
1149 env!("CARGO_PKG_VERSION"),
1150 " (https://github.com/greenticai/greentic-dev)"
1151);
1152
1153pub struct CratesIoApiVersionResolver {
1160 base_url: String,
1161 client: reqwest::blocking::Client,
1162}
1163
1164impl Default for CratesIoApiVersionResolver {
1165 fn default() -> Self {
1166 Self::new(CRATES_IO_API_BASE)
1167 }
1168}
1169
1170impl CratesIoApiVersionResolver {
1171 pub fn new(base_url: impl Into<String>) -> Self {
1172 let client = reqwest::blocking::Client::builder()
1173 .user_agent(CRATES_IO_USER_AGENT)
1174 .build()
1175 .expect("failed to build crates.io API client");
1176 Self {
1177 base_url: base_url.into(),
1178 client,
1179 }
1180 }
1181
1182 fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
1187 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1188 let response = self
1189 .client
1190 .get(&url)
1191 .send()
1192 .with_context(|| format!("failed to GET {url}"))?;
1193 let status = response.status();
1194 let body = response
1195 .text()
1196 .with_context(|| format!("failed to read body of {url}"))?;
1197 classify_crate_response(status, &url, body)
1198 }
1199}
1200
1201fn classify_crate_response(
1205 status: reqwest::StatusCode,
1206 url: &str,
1207 body: String,
1208) -> Result<Option<String>> {
1209 if status == reqwest::StatusCode::NOT_FOUND {
1210 return Ok(None);
1211 }
1212 if !status.is_success() {
1213 bail!("crates.io API GET {url} returned {status}: {body}");
1214 }
1215 Ok(Some(body))
1216}
1217
1218fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
1224 match parse_crates_io_research_version(crate_name, body) {
1225 Ok(version) => Ok(version),
1226 Err(_) => {
1227 let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
1228 eprintln!(
1229 "note: `{crate_name}` has no -research publish; the research \
1230 toolchain falls back to latest `{fallback}`"
1231 );
1232 Ok(fallback)
1233 }
1234 }
1235}
1236
1237struct ReleaseArtifactVersionResolver<'a> {
1238 release: &'a str,
1239}
1240
1241impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
1242 fn resolve_latest(&self, _package: &str) -> Result<String> {
1243 Ok(self.release.to_string())
1244 }
1245}
1246
1247struct GhcrArtifactVersionResolver {
1248 client: reqwest::blocking::Client,
1249 registry: String,
1250 namespace: String,
1251 basic_token: Option<String>,
1252}
1253
1254impl GhcrArtifactVersionResolver {
1255 fn new(raw_token: Option<&str>) -> Result<Self> {
1256 Ok(Self {
1257 client: reqwest::blocking::Client::builder()
1258 .build()
1259 .context("failed to build GHCR HTTP client")?,
1260 registry: "ghcr.io".to_string(),
1261 namespace: "greenticai".to_string(),
1262 basic_token: resolve_registry_token(raw_token)?
1263 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1264 .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
1265 })
1266 }
1267
1268 fn bearer_token(&self, repository: &str) -> Result<String> {
1269 let scope = format!("repository:{repository}:pull");
1270 let mut request = self
1271 .client
1272 .get(format!("https://{}/token", self.registry))
1273 .query(&[
1274 ("service", self.registry.as_str()),
1275 ("scope", scope.as_str()),
1276 ]);
1277 if let Some(token) = &self.basic_token {
1278 request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
1279 }
1280 let response = request
1281 .send()
1282 .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
1283 .error_for_status()
1284 .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
1285 let body: GhcrTokenResponse = response
1286 .json()
1287 .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1288 Ok(body.token)
1289 }
1290
1291 fn tags(&self, repository: &str) -> Result<Vec<String>> {
1292 let token = self.bearer_token(repository)?;
1293 let response = self
1294 .client
1295 .get(format!(
1296 "https://{}/v2/{repository}/tags/list",
1297 self.registry
1298 ))
1299 .bearer_auth(token)
1300 .send()
1301 .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1302 .error_for_status()
1303 .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1304 let body: GhcrTagsResponse = response
1305 .json()
1306 .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1307 Ok(body.tags)
1308 }
1309}
1310
1311impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1312 fn resolve_latest(&self, package: &str) -> Result<String> {
1313 let repository = format!("{}/{}", self.namespace, package);
1314 let tags = self.tags(&repository)?;
1315 select_latest_artifact_tag(&tags)
1316 .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1317 }
1318}
1319
1320#[derive(Deserialize)]
1321struct GhcrTokenResponse {
1322 token: String,
1323}
1324
1325#[derive(Deserialize)]
1326struct GhcrTagsResponse {
1327 #[serde(default)]
1328 tags: Vec<String>,
1329}
1330
1331fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1332 tags.iter()
1333 .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1334 .max_by(|(left, _), (right, _)| left.cmp(right))
1335 .map(|(_, tag)| tag.clone())
1336 .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1337 .context("no semver or latest tags found")
1338}
1339
1340impl CrateVersionResolver for CratesIoApiVersionResolver {
1341 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1342 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1343 let response = self
1344 .client
1345 .get(&url)
1346 .send()
1347 .with_context(|| format!("failed to GET {url}"))?;
1348 let status = response.status();
1349 let body = response
1350 .text()
1351 .with_context(|| format!("failed to read body of {url}"))?;
1352 if !status.is_success() {
1353 bail!("crates.io API GET {url} returned {status}: {body}");
1354 }
1355 parse_crates_io_version(crate_name, &body)
1356 }
1357
1358 fn resolve_latest_for_channel(
1359 &self,
1360 crate_name: &str,
1361 channel: ToolchainChannel,
1362 ) -> Result<String> {
1363 if channel != ToolchainChannel::Rnd {
1364 return self.resolve_latest(crate_name);
1365 }
1366 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1367 let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1368 anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1369 })?;
1370 research_or_fallback(crate_name, &body)
1371 }
1372
1373 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1374 match self.fetch_crate_body(crate_name)? {
1379 None => Ok(ResearchVersion::Absent),
1380 Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1381 }
1382 }
1383}
1384
1385fn pick_highest_crates_io_version(
1392 crate_name: &str,
1393 body: &str,
1394 research_only: bool,
1395) -> Result<String> {
1396 let payload: serde_json::Value = serde_json::from_str(body)
1397 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1398 let versions = payload
1399 .get("versions")
1400 .and_then(|v| v.as_array())
1401 .ok_or_else(|| {
1402 anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1403 })?;
1404 let mut best: Option<Version> = None;
1405 for entry in versions {
1406 if entry
1407 .get("yanked")
1408 .and_then(serde_json::Value::as_bool)
1409 .unwrap_or(false)
1410 {
1411 continue;
1412 }
1413 let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1414 continue;
1415 };
1416 let Ok(parsed) = Version::parse(num) else {
1417 continue;
1418 };
1419 if research_only && !parsed.pre.as_str().starts_with("research") {
1420 continue;
1421 }
1422 if best.as_ref().is_none_or(|current| parsed > *current) {
1423 best = Some(parsed);
1424 }
1425 }
1426 best.map(|v| v.to_string()).ok_or_else(|| {
1427 let what = if research_only {
1428 "no non-yanked `-research` version"
1429 } else {
1430 "no non-yanked versions"
1431 };
1432 anyhow!("crates.io API for `{crate_name}` exposes {what}")
1433 })
1434}
1435
1436fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1437 pick_highest_crates_io_version(crate_name, body, true)
1438}
1439
1440fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1441 let payload: serde_json::Value = serde_json::from_str(body)
1442 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1443 let crate_obj = payload.get("crate").ok_or_else(|| {
1444 anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1445 })?;
1446 let version = crate_obj
1447 .get("max_stable_version")
1448 .and_then(|v| v.as_str())
1449 .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1450 .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1451 .ok_or_else(|| {
1452 anyhow!(
1453 "crates.io API for `{crate_name}` does not expose max_stable_version, \
1454 newest_version, or max_version"
1455 )
1456 })?;
1457 Version::parse(version).with_context(|| {
1458 format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1459 })?;
1460 Ok(version.to_string())
1461}
1462
1463#[async_trait]
1464trait ToolchainManifestSource {
1465 async fn load_manifest(
1466 &self,
1467 repo: &str,
1468 tag: &str,
1469 token: Option<&str>,
1470 ) -> Result<Option<ToolchainManifest>>;
1471}
1472
1473struct OciToolchainManifestSource;
1474
1475#[async_trait]
1476impl ToolchainManifestSource for OciToolchainManifestSource {
1477 async fn load_manifest(
1478 &self,
1479 repo: &str,
1480 tag: &str,
1481 token: Option<&str>,
1482 ) -> Result<Option<ToolchainManifest>> {
1483 let auth = optional_registry_auth(token)?;
1484 let client = oci_client();
1485 let reference = parse_reference(repo, tag)?;
1486 let image = match client
1487 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1488 .await
1489 {
1490 Ok(image) => image,
1491 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1492 return Ok(None);
1493 }
1494 Err(err) => {
1495 return Err(err)
1496 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1497 }
1498 };
1499 let Some(layer) = image
1500 .layers
1501 .into_iter()
1502 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1503 else {
1504 return Ok(None);
1505 };
1506 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1507 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1508 validate_manifest(&manifest)?;
1509 Ok(Some(manifest))
1510 }
1511}
1512
1513async fn load_source_manifest(
1514 repo: &str,
1515 tag: &str,
1516 token: Option<&str>,
1517) -> Result<Option<ToolchainManifest>> {
1518 OciToolchainManifestSource
1519 .load_manifest(repo, tag, token)
1520 .await
1521}
1522
1523fn oci_client() -> Client {
1524 Client::new(ClientConfig {
1525 protocol: ClientProtocol::Https,
1526 ..Default::default()
1527 })
1528}
1529
1530fn should_notify_updater(version: &str, channel: &str) -> bool {
1539 if channel != "stable" {
1540 return false;
1541 }
1542 match Version::parse(version) {
1543 Ok(v) => v.pre.is_empty() && v.build.is_empty(),
1544 Err(_) => false,
1545 }
1546}
1547
1548fn ambient_github_token(raw_token: Option<&str>) -> Option<String> {
1552 resolve_registry_token(raw_token)
1553 .ok()
1554 .flatten()
1555 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1556 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1557 .filter(|token| !token.trim().is_empty())
1558}
1559
1560fn notify_updater_dispatch(version: &str, channel: &str, raw_token: Option<&str>) {
1565 if !should_notify_updater(version, channel) {
1566 eprintln!(
1567 "Skipping updater dispatch: version `{version}` / channel `{channel}` \
1568 is not a stable-lane release"
1569 );
1570 return;
1571 }
1572
1573 let Some(token) = ambient_github_token(raw_token) else {
1574 eprintln!(
1575 "Warning: skipping updater dispatch — no token available \
1576 (pass --token or set GHCR_TOKEN/GITHUB_TOKEN)"
1577 );
1578 return;
1579 };
1580
1581 let client = match reqwest::blocking::Client::builder()
1582 .user_agent("greentic-dev-cli")
1583 .build()
1584 {
1585 Ok(c) => c,
1586 Err(e) => {
1587 eprintln!("Warning: failed to build HTTP client for updater dispatch: {e}");
1588 return;
1589 }
1590 };
1591
1592 let body = serde_json::json!({
1593 "event_type": "toolchain-release-published",
1594 "client_payload": {
1595 "release": version,
1596 "channel": channel,
1597 }
1598 });
1599
1600 let url = "https://api.github.com/repos/greenticai/greentic-dev/dispatches";
1601 match client
1602 .post(url)
1603 .header("Accept", "application/vnd.github+json")
1604 .bearer_auth(&token)
1605 .json(&body)
1606 .send()
1607 {
1608 Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
1609 eprintln!("Dispatched toolchain-release-published for {version} (channel={channel})");
1610 }
1611 Ok(resp) => {
1612 let status = resp.status();
1613 let text = resp.text().unwrap_or_default();
1614 eprintln!("Warning: updater dispatch returned HTTP {status}: {text}");
1615 }
1616 Err(e) => {
1617 eprintln!("Warning: updater dispatch failed: {e}");
1618 }
1619 }
1620}
1621
1622fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1623 let token = resolve_registry_token(raw_token)?
1624 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1625 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1626 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1627 if token.trim().is_empty() {
1628 bail!("GHCR token is empty");
1629 }
1630 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1631}
1632
1633fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1634 match registry_auth(raw_token) {
1635 Ok(auth) => Ok(auth),
1636 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1637 Err(err) => Err(err),
1638 }
1639}
1640
1641fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1642 let Some(raw_token) = raw_token else {
1643 return Ok(None);
1644 };
1645 if let Some(var) = raw_token.strip_prefix("env:") {
1646 let token =
1647 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1648 if token.trim().is_empty() {
1649 bail!("env var {var} resolved to an empty token");
1650 }
1651 return Ok(Some(token));
1652 }
1653 if raw_token.trim().is_empty() {
1654 bail!("GHCR token is empty");
1655 }
1656 Ok(Some(raw_token.to_string()))
1657}
1658
1659fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1660 Reference::from_str(&toolchain_ref(repo, tag))
1661 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1662}
1663
1664async fn manifest_exists(
1665 client: &Client,
1666 reference: &Reference,
1667 auth: &RegistryAuth,
1668) -> Result<bool> {
1669 match client.pull_manifest(reference, auth).await {
1670 Ok(_) => Ok(true),
1671 Err(err) if is_missing_manifest_error(&err) => Ok(false),
1672 Err(err) => Err(err).context("failed to check whether release tag exists"),
1673 }
1674}
1675
1676fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1677 let msg = err.to_string().to_ascii_lowercase();
1678 msg.contains("manifest unknown")
1679 || msg.contains("name unknown")
1680 || msg.contains("not found")
1681 || msg.contains("404")
1682}
1683
1684fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1685 let msg = err.to_string().to_ascii_lowercase();
1686 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1687}
1688
1689async fn push_manifest_layer(
1690 client: &Client,
1691 reference: &Reference,
1692 auth: &RegistryAuth,
1693 manifest: &ToolchainManifest,
1694) -> Result<()> {
1695 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1696 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1697 let config = Config::new(
1698 br#"{"toolchain":"gtc"}"#.to_vec(),
1699 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1700 None,
1701 );
1702 client
1703 .push(reference, &[layer], config, auth, None)
1704 .await
1705 .context("failed to push toolchain manifest")?;
1706 Ok(())
1707}
1708
1709#[cfg(test)]
1710mod tests {
1711 use super::*;
1712 use once_cell::sync::Lazy;
1713 use std::sync::Mutex;
1714
1715 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1716
1717 struct FixedResolver;
1718
1719 impl CrateVersionResolver for FixedResolver {
1720 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1721 Ok(match crate_name {
1722 "greentic-runner" => "0.5.10",
1723 _ => "1.2.3",
1724 }
1725 .to_string())
1726 }
1727 }
1728
1729 struct FixedArtifactResolver;
1730
1731 impl ArtifactVersionResolver for FixedArtifactResolver {
1732 fn resolve_latest(&self, package: &str) -> Result<String> {
1733 Ok(match package {
1734 "packs/messaging/messaging-webchat-gui" => "0.4.93",
1735 "components/component-adaptive-card" => "0.5.8",
1736 _ => "0.1.0",
1737 }
1738 .to_string())
1739 }
1740 }
1741
1742 #[test]
1743 fn parses_crates_io_max_stable_version() {
1744 let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1745 let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1746 assert_eq!(version, "0.5.123");
1747 }
1748
1749 #[test]
1750 fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1751 let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1754 "versions":[
1755 {"num":"0.5.48","yanked":false},
1756 {"num":"1.2.0-research.0","yanked":false},
1757 {"num":"1.2.0-research.1","yanked":false},
1758 {"num":"1.2.0-research.2","yanked":true}
1759 ]}"#;
1760 let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1761 assert_eq!(version, "1.2.0-research.1");
1762 }
1763
1764 #[test]
1765 fn research_resolver_errors_when_no_research_version() {
1766 let body = r#"{"crate":{"id":"greentic-setup"},
1767 "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1768 let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1769 assert!(err.to_string().contains("no non-yanked `-research`"));
1770 }
1771
1772 struct ResearchSkipResolver;
1776
1777 impl CrateVersionResolver for ResearchSkipResolver {
1778 fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1779 Ok("1.2.0-research.4".to_string())
1780 }
1781
1782 fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1783 if crate_name.contains("operator") {
1784 Ok(ResearchVersion::Absent)
1785 } else {
1786 Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1787 }
1788 }
1789 }
1790
1791 #[test]
1792 fn classify_crate_response_maps_404_to_absent() {
1793 let outcome =
1794 classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1795 .unwrap();
1796 assert!(outcome.is_none(), "404 must classify as absent (None)");
1797 }
1798
1799 #[test]
1800 fn classify_crate_response_returns_body_on_success() {
1801 let outcome =
1802 classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1803 assert_eq!(outcome.as_deref(), Some("payload"));
1804 }
1805
1806 #[test]
1807 fn classify_crate_response_errors_on_other_status() {
1808 let err = classify_crate_response(
1809 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1810 "url",
1811 "boom".to_string(),
1812 )
1813 .unwrap_err();
1814 assert!(err.to_string().contains("500"));
1815 }
1816
1817 #[test]
1818 fn snapshot_manifest_skips_crates_without_research_build() {
1819 let manifest = snapshot_manifest(
1822 "1.2.0-research.4",
1823 ToolchainChannel::Rnd,
1824 &ResearchSkipResolver,
1825 None,
1826 )
1827 .unwrap();
1828 assert!(
1829 !manifest.packages.is_empty(),
1830 "research-built tools must remain in the manifest"
1831 );
1832 assert!(
1833 manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1834 "at least one tool without a research build must be skipped"
1835 );
1836 assert!(
1837 manifest
1838 .packages
1839 .iter()
1840 .all(|package| !package.crate_name.contains("operator")),
1841 "the absent `operator` crate must be omitted"
1842 );
1843 assert!(
1844 manifest
1845 .packages
1846 .iter()
1847 .all(|package| package.version == "1.2.0-research.4"),
1848 "remaining research tools pin their resolved -research version"
1849 );
1850 }
1851
1852 #[test]
1853 fn parses_crates_io_falls_back_to_newest_version() {
1854 let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1855 let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1856 assert_eq!(version, "0.6.7");
1857 }
1858
1859 #[test]
1860 fn parses_crates_io_falls_back_to_max_version() {
1861 let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1862 let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1863 assert_eq!(version, "0.4.99");
1864 }
1865
1866 #[test]
1867 fn rejects_crates_io_payload_without_versions() {
1868 let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1869 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1870 assert!(
1871 err.to_string()
1872 .contains("does not expose max_stable_version")
1873 );
1874 }
1875
1876 #[test]
1877 fn rejects_crates_io_payload_with_unparseable_version() {
1878 let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1879 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1880 assert!(err.to_string().contains("unparseable version"));
1881 }
1882
1883 #[test]
1884 fn rejects_crates_io_payload_without_crate_object() {
1885 let body = r#"{"errors":[{"detail":"not found"}]}"#;
1886 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1887 assert!(
1888 err.to_string()
1889 .contains("missing the top-level `crate` object")
1890 );
1891 }
1892
1893 #[test]
1894 fn selects_latest_semver_tag() {
1895 let tags = vec![
1896 "latest".to_string(),
1897 "0.4.93".to_string(),
1898 "0.4.9".to_string(),
1899 "1.0.0-beta.1".to_string(),
1900 "1.0.0".to_string(),
1901 ];
1902
1903 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1904 }
1905
1906 #[test]
1907 fn selects_latest_tag_when_no_semver_tags_exist() {
1908 let tags = vec!["latest".to_string()];
1909
1910 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1911 }
1912
1913 #[test]
1914 fn generates_manifest_from_catalogue() {
1915 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1916 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1917 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1918 assert_eq!(manifest.version, "1.0.5");
1919 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1920 assert!(
1921 manifest
1922 .packages
1923 .iter()
1924 .any(|package| package.crate_name == "greentic-bundle"
1925 && package.bins == ["greentic-bundle"])
1926 );
1927 assert!(
1928 manifest
1929 .packages
1930 .iter()
1931 .any(|package| package.crate_name == "greentic-runner"
1932 && package.bins == ["greentic-runner"])
1933 );
1934 assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1935 assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1936 assert!(
1937 manifest
1938 .extension_packs
1939 .as_ref()
1940 .unwrap()
1941 .iter()
1942 .all(|item| item.version == "1.0.5")
1943 );
1944 assert!(
1945 manifest
1946 .components
1947 .as_ref()
1948 .unwrap()
1949 .iter()
1950 .all(|item| item.version == "1.0.5")
1951 );
1952 }
1953
1954 #[test]
1955 fn generated_manifest_can_use_artifact_resolver_versions() {
1956 let manifest = generate_manifest_with_artifact_resolver(
1957 "1.0.17",
1958 "stable",
1959 None,
1960 &FixedResolver,
1961 &FixedArtifactResolver,
1962 None,
1963 )
1964 .unwrap();
1965
1966 assert!(
1967 manifest
1968 .extension_packs
1969 .as_ref()
1970 .unwrap()
1971 .iter()
1972 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1973 && item.version == "0.4.93")
1974 );
1975 assert!(
1976 manifest
1977 .components
1978 .as_ref()
1979 .unwrap()
1980 .iter()
1981 .any(|item| item.id == "components/component-adaptive-card"
1982 && item.version == "0.5.8")
1983 );
1984 }
1985
1986 #[test]
1987 fn source_manifest_can_pin_package_versions() {
1988 let source = ToolchainManifest {
1989 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1990 toolchain: TOOLCHAIN_NAME.to_string(),
1991 version: "latest".to_string(),
1992 channel: Some("latest".to_string()),
1993 created_at: None,
1994 packages: vec![ToolchainPackage {
1995 crate_name: "greentic-dev".to_string(),
1996 bins: vec!["greentic-dev".to_string()],
1997 version: "0.5.9".to_string(),
1998 }],
1999 extension_packs: None,
2000 components: None,
2001 };
2002 let manifest =
2003 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
2004 let greentic_dev = manifest
2005 .packages
2006 .iter()
2007 .find(|package| package.crate_name == "greentic-dev")
2008 .unwrap();
2009 assert_eq!(greentic_dev.version, "0.5.9");
2010 }
2011
2012 #[test]
2013 fn from_argument_controls_generated_channel_over_source_manifest() {
2014 let source = ToolchainManifest {
2015 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2016 toolchain: TOOLCHAIN_NAME.to_string(),
2017 version: "latest".to_string(),
2018 channel: Some("stable".to_string()),
2019 created_at: None,
2020 packages: Vec::new(),
2021 extension_packs: None,
2022 components: None,
2023 };
2024 let manifest =
2025 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
2026 assert_eq!(manifest.channel.as_deref(), Some("dev"));
2027 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
2028 }
2029
2030 #[test]
2031 fn generate_from_dev_uses_dev_crate_and_binary_names() {
2032 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
2033 assert!(
2034 manifest
2035 .packages
2036 .iter()
2037 .flat_map(|package| package.bins.iter())
2038 .all(|bin| bin.ends_with("-dev"))
2039 );
2040 assert!(
2041 manifest
2042 .packages
2043 .iter()
2044 .all(|package| package.crate_name.ends_with("-dev")),
2045 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
2046 );
2047 assert!(manifest.packages.iter().any(|package| {
2048 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
2049 }));
2050 assert!(manifest.packages.iter().any(|package| {
2051 package.crate_name == "greentic-component-dev"
2052 && package.bins == ["greentic-component-dev"]
2053 }));
2054 assert!(manifest.packages.iter().any(|package| {
2055 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2056 }));
2057 }
2058
2059 #[test]
2060 fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
2061 let manifest =
2062 snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
2063 .unwrap();
2064 assert_eq!(manifest.version, "1.1.5");
2065 assert_eq!(manifest.channel.as_deref(), Some("dev"));
2066 for package in &manifest.packages {
2067 assert!(
2068 package.crate_name.ends_with("-dev"),
2069 "dev snapshot must pin -dev crate names; got {}",
2070 package.crate_name
2071 );
2072 assert!(
2073 package.bins.iter().all(|bin| bin.ends_with("-dev")),
2074 "dev snapshot must pin -dev bin names; got {:?}",
2075 package.bins
2076 );
2077 assert_ne!(
2078 package.version, "latest",
2079 "snapshot must always resolve concrete versions"
2080 );
2081 }
2082 assert!(
2083 manifest
2084 .packages
2085 .iter()
2086 .any(|package| package.crate_name == "greentic-operator-dev")
2087 );
2088 }
2089
2090 #[test]
2091 fn snapshot_manifest_stable_channel_keeps_plain_names() {
2092 let manifest =
2093 snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
2094 assert_eq!(manifest.channel.as_deref(), Some("stable"));
2095 let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
2099 .iter()
2100 .map(|spec| spec.crate_name)
2101 .collect();
2102 for package in &manifest.packages {
2103 assert!(
2104 catalogue_names.contains(package.crate_name.as_str()),
2105 "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
2106 package.crate_name
2107 );
2108 }
2109 }
2110
2111 #[test]
2112 fn snapshot_manifest_resolves_via_resolver() {
2113 let manifest =
2114 snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
2115 .unwrap();
2116 for package in &manifest.packages {
2122 assert_eq!(
2123 package.version, "1.2.3",
2124 "resolver must be hit for {}",
2125 package.crate_name
2126 );
2127 }
2128 }
2129
2130 #[test]
2131 fn parses_dev_channel_argument() {
2132 assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
2133 assert_eq!(
2134 parse_channel("development").unwrap(),
2135 ToolchainChannel::Development
2136 );
2137 assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
2138 assert!(parse_channel("rc").is_err());
2139 }
2140
2141 #[test]
2142 fn detects_concrete_pins_for_publish_deprecation_warning() {
2143 let with_pins = ToolchainManifest {
2144 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2145 toolchain: TOOLCHAIN_NAME.to_string(),
2146 version: "0.0.1".to_string(),
2147 channel: Some("dev".to_string()),
2148 created_at: None,
2149 packages: vec![ToolchainPackage {
2150 crate_name: "greentic-operator-dev".to_string(),
2151 bins: vec!["greentic-operator-dev".to_string()],
2152 version: "0.5.123".to_string(),
2153 }],
2154 extension_packs: None,
2155 components: None,
2156 };
2157 assert!(source_manifest_has_concrete_pins(&with_pins));
2158
2159 let only_latest = ToolchainManifest {
2160 packages: vec![ToolchainPackage {
2161 crate_name: "greentic-operator".to_string(),
2162 bins: vec!["greentic-operator".to_string()],
2163 version: "latest".to_string(),
2164 }],
2165 ..with_pins
2166 };
2167 assert!(!source_manifest_has_concrete_pins(&only_latest));
2168 }
2169
2170 #[test]
2171 fn generate_from_rnd_uses_rnd_binary_names() {
2172 let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
2173 assert_eq!(manifest.channel.as_deref(), Some("rnd"));
2174 assert!(
2175 manifest
2176 .packages
2177 .iter()
2178 .flat_map(|package| package.bins.iter())
2179 .all(|bin| bin.ends_with("-rnd"))
2180 );
2181 assert!(manifest.packages.iter().any(|package| {
2182 package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
2183 }));
2184 }
2185
2186 #[test]
2187 fn bootstrap_source_manifest_uses_source_tag_identity() {
2188 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
2189 assert_eq!(manifest.version, "latest");
2190 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2191 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2192 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2193 assert!(
2194 manifest
2195 .packages
2196 .iter()
2197 .all(|package| package.version != "latest")
2198 );
2199 }
2200
2201 #[test]
2202 fn validates_schema_and_toolchain() {
2203 let mut manifest =
2204 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2205 assert!(validate_manifest(&manifest).is_ok());
2206 manifest.schema = "wrong".to_string();
2207 assert!(validate_manifest(&manifest).is_err());
2208 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
2209 manifest.toolchain = "other".to_string();
2210 assert!(validate_manifest(&manifest).is_err());
2211 }
2212
2213 #[test]
2214 fn resolves_inline_registry_token() {
2215 assert_eq!(
2216 resolve_registry_token(Some("secret-token"))
2217 .unwrap()
2218 .as_deref(),
2219 Some("secret-token")
2220 );
2221 }
2222
2223 #[test]
2224 fn resolves_registry_token_from_environment_reference() {
2225 let _guard = ENV_LOCK.lock().unwrap();
2226 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2227 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
2228
2229 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
2230 assert_eq!(resolved.as_deref(), Some("env-secret"));
2231
2232 match previous {
2233 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2234 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2235 }
2236 }
2237
2238 #[test]
2239 fn rejects_empty_registry_token_from_environment_reference() {
2240 let _guard = ENV_LOCK.lock().unwrap();
2241 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2242 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
2243
2244 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
2245 assert!(err.to_string().contains("resolved to an empty token"));
2246
2247 match previous {
2248 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2249 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2250 }
2251 }
2252
2253 #[test]
2254 fn registry_auth_uses_environment_fallbacks() {
2255 let _guard = ENV_LOCK.lock().unwrap();
2256 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2257 let previous_github = std::env::var("GITHUB_TOKEN").ok();
2258 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
2259 unsafe { std::env::remove_var("GITHUB_TOKEN") };
2260
2261 let auth = registry_auth(None).unwrap();
2262 match auth {
2263 RegistryAuth::Basic(user, token) => {
2264 assert_eq!(user, DEFAULT_OAUTH_USER);
2265 assert_eq!(token, "ghcr-secret");
2266 }
2267 _ => panic!("expected basic auth"),
2268 }
2269
2270 match previous_ghcr {
2271 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2272 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2273 }
2274 match previous_github {
2275 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2276 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2277 }
2278 }
2279
2280 #[test]
2281 fn optional_registry_auth_allows_missing_implicit_token() {
2282 let _guard = ENV_LOCK.lock().unwrap();
2283 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2284 let previous_github = std::env::var("GITHUB_TOKEN").ok();
2285 unsafe { std::env::remove_var("GHCR_TOKEN") };
2286 unsafe { std::env::remove_var("GITHUB_TOKEN") };
2287
2288 let auth = optional_registry_auth(None).unwrap();
2289 assert!(matches!(auth, RegistryAuth::Anonymous));
2290
2291 match previous_ghcr {
2292 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2293 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2294 }
2295 match previous_github {
2296 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2297 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2298 }
2299 }
2300
2301 #[test]
2302 fn release_view_tag_prefers_release_or_tag() {
2303 let args = ReleaseViewArgs {
2304 release: Some("1.0.5".to_string()),
2305 tag: None,
2306 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2307 token: None,
2308 };
2309 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
2310
2311 let args = ReleaseViewArgs {
2312 release: None,
2313 tag: Some("stable".to_string()),
2314 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2315 token: None,
2316 };
2317 assert_eq!(release_view_tag(&args).unwrap(), "stable");
2318 }
2319
2320 #[test]
2321 fn release_view_tag_rejects_invalid_argument_combinations() {
2322 let err = release_view_tag(&ReleaseViewArgs {
2323 release: Some("1.0.5".to_string()),
2324 tag: Some("stable".to_string()),
2325 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2326 token: None,
2327 })
2328 .unwrap_err();
2329 assert!(
2330 err.to_string()
2331 .contains("pass exactly one of --release or --tag")
2332 );
2333
2334 let err = release_view_tag(&ReleaseViewArgs {
2335 release: None,
2336 tag: None,
2337 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2338 token: None,
2339 })
2340 .unwrap_err();
2341 assert!(
2342 err.to_string()
2343 .contains("pass exactly one of --release or --tag")
2344 );
2345 }
2346
2347 #[test]
2348 fn publish_manifest_input_uses_local_manifest_version() {
2349 let dir = tempfile::tempdir().unwrap();
2350 let path = dir.path().join("gtc-1.0.12.json");
2351 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2352 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2353 let args = ReleasePublishArgs {
2354 release: None,
2355 from: None,
2356 tag: Some("stable".to_string()),
2357 manifest: Some(path.clone()),
2358 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2359 token: None,
2360 out: dir.path().to_path_buf(),
2361 dry_run: true,
2362 force: true,
2363 no_notify_updater: true,
2364 };
2365 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2366 assert_eq!(release, "1.0.12");
2367 assert_eq!(loaded, manifest);
2368 assert_eq!(
2369 source_path,
2370 Some(PublishManifestSource::Local(path.clone()))
2371 );
2372 }
2373
2374 #[test]
2375 fn publish_manifest_input_allows_release_override_for_local_manifest() {
2376 let dir = tempfile::tempdir().unwrap();
2377 let path = dir.path().join("gtc-1.0.13.json");
2378 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2379 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2380 let args = ReleasePublishArgs {
2381 release: Some("1.0.13".to_string()),
2382 from: None,
2383 tag: Some("stable".to_string()),
2384 manifest: Some(path.clone()),
2385 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2386 token: None,
2387 out: dir.path().to_path_buf(),
2388 dry_run: true,
2389 force: true,
2390 no_notify_updater: true,
2391 };
2392 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2393 assert_eq!(release, "1.0.13");
2394 assert_eq!(loaded.version, "1.0.13");
2395 assert_eq!(
2396 source_path,
2397 Some(PublishManifestSource::Local(path.clone()))
2398 );
2399 }
2400
2401 #[test]
2402 fn manifest_file_name_omits_stable_channel() {
2403 let manifest = ToolchainManifest {
2404 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2405 toolchain: TOOLCHAIN_NAME.to_string(),
2406 version: "1.0.12".to_string(),
2407 channel: Some("stable".to_string()),
2408 created_at: None,
2409 packages: Vec::new(),
2410 extension_packs: None,
2411 components: None,
2412 };
2413 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2414 }
2415
2416 #[test]
2417 fn parses_manifest_without_extension_sections() {
2418 let manifest: ToolchainManifest = serde_json::from_str(
2419 r#"{
2420 "schema": "greentic.toolchain-manifest.v1",
2421 "toolchain": "gtc",
2422 "version": "1.0.16",
2423 "channel": "stable",
2424 "packages": []
2425 }"#,
2426 )
2427 .unwrap();
2428
2429 assert_eq!(manifest.extension_packs, None);
2430 assert_eq!(manifest.components, None);
2431 }
2432
2433 #[test]
2434 fn generated_manifest_includes_catalogue_extension_sections() {
2435 let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2436 let json = serde_json::to_value(&manifest).unwrap();
2437
2438 assert!(json.get("extension_packs").is_some());
2439 assert!(json.get("components").is_some());
2440 assert!(
2441 manifest
2442 .extension_packs
2443 .as_ref()
2444 .unwrap()
2445 .iter()
2446 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2447 && item.version == "1.0.16")
2448 );
2449 assert!(
2450 manifest
2451 .extension_packs
2452 .as_ref()
2453 .unwrap()
2454 .iter()
2455 .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2456 && item.version == "1.0.16")
2457 );
2458 assert!(
2459 manifest
2460 .components
2461 .as_ref()
2462 .unwrap()
2463 .iter()
2464 .any(|item| item.id == "component/component-llm-openai"
2465 && item.version == "1.0.16")
2466 );
2467 }
2468
2469 #[test]
2470 fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2471 let source = ToolchainManifest {
2472 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2473 toolchain: TOOLCHAIN_NAME.to_string(),
2474 version: "dev".to_string(),
2475 channel: Some("dev".to_string()),
2476 created_at: None,
2477 packages: Vec::new(),
2478 extension_packs: Some(vec![ExtensionPackRef {
2479 id: "packs/messaging/messaging-webchat-gui".to_string(),
2480 version: "0.5.4".to_string(),
2481 }]),
2482 components: Some(vec![ComponentRef {
2483 id: "components/component-adaptive-card".to_string(),
2484 version: "0.5.8".to_string(),
2485 }]),
2486 };
2487
2488 let manifest =
2489 generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2490
2491 assert!(
2492 manifest
2493 .extension_packs
2494 .as_ref()
2495 .unwrap()
2496 .iter()
2497 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2498 && item.version == "0.5.4")
2499 );
2500 assert!(
2501 manifest
2502 .components
2503 .as_ref()
2504 .unwrap()
2505 .iter()
2506 .any(|item| item.id == "components/component-adaptive-card"
2507 && item.version == "0.5.8")
2508 );
2509 }
2510
2511 #[test]
2512 fn manifest_file_name_includes_non_stable_channel() {
2513 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2514 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2515
2516 manifest.channel = Some("customer-a".to_string());
2517 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2518 }
2519
2520 #[test]
2521 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2522 assert_eq!(
2523 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2524 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2525 );
2526 assert_eq!(
2527 manifest_bins_for_source("dev", &["greentic-dev"]),
2528 vec!["greentic-dev-dev".to_string()]
2529 );
2530 assert_eq!(
2531 manifest_crate_name_for_source("latest", "greentic-runner"),
2532 "greentic-runner"
2533 );
2534 assert_eq!(
2535 manifest_crate_name_for_source("dev", "greentic-runner"),
2536 "greentic-runner-dev"
2537 );
2538 }
2539
2540 #[test]
2541 fn source_version_map_handles_missing_and_present_sources() {
2542 assert!(source_version_map(None).is_empty());
2543
2544 let source = ToolchainManifest {
2545 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2546 toolchain: TOOLCHAIN_NAME.to_string(),
2547 version: "latest".to_string(),
2548 channel: Some("latest".to_string()),
2549 created_at: None,
2550 packages: vec![ToolchainPackage {
2551 crate_name: "greentic-dev".to_string(),
2552 bins: vec!["greentic-dev".to_string()],
2553 version: "0.6.0".to_string(),
2554 }],
2555 extension_packs: None,
2556 components: None,
2557 };
2558
2559 let versions = source_version_map(Some(&source));
2560 assert_eq!(
2561 versions.get("greentic-dev").map(String::as_str),
2562 Some("0.6.0")
2563 );
2564 }
2565
2566 #[test]
2567 fn write_manifest_persists_json_to_expected_file_name() {
2568 let dir = tempfile::tempdir().unwrap();
2569 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2570
2571 let path = write_manifest(dir.path(), &manifest).unwrap();
2572 assert_eq!(
2573 path.file_name().and_then(|name| name.to_str()),
2574 Some("gtc-dev-1.0.12.json")
2575 );
2576
2577 let roundtrip = read_manifest_file(&path).unwrap();
2578 assert_eq!(roundtrip, manifest);
2579 }
2580
2581 #[test]
2582 fn latest_manifest_uses_latest_dev_bins() {
2583 let manifest = latest_manifest(None);
2584 assert_eq!(manifest.version, "latest");
2585 assert_eq!(manifest.channel.as_deref(), Some("latest"));
2586 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2587 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2588 assert!(!manifest.packages.is_empty());
2589 assert!(
2590 manifest
2591 .packages
2592 .iter()
2593 .all(|package| package.version == "latest")
2594 );
2595 assert!(
2596 manifest
2597 .packages
2598 .iter()
2599 .flat_map(|package| package.bins.iter())
2600 .all(|bin| bin.ends_with("-dev"))
2601 );
2602 assert!(
2603 manifest
2604 .packages
2605 .iter()
2606 .all(|package| package.crate_name.ends_with("-dev")),
2607 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2608 );
2609 assert!(
2610 manifest
2611 .packages
2612 .iter()
2613 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2614 );
2615 assert!(manifest.packages.iter().any(|package| {
2616 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2617 }));
2618 assert!(
2619 manifest
2620 .extension_packs
2621 .as_ref()
2622 .unwrap()
2623 .iter()
2624 .all(|item| item.version == "latest")
2625 );
2626 assert!(
2627 manifest
2628 .components
2629 .as_ref()
2630 .unwrap()
2631 .iter()
2632 .all(|item| item.version == "latest")
2633 );
2634 }
2635
2636 #[test]
2637 fn publish_dry_run_with_local_manifest_succeeds() {
2638 let dir = tempfile::tempdir().unwrap();
2639 let path = dir.path().join("gtc-1.0.12.json");
2640 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2641 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2642
2643 let checker = StubReleaseChecker::complete_for(&manifest);
2646 publish_with_checker(
2647 ReleasePublishArgs {
2648 release: None,
2649 from: None,
2650 tag: Some("stable".to_string()),
2651 manifest: Some(path),
2652 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2653 token: None,
2654 out: dir.path().to_path_buf(),
2655 dry_run: true,
2656 force: false,
2657 no_notify_updater: true,
2658 },
2659 &checker,
2660 )
2661 .unwrap();
2662 }
2663
2664 struct StubReleaseChecker(BTreeMap<String, Vec<String>>);
2671
2672 impl ReleaseAssetChecker for StubReleaseChecker {
2673 fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
2674 Ok(self.0.get(&format!("{repo}@{tag}")).cloned())
2675 }
2676 }
2677
2678 impl StubReleaseChecker {
2679 fn complete_for(manifest: &ToolchainManifest) -> Self {
2681 Self(
2682 manifest
2683 .packages
2684 .iter()
2685 .map(|package| {
2686 (
2687 format!("{}@v{}", package.crate_name, package.version),
2688 complete_assets(&package.crate_name, &package.version),
2689 )
2690 })
2691 .collect(),
2692 )
2693 }
2694
2695 fn with(entries: &[(&str, Vec<String>)]) -> Self {
2696 Self(
2697 entries
2698 .iter()
2699 .map(|(key, assets)| ((*key).to_string(), assets.clone()))
2700 .collect(),
2701 )
2702 }
2703 }
2704
2705 fn complete_assets(crate_name: &str, version: &str) -> Vec<String> {
2707 let archive = format!("{crate_name}-v{version}-x86_64-unknown-linux-gnu.tgz");
2708 vec![format!("{archive}.sha256"), archive]
2709 }
2710
2711 fn manifest_with_channel(
2712 channel: Option<&str>,
2713 packages: &[(&str, &str)],
2714 ) -> ToolchainManifest {
2715 ToolchainManifest {
2716 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2717 toolchain: TOOLCHAIN_NAME.to_string(),
2718 version: "1.1.13".to_string(),
2719 channel: channel.map(str::to_string),
2720 created_at: None,
2721 packages: packages
2722 .iter()
2723 .map(|(crate_name, version)| ToolchainPackage {
2724 crate_name: (*crate_name).to_string(),
2725 bins: vec![(*crate_name).to_string()],
2726 version: (*version).to_string(),
2727 })
2728 .collect(),
2729 extension_packs: None,
2730 components: None,
2731 }
2732 }
2733
2734 #[test]
2735 fn release_gate_skips_dev_channel() {
2736 let manifest = manifest_with_channel(Some("dev"), &[("greentic-setup", "1.2.30516109579")]);
2737 verify_manifest_releases(&manifest, Some("dev"), &StubReleaseChecker::with(&[])).unwrap();
2739 }
2740
2741 #[test]
2742 fn release_gate_skips_manifest_without_channel_or_stable_tag() {
2743 let manifest = manifest_with_channel(None, &[("greentic-setup", "1.1.31")]);
2744 verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[])).unwrap();
2745 }
2746
2747 #[test]
2748 fn release_gate_accepts_complete_stable_releases() {
2749 let manifest = manifest_with_channel(
2750 Some("stable"),
2751 &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2752 );
2753 let checker = StubReleaseChecker::complete_for(&manifest);
2754 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2755 }
2756
2757 #[test]
2761 fn release_gate_rejects_pin_whose_release_does_not_exist() {
2762 let manifest = manifest_with_channel(
2763 Some("stable"),
2764 &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2765 );
2766 let checker = StubReleaseChecker::with(&[(
2767 "greentic-start@v1.1.38",
2768 complete_assets("greentic-start", "1.1.38"),
2769 )]);
2770 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2771 .unwrap_err()
2772 .to_string();
2773 assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2774 assert!(error.contains("no GitHub release"), "{error}");
2775 assert!(!error.contains("greentic-start"), "{error}");
2776 }
2777
2778 #[test]
2781 fn release_gate_fires_on_stable_tag_despite_latest_channel() {
2782 let manifest = manifest_with_channel(Some("latest"), &[("greentic-setup", "1.1.31")]);
2783 let error =
2784 verify_manifest_releases(&manifest, Some("stable"), &StubReleaseChecker::with(&[]))
2785 .unwrap_err()
2786 .to_string();
2787 assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2788 }
2789
2790 #[test]
2791 fn release_gate_rejects_release_with_no_archives_yet() {
2792 let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2793 let checker = StubReleaseChecker::with(&[("greentic-setup@v1.1.31", Vec::new())]);
2794 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2795 .unwrap_err()
2796 .to_string();
2797 assert!(error.contains("no v1.1.31 archives yet"), "{error}");
2798 }
2799
2800 #[test]
2801 fn release_gate_rejects_archive_missing_its_checksum() {
2802 let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2803 let checker = StubReleaseChecker::with(&[(
2804 "greentic-setup@v1.1.31",
2805 vec![
2806 "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz".to_string(),
2807 "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2808 "greentic-setup-v1.1.31-aarch64-apple-darwin.tgz".to_string(),
2809 ],
2810 )]);
2811 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2812 .unwrap_err()
2813 .to_string();
2814 assert!(error.contains("missing .sha256"), "{error}");
2815 assert!(error.contains("aarch64-apple-darwin"), "{error}");
2816 }
2817
2818 #[test]
2821 fn release_gate_accepts_multi_binary_release() {
2822 let manifest = manifest_with_channel(Some("stable"), &[("greentic-mcp", "1.1.1")]);
2823 let checker = StubReleaseChecker::with(&[(
2824 "greentic-mcp@v1.1.1",
2825 vec![
2826 "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz".to_string(),
2827 "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2828 "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip".to_string(),
2829 "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip.sha256".to_string(),
2830 ],
2831 )]);
2832 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2833 }
2834
2835 #[test]
2840 fn release_gate_fires_on_stable_channel_without_a_target_tag() {
2841 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2842 let error = verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[]))
2843 .unwrap_err()
2844 .to_string();
2845 assert!(error.contains("greentic-pack v1.1.5"), "{error}");
2846 assert!(error.contains("no GitHub release"), "{error}");
2847 }
2848
2849 #[test]
2853 fn release_gate_ignores_unversioned_binstall_aliases() {
2854 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2855 let checker = StubReleaseChecker::with(&[(
2856 "greentic-pack@v1.1.5",
2857 vec![
2858 "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz".to_string(),
2859 "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2860 "greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string(),
2862 ],
2863 )]);
2864 verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2865 }
2866
2867 #[test]
2870 fn release_gate_rejects_release_with_only_unversioned_aliases() {
2871 let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2872 let checker = StubReleaseChecker::with(&[(
2873 "greentic-pack@v1.1.5",
2874 vec!["greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string()],
2875 )]);
2876 let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2877 .unwrap_err()
2878 .to_string();
2879 assert!(error.contains("no v1.1.5 archives yet"), "{error}");
2880 }
2881
2882 #[test]
2883 fn release_response_404_means_absent() {
2884 assert_eq!(
2885 classify_release_response(reqwest::StatusCode::NOT_FOUND, "url", "{}".to_string())
2886 .unwrap(),
2887 None
2888 );
2889 }
2890
2891 #[test]
2892 fn release_response_success_returns_body() {
2893 assert_eq!(
2894 classify_release_response(reqwest::StatusCode::OK, "url", "{}".to_string()).unwrap(),
2895 Some("{}".to_string())
2896 );
2897 }
2898
2899 #[test]
2900 fn release_response_server_error_is_fatal() {
2901 assert!(
2904 classify_release_response(
2905 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2906 "url",
2907 "boom".to_string()
2908 )
2909 .is_err()
2910 );
2911 }
2912
2913 #[test]
2914 fn latest_dry_run_succeeds() {
2915 latest(ReleaseLatestArgs {
2916 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2917 token: None,
2918 dry_run: true,
2919 force: false,
2920 })
2921 .unwrap();
2922 }
2923
2924 #[test]
2925 fn promote_dry_run_succeeds() {
2926 promote(ReleasePromoteArgs {
2927 release: "1.0.12".to_string(),
2928 tag: "stable".to_string(),
2929 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2930 token: None,
2931 dry_run: true,
2932 no_notify_updater: true,
2933 })
2934 .unwrap();
2935 }
2936
2937 #[test]
2938 fn builds_toolchain_ref() {
2939 assert_eq!(
2940 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
2941 "ghcr.io/greenticai/greentic-versions/gtc:stable"
2942 );
2943 }
2944
2945 #[test]
2950 fn notify_gate_accepts_stable_plain_version() {
2951 assert!(should_notify_updater("1.1.2", "stable"));
2952 }
2953
2954 #[test]
2955 fn notify_gate_accepts_stable_zero_version() {
2956 assert!(should_notify_updater("0.1.0", "stable"));
2957 }
2958
2959 #[test]
2960 fn notify_gate_rejects_dev_channel() {
2961 assert!(!should_notify_updater("1.1.2", "dev"));
2962 }
2963
2964 #[test]
2965 fn notify_gate_rejects_rnd_channel() {
2966 assert!(!should_notify_updater("1.1.2", "rnd"));
2967 }
2968
2969 #[test]
2970 fn notify_gate_rejects_prerelease_version() {
2971 assert!(!should_notify_updater("1.2.0-dev.3", "stable"));
2972 }
2973
2974 #[test]
2975 fn notify_gate_rejects_run_id_version_on_dev_channel() {
2976 assert!(!should_notify_updater("1.1.14995680637", "dev"));
2979 }
2980
2981 #[test]
2982 fn notify_gate_rejects_research_prerelease() {
2983 assert!(!should_notify_updater("1.3.0-research.1", "stable"));
2984 }
2985
2986 #[test]
2987 fn notify_gate_rejects_build_metadata() {
2988 assert!(!should_notify_updater("1.1.2+build.42", "stable"));
2989 }
2990
2991 #[test]
2992 fn notify_gate_rejects_empty_channel() {
2993 assert!(!should_notify_updater("1.1.2", ""));
2994 }
2995
2996 #[test]
2997 fn notify_gate_rejects_unparseable_version() {
2998 assert!(!should_notify_updater("not-a-version", "stable"));
2999 }
3000}