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: delegated_binary_name_for_channel(
354 TOOLCHAIN_NAME,
355 ToolchainChannel::Development,
356 ),
357 bins: vec![delegated_binary_name_for_channel(
358 TOOLCHAIN_NAME,
359 ToolchainChannel::Development,
360 )],
361 version: "latest".to_string(),
362 })
363 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
364 ToolchainPackage {
365 crate_name: delegated_binary_name_for_channel(
366 package.crate_name,
367 ToolchainChannel::Development,
368 ),
369 bins: package
370 .bins
371 .iter()
372 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
373 .collect(),
374 version: "latest".to_string(),
375 }
376 }))
377 .collect()
378}
379
380fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
381 match (&args.release, &args.tag) {
382 (Some(release), None) => Ok(release.clone()),
383 (None, Some(tag)) => Ok(tag.clone()),
384 _ => bail!("pass exactly one of --release or --tag"),
385 }
386}
387
388pub fn generate_manifest<R: CrateVersionResolver>(
389 release: &str,
390 from: &str,
391 source: Option<&ToolchainManifest>,
392 resolver: &R,
393 created_at: Option<String>,
394) -> Result<ToolchainManifest> {
395 if let Some(source) = source {
396 validate_manifest(source)?;
397 }
398 let source_versions = source_version_map(source);
399 let mut packages = Vec::new();
400 for package in GREENTIC_TOOLCHAIN_PACKAGES {
401 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
402 let source_version = source_versions.get(&crate_in_manifest);
403 let version = match source_version.map(String::as_str) {
404 Some(version) if version != "latest" => version.to_string(),
405 _ => resolver.resolve_latest(&crate_in_manifest)?,
406 };
407 packages.push(ToolchainPackage {
408 crate_name: crate_in_manifest,
409 bins: manifest_bins_for_source(from, package.bins),
410 version,
411 });
412 }
413 Ok(ToolchainManifest {
414 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
415 toolchain: TOOLCHAIN_NAME.to_string(),
416 version: release.to_string(),
417 channel: Some(from.to_string()),
418 created_at,
419 packages,
420 })
421}
422
423fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
424 if from == "dev" {
425 bins.iter()
426 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
427 .collect()
428 } else {
429 bins.iter().map(|bin| (*bin).to_string()).collect()
430 }
431}
432
433fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
441 if from == "dev" {
442 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
443 } else {
444 crate_name.to_string()
445 }
446}
447
448pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
449 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
450 bail!(
451 "unsupported toolchain manifest schema `{}`",
452 manifest.schema
453 );
454 }
455 if manifest.toolchain != TOOLCHAIN_NAME {
456 bail!("unsupported toolchain `{}`", manifest.toolchain);
457 }
458 Ok(())
459}
460
461pub fn toolchain_ref(repo: &str, tag: &str) -> String {
462 format!("{repo}:{tag}")
463}
464
465fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
466 let mut out = BTreeMap::new();
467 if let Some(source) = source {
468 for package in &source.packages {
469 out.insert(package.crate_name.clone(), package.version.clone());
470 }
471 }
472 out
473}
474
475fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
476 fs::create_dir_all(out_dir)
477 .with_context(|| format!("failed to create {}", out_dir.display()))?;
478 let path = out_dir.join(manifest_file_name(manifest));
479 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
480 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
481 Ok(path)
482}
483
484fn manifest_file_name(manifest: &ToolchainManifest) -> String {
485 match manifest.channel.as_deref() {
486 Some("stable") | None => format!("gtc-{}.json", manifest.version),
487 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
488 }
489}
490
491fn created_at_now() -> Result<String> {
492 OffsetDateTime::now_utc()
493 .format(&Rfc3339)
494 .context("failed to format current time")
495}
496
497pub trait CrateVersionResolver {
498 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
499}
500
501struct CargoSearchVersionResolver;
502
503impl CrateVersionResolver for CargoSearchVersionResolver {
504 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
505 let output = Command::new("cargo")
506 .arg("search")
507 .arg(crate_name)
508 .arg("--limit")
509 .arg("1")
510 .output()
511 .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
512 if !output.status.success() {
513 bail!(
514 "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
515 output.status.code()
516 );
517 }
518 let stdout = String::from_utf8(output.stdout).with_context(|| {
519 format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
520 })?;
521 parse_cargo_search_version(crate_name, &stdout)
522 }
523}
524
525fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
526 let first_line = stdout
527 .lines()
528 .find(|line| !line.trim().is_empty())
529 .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
530 let Some((found_name, rhs)) = first_line.split_once('=') else {
531 bail!("unexpected cargo search output: {first_line}");
532 };
533 if found_name.trim() != crate_name {
534 bail!(
535 "`cargo search {crate_name} --limit 1` returned `{}` first",
536 found_name.trim()
537 );
538 }
539 let quoted = rhs
540 .split('#')
541 .next()
542 .map(str::trim)
543 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
544 let version = quoted.trim_matches('"');
545 Version::parse(version)
546 .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
547 Ok(version.to_string())
548}
549
550#[async_trait]
551trait ToolchainManifestSource {
552 async fn load_manifest(
553 &self,
554 repo: &str,
555 tag: &str,
556 token: Option<&str>,
557 ) -> Result<Option<ToolchainManifest>>;
558}
559
560struct OciToolchainManifestSource;
561
562#[async_trait]
563impl ToolchainManifestSource for OciToolchainManifestSource {
564 async fn load_manifest(
565 &self,
566 repo: &str,
567 tag: &str,
568 token: Option<&str>,
569 ) -> Result<Option<ToolchainManifest>> {
570 let auth = optional_registry_auth(token)?;
571 let client = oci_client();
572 let reference = parse_reference(repo, tag)?;
573 let image = match client
574 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
575 .await
576 {
577 Ok(image) => image,
578 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
579 return Ok(None);
580 }
581 Err(err) => {
582 return Err(err)
583 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
584 }
585 };
586 let Some(layer) = image
587 .layers
588 .into_iter()
589 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
590 else {
591 return Ok(None);
592 };
593 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
594 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
595 validate_manifest(&manifest)?;
596 Ok(Some(manifest))
597 }
598}
599
600async fn load_source_manifest(
601 repo: &str,
602 tag: &str,
603 token: Option<&str>,
604) -> Result<Option<ToolchainManifest>> {
605 OciToolchainManifestSource
606 .load_manifest(repo, tag, token)
607 .await
608}
609
610fn oci_client() -> Client {
611 Client::new(ClientConfig {
612 protocol: ClientProtocol::Https,
613 ..Default::default()
614 })
615}
616
617fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
618 let token = resolve_registry_token(raw_token)?
619 .or_else(|| std::env::var("GHCR_TOKEN").ok())
620 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
621 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
622 if token.trim().is_empty() {
623 bail!("GHCR token is empty");
624 }
625 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
626}
627
628fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
629 match registry_auth(raw_token) {
630 Ok(auth) => Ok(auth),
631 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
632 Err(err) => Err(err),
633 }
634}
635
636fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
637 let Some(raw_token) = raw_token else {
638 return Ok(None);
639 };
640 if let Some(var) = raw_token.strip_prefix("env:") {
641 let token =
642 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
643 if token.trim().is_empty() {
644 bail!("env var {var} resolved to an empty token");
645 }
646 return Ok(Some(token));
647 }
648 if raw_token.trim().is_empty() {
649 bail!("GHCR token is empty");
650 }
651 Ok(Some(raw_token.to_string()))
652}
653
654fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
655 Reference::from_str(&toolchain_ref(repo, tag))
656 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
657}
658
659async fn manifest_exists(
660 client: &Client,
661 reference: &Reference,
662 auth: &RegistryAuth,
663) -> Result<bool> {
664 match client.pull_manifest(reference, auth).await {
665 Ok(_) => Ok(true),
666 Err(err) if is_missing_manifest_error(&err) => Ok(false),
667 Err(err) => Err(err).context("failed to check whether release tag exists"),
668 }
669}
670
671fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
672 let msg = err.to_string().to_ascii_lowercase();
673 msg.contains("manifest unknown")
674 || msg.contains("name unknown")
675 || msg.contains("not found")
676 || msg.contains("404")
677}
678
679fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
680 let msg = err.to_string().to_ascii_lowercase();
681 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
682}
683
684async fn push_manifest_layer(
685 client: &Client,
686 reference: &Reference,
687 auth: &RegistryAuth,
688 manifest: &ToolchainManifest,
689) -> Result<()> {
690 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
691 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
692 let config = Config::new(
693 br#"{"toolchain":"gtc"}"#.to_vec(),
694 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
695 None,
696 );
697 client
698 .push(reference, &[layer], config, auth, None)
699 .await
700 .context("failed to push toolchain manifest")?;
701 Ok(())
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use once_cell::sync::Lazy;
708 use std::sync::Mutex;
709
710 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
711
712 struct FixedResolver;
713
714 impl CrateVersionResolver for FixedResolver {
715 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
716 Ok(match crate_name {
717 "greentic-runner" => "0.5.10",
718 _ => "1.2.3",
719 }
720 .to_string())
721 }
722 }
723
724 #[test]
725 fn parses_cargo_search_version() {
726 let version = parse_cargo_search_version(
727 "greentic-dev",
728 r#"greentic-dev = "0.5.1" # Developer CLI"#,
729 )
730 .unwrap();
731 assert_eq!(version, "0.5.1");
732 }
733
734 #[test]
735 fn rejects_empty_cargo_search_results() {
736 let err = parse_cargo_search_version("greentic-dev", "\n\n").unwrap_err();
737 assert!(err.to_string().contains("returned no results"));
738 }
739
740 #[test]
741 fn rejects_wrong_first_cargo_search_result() {
742 let err =
743 parse_cargo_search_version("greentic-dev", r#"greentic-runner = "0.5.1""#).unwrap_err();
744 assert!(err.to_string().contains("returned `greentic-runner` first"));
745 }
746
747 #[test]
748 fn rejects_invalid_cargo_search_version() {
749 let err = parse_cargo_search_version("greentic-dev", r#"greentic-dev = "not-a-version""#)
750 .unwrap_err();
751 assert!(err.to_string().contains("failed to parse crate version"));
752 }
753
754 #[test]
755 fn generates_manifest_from_catalogue() {
756 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
757 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
758 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
759 assert_eq!(manifest.version, "1.0.5");
760 assert_eq!(manifest.channel.as_deref(), Some("latest"));
761 assert!(
762 manifest
763 .packages
764 .iter()
765 .any(|package| package.crate_name == "greentic-bundle"
766 && package.bins == ["greentic-bundle"])
767 );
768 assert!(
769 manifest
770 .packages
771 .iter()
772 .any(|package| package.crate_name == "greentic-runner"
773 && package.bins == ["greentic-runner"])
774 );
775 }
776
777 #[test]
778 fn source_manifest_can_pin_package_versions() {
779 let source = ToolchainManifest {
780 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
781 toolchain: TOOLCHAIN_NAME.to_string(),
782 version: "latest".to_string(),
783 channel: Some("latest".to_string()),
784 created_at: None,
785 packages: vec![ToolchainPackage {
786 crate_name: "greentic-dev".to_string(),
787 bins: vec!["greentic-dev".to_string()],
788 version: "0.5.9".to_string(),
789 }],
790 };
791 let manifest =
792 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
793 let greentic_dev = manifest
794 .packages
795 .iter()
796 .find(|package| package.crate_name == "greentic-dev")
797 .unwrap();
798 assert_eq!(greentic_dev.version, "0.5.9");
799 }
800
801 #[test]
802 fn from_argument_controls_generated_channel_over_source_manifest() {
803 let source = ToolchainManifest {
804 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
805 toolchain: TOOLCHAIN_NAME.to_string(),
806 version: "latest".to_string(),
807 channel: Some("stable".to_string()),
808 created_at: None,
809 packages: Vec::new(),
810 };
811 let manifest =
812 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
813 assert_eq!(manifest.channel.as_deref(), Some("dev"));
814 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
815 }
816
817 #[test]
818 fn generate_from_dev_uses_dev_crate_and_binary_names() {
819 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
820 assert!(
821 manifest
822 .packages
823 .iter()
824 .flat_map(|package| package.bins.iter())
825 .all(|bin| bin.ends_with("-dev"))
826 );
827 assert!(
828 manifest
829 .packages
830 .iter()
831 .all(|package| package.crate_name.ends_with("-dev")),
832 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
833 );
834 assert!(manifest.packages.iter().any(|package| {
835 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
836 }));
837 assert!(manifest.packages.iter().any(|package| {
838 package.crate_name == "greentic-component-dev"
839 && package.bins == ["greentic-component-dev"]
840 }));
841 assert!(manifest.packages.iter().any(|package| {
842 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
843 }));
844 }
845
846 #[test]
847 fn bootstrap_source_manifest_uses_source_tag_identity() {
848 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
849 assert_eq!(manifest.version, "latest");
850 assert_eq!(manifest.channel.as_deref(), Some("latest"));
851 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
852 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
853 assert!(
854 manifest
855 .packages
856 .iter()
857 .all(|package| package.version != "latest")
858 );
859 }
860
861 #[test]
862 fn validates_schema_and_toolchain() {
863 let mut manifest =
864 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
865 assert!(validate_manifest(&manifest).is_ok());
866 manifest.schema = "wrong".to_string();
867 assert!(validate_manifest(&manifest).is_err());
868 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
869 manifest.toolchain = "other".to_string();
870 assert!(validate_manifest(&manifest).is_err());
871 }
872
873 #[test]
874 fn resolves_inline_registry_token() {
875 assert_eq!(
876 resolve_registry_token(Some("secret-token"))
877 .unwrap()
878 .as_deref(),
879 Some("secret-token")
880 );
881 }
882
883 #[test]
884 fn resolves_registry_token_from_environment_reference() {
885 let _guard = ENV_LOCK.lock().unwrap();
886 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
887 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
888
889 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
890 assert_eq!(resolved.as_deref(), Some("env-secret"));
891
892 match previous {
893 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
894 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
895 }
896 }
897
898 #[test]
899 fn rejects_empty_registry_token_from_environment_reference() {
900 let _guard = ENV_LOCK.lock().unwrap();
901 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
902 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
903
904 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
905 assert!(err.to_string().contains("resolved to an empty token"));
906
907 match previous {
908 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
909 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
910 }
911 }
912
913 #[test]
914 fn registry_auth_uses_environment_fallbacks() {
915 let _guard = ENV_LOCK.lock().unwrap();
916 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
917 let previous_github = std::env::var("GITHUB_TOKEN").ok();
918 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
919 unsafe { std::env::remove_var("GITHUB_TOKEN") };
920
921 let auth = registry_auth(None).unwrap();
922 match auth {
923 RegistryAuth::Basic(user, token) => {
924 assert_eq!(user, DEFAULT_OAUTH_USER);
925 assert_eq!(token, "ghcr-secret");
926 }
927 _ => panic!("expected basic auth"),
928 }
929
930 match previous_ghcr {
931 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
932 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
933 }
934 match previous_github {
935 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
936 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
937 }
938 }
939
940 #[test]
941 fn optional_registry_auth_allows_missing_implicit_token() {
942 let _guard = ENV_LOCK.lock().unwrap();
943 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
944 let previous_github = std::env::var("GITHUB_TOKEN").ok();
945 unsafe { std::env::remove_var("GHCR_TOKEN") };
946 unsafe { std::env::remove_var("GITHUB_TOKEN") };
947
948 let auth = optional_registry_auth(None).unwrap();
949 assert!(matches!(auth, RegistryAuth::Anonymous));
950
951 match previous_ghcr {
952 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
953 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
954 }
955 match previous_github {
956 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
957 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
958 }
959 }
960
961 #[test]
962 fn release_view_tag_prefers_release_or_tag() {
963 let args = ReleaseViewArgs {
964 release: Some("1.0.5".to_string()),
965 tag: None,
966 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
967 token: None,
968 };
969 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
970
971 let args = ReleaseViewArgs {
972 release: None,
973 tag: Some("stable".to_string()),
974 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
975 token: None,
976 };
977 assert_eq!(release_view_tag(&args).unwrap(), "stable");
978 }
979
980 #[test]
981 fn release_view_tag_rejects_invalid_argument_combinations() {
982 let err = release_view_tag(&ReleaseViewArgs {
983 release: Some("1.0.5".to_string()),
984 tag: Some("stable".to_string()),
985 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
986 token: None,
987 })
988 .unwrap_err();
989 assert!(
990 err.to_string()
991 .contains("pass exactly one of --release or --tag")
992 );
993
994 let err = release_view_tag(&ReleaseViewArgs {
995 release: None,
996 tag: None,
997 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
998 token: None,
999 })
1000 .unwrap_err();
1001 assert!(
1002 err.to_string()
1003 .contains("pass exactly one of --release or --tag")
1004 );
1005 }
1006
1007 #[test]
1008 fn publish_manifest_input_uses_local_manifest_version() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let path = dir.path().join("gtc-1.0.12.json");
1011 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1012 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1013 let args = ReleasePublishArgs {
1014 release: None,
1015 from: None,
1016 tag: Some("stable".to_string()),
1017 manifest: Some(path.clone()),
1018 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1019 token: None,
1020 out: dir.path().to_path_buf(),
1021 dry_run: true,
1022 force: true,
1023 };
1024 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1025 assert_eq!(release, "1.0.12");
1026 assert_eq!(loaded, manifest);
1027 assert_eq!(
1028 source_path,
1029 Some(PublishManifestSource::Local(path.clone()))
1030 );
1031 }
1032
1033 #[test]
1034 fn publish_manifest_input_allows_release_override_for_local_manifest() {
1035 let dir = tempfile::tempdir().unwrap();
1036 let path = dir.path().join("gtc-1.0.13.json");
1037 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1038 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1039 let args = ReleasePublishArgs {
1040 release: Some("1.0.13".to_string()),
1041 from: None,
1042 tag: Some("stable".to_string()),
1043 manifest: Some(path.clone()),
1044 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1045 token: None,
1046 out: dir.path().to_path_buf(),
1047 dry_run: true,
1048 force: true,
1049 };
1050 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1051 assert_eq!(release, "1.0.13");
1052 assert_eq!(loaded.version, "1.0.13");
1053 assert_eq!(
1054 source_path,
1055 Some(PublishManifestSource::Local(path.clone()))
1056 );
1057 }
1058
1059 #[test]
1060 fn manifest_file_name_omits_stable_channel() {
1061 let manifest = ToolchainManifest {
1062 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1063 toolchain: TOOLCHAIN_NAME.to_string(),
1064 version: "1.0.12".to_string(),
1065 channel: Some("stable".to_string()),
1066 created_at: None,
1067 packages: Vec::new(),
1068 };
1069 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1070 }
1071
1072 #[test]
1073 fn manifest_file_name_includes_non_stable_channel() {
1074 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1075 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1076
1077 manifest.channel = Some("customer-a".to_string());
1078 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1079 }
1080
1081 #[test]
1082 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
1083 assert_eq!(
1084 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
1085 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
1086 );
1087 assert_eq!(
1088 manifest_bins_for_source("dev", &["greentic-dev"]),
1089 vec!["greentic-dev-dev".to_string()]
1090 );
1091 assert_eq!(
1092 manifest_crate_name_for_source("latest", "greentic-runner"),
1093 "greentic-runner"
1094 );
1095 assert_eq!(
1096 manifest_crate_name_for_source("dev", "greentic-runner"),
1097 "greentic-runner-dev"
1098 );
1099 }
1100
1101 #[test]
1102 fn source_version_map_handles_missing_and_present_sources() {
1103 assert!(source_version_map(None).is_empty());
1104
1105 let source = ToolchainManifest {
1106 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1107 toolchain: TOOLCHAIN_NAME.to_string(),
1108 version: "latest".to_string(),
1109 channel: Some("latest".to_string()),
1110 created_at: None,
1111 packages: vec![ToolchainPackage {
1112 crate_name: "greentic-dev".to_string(),
1113 bins: vec!["greentic-dev".to_string()],
1114 version: "0.6.0".to_string(),
1115 }],
1116 };
1117
1118 let versions = source_version_map(Some(&source));
1119 assert_eq!(
1120 versions.get("greentic-dev").map(String::as_str),
1121 Some("0.6.0")
1122 );
1123 }
1124
1125 #[test]
1126 fn write_manifest_persists_json_to_expected_file_name() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1129
1130 let path = write_manifest(dir.path(), &manifest).unwrap();
1131 assert_eq!(
1132 path.file_name().and_then(|name| name.to_str()),
1133 Some("gtc-dev-1.0.12.json")
1134 );
1135
1136 let roundtrip = read_manifest_file(&path).unwrap();
1137 assert_eq!(roundtrip, manifest);
1138 }
1139
1140 #[test]
1141 fn latest_manifest_uses_latest_dev_bins() {
1142 let manifest = latest_manifest(None);
1143 assert_eq!(manifest.version, "latest");
1144 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1145 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1146 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1147 assert!(!manifest.packages.is_empty());
1148 assert!(
1149 manifest
1150 .packages
1151 .iter()
1152 .all(|package| package.version == "latest")
1153 );
1154 assert!(
1155 manifest
1156 .packages
1157 .iter()
1158 .flat_map(|package| package.bins.iter())
1159 .all(|bin| bin.ends_with("-dev"))
1160 );
1161 assert!(
1162 manifest
1163 .packages
1164 .iter()
1165 .all(|package| package.crate_name.ends_with("-dev")),
1166 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
1167 );
1168 assert!(
1169 manifest
1170 .packages
1171 .iter()
1172 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
1173 );
1174 assert!(manifest.packages.iter().any(|package| {
1175 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1176 }));
1177 }
1178
1179 #[test]
1180 fn publish_dry_run_with_local_manifest_succeeds() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = dir.path().join("gtc-1.0.12.json");
1183 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1184 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1185
1186 publish(ReleasePublishArgs {
1187 release: None,
1188 from: None,
1189 tag: Some("stable".to_string()),
1190 manifest: Some(path),
1191 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1192 token: None,
1193 out: dir.path().to_path_buf(),
1194 dry_run: true,
1195 force: false,
1196 })
1197 .unwrap();
1198 }
1199
1200 #[test]
1201 fn latest_dry_run_succeeds() {
1202 latest(ReleaseLatestArgs {
1203 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1204 token: None,
1205 dry_run: true,
1206 force: false,
1207 })
1208 .unwrap();
1209 }
1210
1211 #[test]
1212 fn promote_dry_run_succeeds() {
1213 promote(ReleasePromoteArgs {
1214 release: "1.0.12".to_string(),
1215 tag: "stable".to_string(),
1216 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1217 token: None,
1218 dry_run: true,
1219 })
1220 .unwrap();
1221 }
1222
1223 #[test]
1224 fn builds_toolchain_ref() {
1225 assert_eq!(
1226 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1227 "ghcr.io/greenticai/greentic-versions/gtc:stable"
1228 );
1229 }
1230}