1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6
7use anyhow::{Context, Result, anyhow, bail};
8use async_trait::async_trait;
9use oci_distribution::Reference;
10use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
11use oci_distribution::secrets::RegistryAuth;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16
17use crate::cli::{
18 ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
22use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
23
24const DEFAULT_OAUTH_USER: &str = "oauth2";
25pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
26pub const TOOLCHAIN_NAME: &str = "gtc";
27pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
28const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct ToolchainManifest {
32 pub schema: String,
33 pub toolchain: String,
34 pub version: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub channel: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub created_at: Option<String>,
39 pub packages: Vec<ToolchainPackage>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct ToolchainPackage {
44 #[serde(rename = "crate")]
45 pub crate_name: String,
46 pub bins: Vec<String>,
47 pub version: String,
48}
49
50pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
51 let resolver = CargoSearchVersionResolver;
52 let source = block_on_maybe_runtime(load_source_manifest(
53 &args.repo,
54 &args.from,
55 args.token.as_deref(),
56 ))
57 .with_context(|| {
58 format!(
59 "failed to resolve source manifest `{}`",
60 toolchain_ref(&args.repo, &args.from)
61 )
62 })?;
63 let source = match source {
64 Some(source) => Some(source),
65 None => bootstrap_source_manifest_if_needed(
66 &args.repo,
67 &args.from,
68 args.token.as_deref(),
69 args.dry_run,
70 &resolver,
71 )?,
72 };
73 let manifest = generate_manifest(
74 &args.release,
75 &args.from,
76 source.as_ref(),
77 &resolver,
78 Some(created_at_now()?),
79 )?;
80 if args.dry_run {
81 println!("{}", serde_json::to_string_pretty(&manifest)?);
82 return Ok(());
83 }
84 let path = write_manifest(&args.out, &manifest)?;
85 println!("Wrote {}", path.display());
86 Ok(())
87}
88
89fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
90 repo: &str,
91 tag: &str,
92 token: Option<&str>,
93 dry_run: bool,
94 resolver: &R,
95) -> Result<Option<ToolchainManifest>> {
96 let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
97 if dry_run {
98 eprintln!(
99 "Dry run: would bootstrap missing source manifest {}",
100 toolchain_ref(repo, tag)
101 );
102 return Ok(Some(manifest));
103 }
104
105 let auth = match optional_registry_auth(token)? {
106 RegistryAuth::Anonymous => {
107 eprintln!(
108 "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
109 toolchain_ref(repo, tag)
110 );
111 return Ok(Some(manifest));
112 }
113 auth => auth,
114 };
115 block_on_maybe_runtime(async {
116 let client = oci_client();
117 let source_ref = parse_reference(repo, tag)?;
118 push_manifest_layer(&client, &source_ref, &auth, &manifest).await
119 })
120 .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
121 println!("Bootstrapped {}", toolchain_ref(repo, tag));
122 Ok(Some(manifest))
123}
124
125fn bootstrap_source_manifest<R: CrateVersionResolver>(
126 tag: &str,
127 resolver: &R,
128 created_at: Option<String>,
129) -> Result<ToolchainManifest> {
130 generate_manifest(tag, tag, None, resolver, created_at)
131}
132
133pub fn publish(args: ReleasePublishArgs) -> Result<()> {
134 let (release, manifest, source) = publish_manifest_input(&args)?;
135
136 if args.dry_run {
137 println!(
138 "Dry run: would publish {}",
139 toolchain_ref(&args.repo, &release)
140 );
141 if let Some(tag) = &args.tag {
142 println!(
143 "Dry run: would tag {} as {}",
144 toolchain_ref(&args.repo, &release),
145 toolchain_ref(&args.repo, tag)
146 );
147 }
148 return Ok(());
149 }
150
151 let auth = registry_auth(args.token.as_deref())?;
152 block_on_maybe_runtime(async {
153 let client = oci_client();
154 let release_ref = parse_reference(&args.repo, &release)?;
155 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
156 bail!(
157 "release tag `{}` already exists; pass --force to overwrite it",
158 toolchain_ref(&args.repo, &release)
159 );
160 }
161 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
162 if let Some(tag) = &args.tag {
163 let tag_ref = parse_reference(&args.repo, tag)?;
164 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
165 }
166 Ok(())
167 })?;
168
169 if let Some(source) = source {
170 match source {
171 PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
172 PublishManifestSource::Local(path) => println!("Read {}", path.display()),
173 }
174 }
175 println!("Published {}", toolchain_ref(&args.repo, &release));
176 if let Some(tag) = &args.tag {
177 println!("Updated {}", toolchain_ref(&args.repo, tag));
178 }
179 Ok(())
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183enum PublishManifestSource {
184 Generated(PathBuf),
185 Local(PathBuf),
186}
187
188fn publish_manifest_input(
189 args: &ReleasePublishArgs,
190) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
191 if let Some(path) = &args.manifest {
192 let mut manifest = read_manifest_file(path)?;
193 validate_manifest(&manifest)?;
194 let release = if let Some(release) = &args.release {
195 manifest.version = release.clone();
196 release.clone()
197 } else {
198 manifest.version.clone()
199 };
200 return Ok((
201 release,
202 manifest,
203 Some(PublishManifestSource::Local(path.clone())),
204 ));
205 }
206
207 let release = args
208 .release
209 .as_deref()
210 .context("pass --release or --manifest")?;
211 let from = args.from.as_deref().unwrap_or("latest");
212 let resolver = CargoSearchVersionResolver;
213 let source = block_on_maybe_runtime(load_source_manifest(
214 &args.repo,
215 from,
216 args.token.as_deref(),
217 ))
218 .with_context(|| {
219 format!(
220 "failed to resolve source manifest `{}`",
221 toolchain_ref(&args.repo, from)
222 )
223 })?;
224 let manifest = generate_manifest(
225 release,
226 from,
227 source.as_ref(),
228 &resolver,
229 Some(created_at_now()?),
230 )?;
231 let path = if args.dry_run {
232 println!("{}", serde_json::to_string_pretty(&manifest)?);
233 None
234 } else {
235 Some(PublishManifestSource::Generated(write_manifest(
236 &args.out, &manifest,
237 )?))
238 };
239 Ok((release.to_string(), manifest, path))
240}
241
242fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
243 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
244 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
245}
246
247pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
248 if args.dry_run {
249 println!(
250 "Dry run: would promote {} to {}",
251 toolchain_ref(&args.repo, &args.release),
252 toolchain_ref(&args.repo, &args.tag)
253 );
254 return Ok(());
255 }
256
257 let auth = registry_auth(args.token.as_deref())?;
258 block_on_maybe_runtime(async {
259 let client = oci_client();
260 let source_ref = parse_reference(&args.repo, &args.release)?;
261 let target_ref = parse_reference(&args.repo, &args.tag)?;
262 let (manifest, _) = client
263 .pull_manifest(&source_ref, &auth)
264 .await
265 .with_context(|| {
266 format!(
267 "failed to resolve source release `{}`",
268 toolchain_ref(&args.repo, &args.release)
269 )
270 })?;
271 client
272 .push_manifest(&target_ref, &manifest)
273 .await
274 .with_context(|| {
275 format!(
276 "failed to update tag `{}`",
277 toolchain_ref(&args.repo, &args.tag)
278 )
279 })?;
280 Ok(())
281 })?;
282 println!(
283 "Promoted {} to {}",
284 toolchain_ref(&args.repo, &args.release),
285 toolchain_ref(&args.repo, &args.tag)
286 );
287 Ok(())
288}
289
290pub fn view(args: ReleaseViewArgs) -> Result<()> {
291 let tag = release_view_tag(&args)?;
292 let manifest = block_on_maybe_runtime(load_source_manifest(
293 &args.repo,
294 &tag,
295 args.token.as_deref(),
296 ))
297 .with_context(|| {
298 format!(
299 "failed to resolve manifest `{}`",
300 toolchain_ref(&args.repo, &tag)
301 )
302 })?
303 .with_context(|| {
304 format!(
305 "manifest `{}` was not found or is not authorized for this token",
306 toolchain_ref(&args.repo, &tag)
307 )
308 })?;
309 println!("{}", serde_json::to_string_pretty(&manifest)?);
310 Ok(())
311}
312
313pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
314 let manifest = latest_manifest(Some(created_at_now()?));
315 if args.dry_run {
316 println!("{}", serde_json::to_string_pretty(&manifest)?);
317 println!(
318 "Dry run: would publish {}",
319 toolchain_ref(&args.repo, "latest")
320 );
321 return Ok(());
322 }
323
324 let auth = registry_auth(args.token.as_deref())?;
325 block_on_maybe_runtime(async {
326 let client = oci_client();
327 let latest_ref = parse_reference(&args.repo, "latest")?;
328 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
329 bail!(
330 "latest tag `{}` already exists; pass --force to overwrite it",
331 toolchain_ref(&args.repo, "latest")
332 );
333 }
334 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
335 })?;
336 println!("Published {}", toolchain_ref(&args.repo, "latest"));
337 Ok(())
338}
339
340fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
341 ToolchainManifest {
342 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
343 toolchain: TOOLCHAIN_NAME.to_string(),
344 version: "latest".to_string(),
345 channel: Some("latest".to_string()),
346 created_at,
347 packages: latest_manifest_packages(),
348 }
349}
350
351fn latest_manifest_packages() -> Vec<ToolchainPackage> {
352 std::iter::once(ToolchainPackage {
353 crate_name: TOOLCHAIN_NAME.to_string(),
354 bins: vec![delegated_binary_name_for_channel(
355 TOOLCHAIN_NAME,
356 ToolchainChannel::Development,
357 )],
358 version: "latest".to_string(),
359 })
360 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
361 ToolchainPackage {
362 crate_name: package.crate_name.to_string(),
363 bins: package
364 .bins
365 .iter()
366 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
367 .collect(),
368 version: "latest".to_string(),
369 }
370 }))
371 .collect()
372}
373
374fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
375 match (&args.release, &args.tag) {
376 (Some(release), None) => Ok(release.clone()),
377 (None, Some(tag)) => Ok(tag.clone()),
378 _ => bail!("pass exactly one of --release or --tag"),
379 }
380}
381
382pub fn generate_manifest<R: CrateVersionResolver>(
383 release: &str,
384 from: &str,
385 source: Option<&ToolchainManifest>,
386 resolver: &R,
387 created_at: Option<String>,
388) -> Result<ToolchainManifest> {
389 if let Some(source) = source {
390 validate_manifest(source)?;
391 }
392 let source_versions = source_version_map(source);
393 let mut packages = Vec::new();
394 for package in GREENTIC_TOOLCHAIN_PACKAGES {
395 let source_version = source_versions.get(package.crate_name);
396 let version = match source_version.map(String::as_str) {
397 Some(version) if version != "latest" => version.to_string(),
398 _ => resolver.resolve_latest(package.crate_name)?,
399 };
400 packages.push(ToolchainPackage {
401 crate_name: package.crate_name.to_string(),
402 bins: manifest_bins_for_source(from, package.bins),
403 version,
404 });
405 }
406 Ok(ToolchainManifest {
407 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
408 toolchain: TOOLCHAIN_NAME.to_string(),
409 version: release.to_string(),
410 channel: Some(from.to_string()),
411 created_at,
412 packages,
413 })
414}
415
416fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
417 if from == "dev" {
418 bins.iter()
419 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
420 .collect()
421 } else {
422 bins.iter().map(|bin| (*bin).to_string()).collect()
423 }
424}
425
426pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
427 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
428 bail!(
429 "unsupported toolchain manifest schema `{}`",
430 manifest.schema
431 );
432 }
433 if manifest.toolchain != TOOLCHAIN_NAME {
434 bail!("unsupported toolchain `{}`", manifest.toolchain);
435 }
436 Ok(())
437}
438
439pub fn toolchain_ref(repo: &str, tag: &str) -> String {
440 format!("{repo}:{tag}")
441}
442
443fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
444 let mut out = BTreeMap::new();
445 if let Some(source) = source {
446 for package in &source.packages {
447 out.insert(package.crate_name.clone(), package.version.clone());
448 }
449 }
450 out
451}
452
453fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
454 fs::create_dir_all(out_dir)
455 .with_context(|| format!("failed to create {}", out_dir.display()))?;
456 let path = out_dir.join(manifest_file_name(manifest));
457 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
458 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
459 Ok(path)
460}
461
462fn manifest_file_name(manifest: &ToolchainManifest) -> String {
463 match manifest.channel.as_deref() {
464 Some("stable") | None => format!("gtc-{}.json", manifest.version),
465 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
466 }
467}
468
469fn created_at_now() -> Result<String> {
470 OffsetDateTime::now_utc()
471 .format(&Rfc3339)
472 .context("failed to format current time")
473}
474
475pub trait CrateVersionResolver {
476 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
477}
478
479struct CargoSearchVersionResolver;
480
481impl CrateVersionResolver for CargoSearchVersionResolver {
482 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
483 let output = Command::new("cargo")
484 .arg("search")
485 .arg(crate_name)
486 .arg("--limit")
487 .arg("1")
488 .output()
489 .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
490 if !output.status.success() {
491 bail!(
492 "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
493 output.status.code()
494 );
495 }
496 let stdout = String::from_utf8(output.stdout).with_context(|| {
497 format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
498 })?;
499 parse_cargo_search_version(crate_name, &stdout)
500 }
501}
502
503fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
504 let first_line = stdout
505 .lines()
506 .find(|line| !line.trim().is_empty())
507 .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
508 let Some((found_name, rhs)) = first_line.split_once('=') else {
509 bail!("unexpected cargo search output: {first_line}");
510 };
511 if found_name.trim() != crate_name {
512 bail!(
513 "`cargo search {crate_name} --limit 1` returned `{}` first",
514 found_name.trim()
515 );
516 }
517 let quoted = rhs
518 .split('#')
519 .next()
520 .map(str::trim)
521 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
522 let version = quoted.trim_matches('"');
523 Version::parse(version)
524 .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
525 Ok(version.to_string())
526}
527
528#[async_trait]
529trait ToolchainManifestSource {
530 async fn load_manifest(
531 &self,
532 repo: &str,
533 tag: &str,
534 token: Option<&str>,
535 ) -> Result<Option<ToolchainManifest>>;
536}
537
538struct OciToolchainManifestSource;
539
540#[async_trait]
541impl ToolchainManifestSource for OciToolchainManifestSource {
542 async fn load_manifest(
543 &self,
544 repo: &str,
545 tag: &str,
546 token: Option<&str>,
547 ) -> Result<Option<ToolchainManifest>> {
548 let auth = optional_registry_auth(token)?;
549 let client = oci_client();
550 let reference = parse_reference(repo, tag)?;
551 let image = match client
552 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
553 .await
554 {
555 Ok(image) => image,
556 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
557 return Ok(None);
558 }
559 Err(err) => {
560 return Err(err)
561 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
562 }
563 };
564 let Some(layer) = image
565 .layers
566 .into_iter()
567 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
568 else {
569 return Ok(None);
570 };
571 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
572 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
573 validate_manifest(&manifest)?;
574 Ok(Some(manifest))
575 }
576}
577
578async fn load_source_manifest(
579 repo: &str,
580 tag: &str,
581 token: Option<&str>,
582) -> Result<Option<ToolchainManifest>> {
583 OciToolchainManifestSource
584 .load_manifest(repo, tag, token)
585 .await
586}
587
588fn oci_client() -> Client {
589 Client::new(ClientConfig {
590 protocol: ClientProtocol::Https,
591 ..Default::default()
592 })
593}
594
595fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
596 let token = resolve_registry_token(raw_token)?
597 .or_else(|| std::env::var("GHCR_TOKEN").ok())
598 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
599 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
600 if token.trim().is_empty() {
601 bail!("GHCR token is empty");
602 }
603 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
604}
605
606fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
607 match registry_auth(raw_token) {
608 Ok(auth) => Ok(auth),
609 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
610 Err(err) => Err(err),
611 }
612}
613
614fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
615 let Some(raw_token) = raw_token else {
616 return Ok(None);
617 };
618 if let Some(var) = raw_token.strip_prefix("env:") {
619 let token =
620 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
621 if token.trim().is_empty() {
622 bail!("env var {var} resolved to an empty token");
623 }
624 return Ok(Some(token));
625 }
626 if raw_token.trim().is_empty() {
627 bail!("GHCR token is empty");
628 }
629 Ok(Some(raw_token.to_string()))
630}
631
632fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
633 Reference::from_str(&toolchain_ref(repo, tag))
634 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
635}
636
637async fn manifest_exists(
638 client: &Client,
639 reference: &Reference,
640 auth: &RegistryAuth,
641) -> Result<bool> {
642 match client.pull_manifest(reference, auth).await {
643 Ok(_) => Ok(true),
644 Err(err) if is_missing_manifest_error(&err) => Ok(false),
645 Err(err) => Err(err).context("failed to check whether release tag exists"),
646 }
647}
648
649fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
650 let msg = err.to_string().to_ascii_lowercase();
651 msg.contains("manifest unknown")
652 || msg.contains("name unknown")
653 || msg.contains("not found")
654 || msg.contains("404")
655}
656
657fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
658 let msg = err.to_string().to_ascii_lowercase();
659 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
660}
661
662async fn push_manifest_layer(
663 client: &Client,
664 reference: &Reference,
665 auth: &RegistryAuth,
666 manifest: &ToolchainManifest,
667) -> Result<()> {
668 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
669 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
670 let config = Config::new(
671 br#"{"toolchain":"gtc"}"#.to_vec(),
672 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
673 None,
674 );
675 client
676 .push(reference, &[layer], config, auth, None)
677 .await
678 .context("failed to push toolchain manifest")?;
679 Ok(())
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 struct FixedResolver;
687
688 impl CrateVersionResolver for FixedResolver {
689 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
690 Ok(match crate_name {
691 "greentic-runner" => "0.5.10",
692 _ => "1.2.3",
693 }
694 .to_string())
695 }
696 }
697
698 #[test]
699 fn parses_cargo_search_version() {
700 let version = parse_cargo_search_version(
701 "greentic-dev",
702 r#"greentic-dev = "0.5.1" # Developer CLI"#,
703 )
704 .unwrap();
705 assert_eq!(version, "0.5.1");
706 }
707
708 #[test]
709 fn generates_manifest_from_catalogue() {
710 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
711 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
712 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
713 assert_eq!(manifest.version, "1.0.5");
714 assert_eq!(manifest.channel.as_deref(), Some("latest"));
715 assert!(
716 manifest
717 .packages
718 .iter()
719 .any(|package| package.crate_name == "greentic-bundle"
720 && package.bins == ["greentic-bundle"])
721 );
722 assert!(
723 manifest
724 .packages
725 .iter()
726 .any(|package| package.crate_name == "greentic-runner"
727 && package.bins == ["greentic-runner"])
728 );
729 }
730
731 #[test]
732 fn source_manifest_can_pin_package_versions() {
733 let source = ToolchainManifest {
734 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
735 toolchain: TOOLCHAIN_NAME.to_string(),
736 version: "latest".to_string(),
737 channel: Some("latest".to_string()),
738 created_at: None,
739 packages: vec![ToolchainPackage {
740 crate_name: "greentic-dev".to_string(),
741 bins: vec!["greentic-dev".to_string()],
742 version: "0.5.9".to_string(),
743 }],
744 };
745 let manifest =
746 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
747 let greentic_dev = manifest
748 .packages
749 .iter()
750 .find(|package| package.crate_name == "greentic-dev")
751 .unwrap();
752 assert_eq!(greentic_dev.version, "0.5.9");
753 }
754
755 #[test]
756 fn from_argument_controls_generated_channel_over_source_manifest() {
757 let source = ToolchainManifest {
758 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
759 toolchain: TOOLCHAIN_NAME.to_string(),
760 version: "latest".to_string(),
761 channel: Some("stable".to_string()),
762 created_at: None,
763 packages: Vec::new(),
764 };
765 let manifest =
766 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
767 assert_eq!(manifest.channel.as_deref(), Some("dev"));
768 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
769 }
770
771 #[test]
772 fn generate_from_dev_uses_dev_binary_names() {
773 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
774 assert!(
775 manifest
776 .packages
777 .iter()
778 .flat_map(|package| package.bins.iter())
779 .all(|bin| bin.ends_with("-dev"))
780 );
781 assert!(manifest.packages.iter().any(|package| {
782 package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-dev"]
783 }));
784 assert!(manifest.packages.iter().any(|package| {
785 package.crate_name == "greentic-component" && package.bins == ["greentic-component-dev"]
786 }));
787 }
788
789 #[test]
790 fn bootstrap_source_manifest_uses_source_tag_identity() {
791 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
792 assert_eq!(manifest.version, "latest");
793 assert_eq!(manifest.channel.as_deref(), Some("latest"));
794 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
795 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
796 assert!(
797 manifest
798 .packages
799 .iter()
800 .all(|package| package.version != "latest")
801 );
802 }
803
804 #[test]
805 fn validates_schema_and_toolchain() {
806 let mut manifest =
807 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
808 assert!(validate_manifest(&manifest).is_ok());
809 manifest.schema = "wrong".to_string();
810 assert!(validate_manifest(&manifest).is_err());
811 }
812
813 #[test]
814 fn resolves_inline_registry_token() {
815 assert_eq!(
816 resolve_registry_token(Some("secret-token"))
817 .unwrap()
818 .as_deref(),
819 Some("secret-token")
820 );
821 }
822
823 #[test]
824 fn release_view_tag_prefers_release_or_tag() {
825 let args = ReleaseViewArgs {
826 release: Some("1.0.5".to_string()),
827 tag: None,
828 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
829 token: None,
830 };
831 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
832
833 let args = ReleaseViewArgs {
834 release: None,
835 tag: Some("stable".to_string()),
836 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
837 token: None,
838 };
839 assert_eq!(release_view_tag(&args).unwrap(), "stable");
840 }
841
842 #[test]
843 fn publish_manifest_input_uses_local_manifest_version() {
844 let dir = tempfile::tempdir().unwrap();
845 let path = dir.path().join("gtc-1.0.12.json");
846 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
847 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
848 let args = ReleasePublishArgs {
849 release: None,
850 from: None,
851 tag: Some("stable".to_string()),
852 manifest: Some(path.clone()),
853 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
854 token: None,
855 out: dir.path().to_path_buf(),
856 dry_run: true,
857 force: true,
858 };
859 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
860 assert_eq!(release, "1.0.12");
861 assert_eq!(loaded, manifest);
862 assert_eq!(
863 source_path,
864 Some(PublishManifestSource::Local(path.clone()))
865 );
866 }
867
868 #[test]
869 fn publish_manifest_input_allows_release_override_for_local_manifest() {
870 let dir = tempfile::tempdir().unwrap();
871 let path = dir.path().join("gtc-1.0.13.json");
872 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
873 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
874 let args = ReleasePublishArgs {
875 release: Some("1.0.13".to_string()),
876 from: None,
877 tag: Some("stable".to_string()),
878 manifest: Some(path.clone()),
879 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
880 token: None,
881 out: dir.path().to_path_buf(),
882 dry_run: true,
883 force: true,
884 };
885 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
886 assert_eq!(release, "1.0.13");
887 assert_eq!(loaded.version, "1.0.13");
888 assert_eq!(
889 source_path,
890 Some(PublishManifestSource::Local(path.clone()))
891 );
892 }
893
894 #[test]
895 fn manifest_file_name_omits_stable_channel() {
896 let manifest = ToolchainManifest {
897 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
898 toolchain: TOOLCHAIN_NAME.to_string(),
899 version: "1.0.12".to_string(),
900 channel: Some("stable".to_string()),
901 created_at: None,
902 packages: Vec::new(),
903 };
904 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
905 }
906
907 #[test]
908 fn manifest_file_name_includes_non_stable_channel() {
909 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
910 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
911
912 manifest.channel = Some("customer-a".to_string());
913 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
914 }
915
916 #[test]
917 fn latest_manifest_uses_latest_dev_bins() {
918 let manifest = latest_manifest(None);
919 assert_eq!(manifest.version, "latest");
920 assert_eq!(manifest.channel.as_deref(), Some("latest"));
921 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
922 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
923 assert!(!manifest.packages.is_empty());
924 assert!(
925 manifest
926 .packages
927 .iter()
928 .all(|package| package.version == "latest")
929 );
930 assert!(
931 manifest
932 .packages
933 .iter()
934 .flat_map(|package| package.bins.iter())
935 .all(|bin| bin.ends_with("-dev"))
936 );
937 assert!(
938 manifest
939 .packages
940 .iter()
941 .any(|package| { package.crate_name == "gtc" && package.bins == ["gtc-dev"] })
942 );
943 assert!(manifest.packages.iter().any(|package| {
944 package.crate_name == "greentic-dev" && package.bins == ["greentic-dev-dev"]
945 }));
946 }
947
948 #[test]
949 fn builds_toolchain_ref() {
950 assert_eq!(
951 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
952 "ghcr.io/greenticai/greentic-versions/gtc:stable"
953 );
954 }
955}