1use std::collections::BTreeMap;
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::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 = default_resolver();
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 = default_resolver();
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 if let Some(source_manifest) = source.as_ref()
225 && source_manifest_has_concrete_pins(source_manifest)
226 {
227 eprintln!(
228 "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
229 of querying crates.io. To refresh a channel from the latest crates.io versions, use \
230 `release snapshot --channel <dev|stable>`. To copy an existing release tag without \
231 re-resolving, use `release promote`. The conflated `--from` semantics will be \
232 removed in a future release.",
233 toolchain_ref(&args.repo, from),
234 );
235 }
236 let manifest = generate_manifest(
237 release,
238 from,
239 source.as_ref(),
240 &resolver,
241 Some(created_at_now()?),
242 )?;
243 let path = if args.dry_run {
244 println!("{}", serde_json::to_string_pretty(&manifest)?);
245 None
246 } else {
247 Some(PublishManifestSource::Generated(write_manifest(
248 &args.out, &manifest,
249 )?))
250 };
251 Ok((release.to_string(), manifest, path))
252}
253
254fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
259 manifest
260 .packages
261 .iter()
262 .any(|package| package.version != "latest")
263}
264
265fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
266 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
267 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
268}
269
270pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
271 if args.dry_run {
272 println!(
273 "Dry run: would promote {} to {}",
274 toolchain_ref(&args.repo, &args.release),
275 toolchain_ref(&args.repo, &args.tag)
276 );
277 return Ok(());
278 }
279
280 let auth = registry_auth(args.token.as_deref())?;
281 block_on_maybe_runtime(async {
282 let client = oci_client();
283 let source_ref = parse_reference(&args.repo, &args.release)?;
284 let target_ref = parse_reference(&args.repo, &args.tag)?;
285 let (manifest, _) = client
286 .pull_manifest(&source_ref, &auth)
287 .await
288 .with_context(|| {
289 format!(
290 "failed to resolve source release `{}`",
291 toolchain_ref(&args.repo, &args.release)
292 )
293 })?;
294 client
295 .push_manifest(&target_ref, &manifest)
296 .await
297 .with_context(|| {
298 format!(
299 "failed to update tag `{}`",
300 toolchain_ref(&args.repo, &args.tag)
301 )
302 })?;
303 Ok(())
304 })?;
305 println!(
306 "Promoted {} to {}",
307 toolchain_ref(&args.repo, &args.release),
308 toolchain_ref(&args.repo, &args.tag)
309 );
310 Ok(())
311}
312
313pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
321 let channel = parse_channel(&args.channel)?;
322 let resolver = CratesIoApiVersionResolver::default();
323 let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
324
325 if args.dry_run {
326 println!("{}", serde_json::to_string_pretty(&manifest)?);
327 println!(
328 "Dry run: would publish {}",
329 toolchain_ref(&args.repo, &args.release)
330 );
331 if let Some(tag) = &args.tag {
332 println!(
333 "Dry run: would tag {} as {}",
334 toolchain_ref(&args.repo, &args.release),
335 toolchain_ref(&args.repo, tag)
336 );
337 }
338 return Ok(());
339 }
340
341 let path = write_manifest(&args.out, &manifest)?;
342 println!("Wrote {}", path.display());
343
344 let auth = registry_auth(args.token.as_deref())?;
345 block_on_maybe_runtime(async {
346 let client = oci_client();
347 let release_ref = parse_reference(&args.repo, &args.release)?;
348 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
349 bail!(
350 "release tag `{}` already exists; pass --force to overwrite it",
351 toolchain_ref(&args.repo, &args.release)
352 );
353 }
354 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
355 if let Some(tag) = &args.tag {
356 let tag_ref = parse_reference(&args.repo, tag)?;
357 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
358 }
359 Ok(())
360 })?;
361 println!("Published {}", toolchain_ref(&args.repo, &args.release));
362 if let Some(tag) = &args.tag {
363 println!("Updated {}", toolchain_ref(&args.repo, tag));
364 }
365 Ok(())
366}
367
368fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
369 match channel {
370 "dev" | "development" => Ok(ToolchainChannel::Development),
371 "stable" => Ok(ToolchainChannel::Stable),
372 other => bail!(
373 "unknown channel `{other}` (expected `dev` or `stable`); pass --channel dev for the \
374 dev lane or --channel stable for the stable lane"
375 ),
376 }
377}
378
379fn channel_tag(channel: ToolchainChannel) -> &'static str {
380 match channel {
381 ToolchainChannel::Stable => "stable",
382 ToolchainChannel::Development => "dev",
383 }
384}
385
386pub fn snapshot_manifest<R: CrateVersionResolver>(
387 release: &str,
388 channel: ToolchainChannel,
389 resolver: &R,
390 created_at: Option<String>,
391) -> Result<ToolchainManifest> {
392 let from = channel_tag(channel);
393 let mut packages = Vec::new();
394 for package in GREENTIC_TOOLCHAIN_PACKAGES {
395 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
396 let version = resolver
397 .resolve_latest(&crate_in_manifest)
398 .with_context(|| {
399 format!("failed to resolve latest version for `{crate_in_manifest}`")
400 })?;
401 packages.push(ToolchainPackage {
402 crate_name: crate_in_manifest,
403 bins: manifest_bins_for_source(from, package.bins),
404 version,
405 });
406 }
407 Ok(ToolchainManifest {
408 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
409 toolchain: TOOLCHAIN_NAME.to_string(),
410 version: release.to_string(),
411 channel: Some(from.to_string()),
412 created_at,
413 packages,
414 })
415}
416
417pub fn view(args: ReleaseViewArgs) -> Result<()> {
418 let tag = release_view_tag(&args)?;
419 let manifest = block_on_maybe_runtime(load_source_manifest(
420 &args.repo,
421 &tag,
422 args.token.as_deref(),
423 ))
424 .with_context(|| {
425 format!(
426 "failed to resolve manifest `{}`",
427 toolchain_ref(&args.repo, &tag)
428 )
429 })?
430 .with_context(|| {
431 format!(
432 "manifest `{}` was not found or is not authorized for this token",
433 toolchain_ref(&args.repo, &tag)
434 )
435 })?;
436 println!("{}", serde_json::to_string_pretty(&manifest)?);
437 Ok(())
438}
439
440pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
441 let manifest = latest_manifest(Some(created_at_now()?));
442 if args.dry_run {
443 println!("{}", serde_json::to_string_pretty(&manifest)?);
444 println!(
445 "Dry run: would publish {}",
446 toolchain_ref(&args.repo, "latest")
447 );
448 return Ok(());
449 }
450
451 let auth = registry_auth(args.token.as_deref())?;
452 block_on_maybe_runtime(async {
453 let client = oci_client();
454 let latest_ref = parse_reference(&args.repo, "latest")?;
455 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
456 bail!(
457 "latest tag `{}` already exists; pass --force to overwrite it",
458 toolchain_ref(&args.repo, "latest")
459 );
460 }
461 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
462 })?;
463 println!("Published {}", toolchain_ref(&args.repo, "latest"));
464 Ok(())
465}
466
467fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
468 ToolchainManifest {
469 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
470 toolchain: TOOLCHAIN_NAME.to_string(),
471 version: "latest".to_string(),
472 channel: Some("latest".to_string()),
473 created_at,
474 packages: latest_manifest_packages(),
475 }
476}
477
478fn latest_manifest_packages() -> Vec<ToolchainPackage> {
479 std::iter::once(ToolchainPackage {
480 crate_name: delegated_binary_name_for_channel(
481 TOOLCHAIN_NAME,
482 ToolchainChannel::Development,
483 ),
484 bins: vec![delegated_binary_name_for_channel(
485 TOOLCHAIN_NAME,
486 ToolchainChannel::Development,
487 )],
488 version: "latest".to_string(),
489 })
490 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
491 ToolchainPackage {
492 crate_name: delegated_binary_name_for_channel(
493 package.crate_name,
494 ToolchainChannel::Development,
495 ),
496 bins: package
497 .bins
498 .iter()
499 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
500 .collect(),
501 version: "latest".to_string(),
502 }
503 }))
504 .collect()
505}
506
507fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
508 match (&args.release, &args.tag) {
509 (Some(release), None) => Ok(release.clone()),
510 (None, Some(tag)) => Ok(tag.clone()),
511 _ => bail!("pass exactly one of --release or --tag"),
512 }
513}
514
515pub fn generate_manifest<R: CrateVersionResolver>(
516 release: &str,
517 from: &str,
518 source: Option<&ToolchainManifest>,
519 resolver: &R,
520 created_at: Option<String>,
521) -> Result<ToolchainManifest> {
522 if let Some(source) = source {
523 validate_manifest(source)?;
524 }
525 let source_versions = source_version_map(source);
526 let mut packages = Vec::new();
527 for package in GREENTIC_TOOLCHAIN_PACKAGES {
528 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
529 let source_version = source_versions.get(&crate_in_manifest);
530 let version = match source_version.map(String::as_str) {
531 Some(version) if version != "latest" => version.to_string(),
532 _ => resolver.resolve_latest(&crate_in_manifest)?,
533 };
534 packages.push(ToolchainPackage {
535 crate_name: crate_in_manifest,
536 bins: manifest_bins_for_source(from, package.bins),
537 version,
538 });
539 }
540 Ok(ToolchainManifest {
541 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
542 toolchain: TOOLCHAIN_NAME.to_string(),
543 version: release.to_string(),
544 channel: Some(from.to_string()),
545 created_at,
546 packages,
547 })
548}
549
550fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
551 if from == "dev" {
552 bins.iter()
553 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
554 .collect()
555 } else {
556 bins.iter().map(|bin| (*bin).to_string()).collect()
557 }
558}
559
560fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
568 if from == "dev" {
569 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
570 } else {
571 crate_name.to_string()
572 }
573}
574
575pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
576 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
577 bail!(
578 "unsupported toolchain manifest schema `{}`",
579 manifest.schema
580 );
581 }
582 if manifest.toolchain != TOOLCHAIN_NAME {
583 bail!("unsupported toolchain `{}`", manifest.toolchain);
584 }
585 Ok(())
586}
587
588pub fn toolchain_ref(repo: &str, tag: &str) -> String {
589 format!("{repo}:{tag}")
590}
591
592fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
593 let mut out = BTreeMap::new();
594 if let Some(source) = source {
595 for package in &source.packages {
596 out.insert(package.crate_name.clone(), package.version.clone());
597 }
598 }
599 out
600}
601
602fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
603 fs::create_dir_all(out_dir)
604 .with_context(|| format!("failed to create {}", out_dir.display()))?;
605 let path = out_dir.join(manifest_file_name(manifest));
606 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
607 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
608 Ok(path)
609}
610
611fn manifest_file_name(manifest: &ToolchainManifest) -> String {
612 match manifest.channel.as_deref() {
613 Some("stable") | None => format!("gtc-{}.json", manifest.version),
614 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
615 }
616}
617
618fn created_at_now() -> Result<String> {
619 OffsetDateTime::now_utc()
620 .format(&Rfc3339)
621 .context("failed to format current time")
622}
623
624pub trait CrateVersionResolver {
625 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
626}
627
628fn default_resolver() -> CratesIoApiVersionResolver {
632 CratesIoApiVersionResolver::default()
633}
634
635const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
636const CRATES_IO_USER_AGENT: &str = concat!(
637 "greentic-dev/",
638 env!("CARGO_PKG_VERSION"),
639 " (https://github.com/greenticai/greentic-dev)"
640);
641
642pub struct CratesIoApiVersionResolver {
649 base_url: String,
650 client: reqwest::blocking::Client,
651}
652
653impl Default for CratesIoApiVersionResolver {
654 fn default() -> Self {
655 Self::new(CRATES_IO_API_BASE)
656 }
657}
658
659impl CratesIoApiVersionResolver {
660 pub fn new(base_url: impl Into<String>) -> Self {
661 let client = reqwest::blocking::Client::builder()
662 .user_agent(CRATES_IO_USER_AGENT)
663 .build()
664 .expect("failed to build crates.io API client");
665 Self {
666 base_url: base_url.into(),
667 client,
668 }
669 }
670}
671
672impl CrateVersionResolver for CratesIoApiVersionResolver {
673 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
674 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
675 let response = self
676 .client
677 .get(&url)
678 .send()
679 .with_context(|| format!("failed to GET {url}"))?;
680 let status = response.status();
681 let body = response
682 .text()
683 .with_context(|| format!("failed to read body of {url}"))?;
684 if !status.is_success() {
685 bail!("crates.io API GET {url} returned {status}: {body}");
686 }
687 parse_crates_io_version(crate_name, &body)
688 }
689}
690
691fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
692 let payload: serde_json::Value = serde_json::from_str(body)
693 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
694 let crate_obj = payload.get("crate").ok_or_else(|| {
695 anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
696 })?;
697 let version = crate_obj
698 .get("max_stable_version")
699 .and_then(|v| v.as_str())
700 .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
701 .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
702 .ok_or_else(|| {
703 anyhow!(
704 "crates.io API for `{crate_name}` does not expose max_stable_version, \
705 newest_version, or max_version"
706 )
707 })?;
708 Version::parse(version).with_context(|| {
709 format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
710 })?;
711 Ok(version.to_string())
712}
713
714#[async_trait]
715trait ToolchainManifestSource {
716 async fn load_manifest(
717 &self,
718 repo: &str,
719 tag: &str,
720 token: Option<&str>,
721 ) -> Result<Option<ToolchainManifest>>;
722}
723
724struct OciToolchainManifestSource;
725
726#[async_trait]
727impl ToolchainManifestSource for OciToolchainManifestSource {
728 async fn load_manifest(
729 &self,
730 repo: &str,
731 tag: &str,
732 token: Option<&str>,
733 ) -> Result<Option<ToolchainManifest>> {
734 let auth = optional_registry_auth(token)?;
735 let client = oci_client();
736 let reference = parse_reference(repo, tag)?;
737 let image = match client
738 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
739 .await
740 {
741 Ok(image) => image,
742 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
743 return Ok(None);
744 }
745 Err(err) => {
746 return Err(err)
747 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
748 }
749 };
750 let Some(layer) = image
751 .layers
752 .into_iter()
753 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
754 else {
755 return Ok(None);
756 };
757 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
758 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
759 validate_manifest(&manifest)?;
760 Ok(Some(manifest))
761 }
762}
763
764async fn load_source_manifest(
765 repo: &str,
766 tag: &str,
767 token: Option<&str>,
768) -> Result<Option<ToolchainManifest>> {
769 OciToolchainManifestSource
770 .load_manifest(repo, tag, token)
771 .await
772}
773
774fn oci_client() -> Client {
775 Client::new(ClientConfig {
776 protocol: ClientProtocol::Https,
777 ..Default::default()
778 })
779}
780
781fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
782 let token = resolve_registry_token(raw_token)?
783 .or_else(|| std::env::var("GHCR_TOKEN").ok())
784 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
785 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
786 if token.trim().is_empty() {
787 bail!("GHCR token is empty");
788 }
789 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
790}
791
792fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
793 match registry_auth(raw_token) {
794 Ok(auth) => Ok(auth),
795 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
796 Err(err) => Err(err),
797 }
798}
799
800fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
801 let Some(raw_token) = raw_token else {
802 return Ok(None);
803 };
804 if let Some(var) = raw_token.strip_prefix("env:") {
805 let token =
806 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
807 if token.trim().is_empty() {
808 bail!("env var {var} resolved to an empty token");
809 }
810 return Ok(Some(token));
811 }
812 if raw_token.trim().is_empty() {
813 bail!("GHCR token is empty");
814 }
815 Ok(Some(raw_token.to_string()))
816}
817
818fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
819 Reference::from_str(&toolchain_ref(repo, tag))
820 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
821}
822
823async fn manifest_exists(
824 client: &Client,
825 reference: &Reference,
826 auth: &RegistryAuth,
827) -> Result<bool> {
828 match client.pull_manifest(reference, auth).await {
829 Ok(_) => Ok(true),
830 Err(err) if is_missing_manifest_error(&err) => Ok(false),
831 Err(err) => Err(err).context("failed to check whether release tag exists"),
832 }
833}
834
835fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
836 let msg = err.to_string().to_ascii_lowercase();
837 msg.contains("manifest unknown")
838 || msg.contains("name unknown")
839 || msg.contains("not found")
840 || msg.contains("404")
841}
842
843fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
844 let msg = err.to_string().to_ascii_lowercase();
845 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
846}
847
848async fn push_manifest_layer(
849 client: &Client,
850 reference: &Reference,
851 auth: &RegistryAuth,
852 manifest: &ToolchainManifest,
853) -> Result<()> {
854 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
855 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
856 let config = Config::new(
857 br#"{"toolchain":"gtc"}"#.to_vec(),
858 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
859 None,
860 );
861 client
862 .push(reference, &[layer], config, auth, None)
863 .await
864 .context("failed to push toolchain manifest")?;
865 Ok(())
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use once_cell::sync::Lazy;
872 use std::sync::Mutex;
873
874 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
875
876 struct FixedResolver;
877
878 impl CrateVersionResolver for FixedResolver {
879 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
880 Ok(match crate_name {
881 "greentic-runner" => "0.5.10",
882 _ => "1.2.3",
883 }
884 .to_string())
885 }
886 }
887
888 #[test]
889 fn parses_crates_io_max_stable_version() {
890 let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
891 let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
892 assert_eq!(version, "0.5.123");
893 }
894
895 #[test]
896 fn parses_crates_io_falls_back_to_newest_version() {
897 let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
898 let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
899 assert_eq!(version, "0.6.7");
900 }
901
902 #[test]
903 fn parses_crates_io_falls_back_to_max_version() {
904 let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
905 let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
906 assert_eq!(version, "0.4.99");
907 }
908
909 #[test]
910 fn rejects_crates_io_payload_without_versions() {
911 let body = r#"{"crate":{"id":"greentic-dev"}}"#;
912 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
913 assert!(
914 err.to_string()
915 .contains("does not expose max_stable_version")
916 );
917 }
918
919 #[test]
920 fn rejects_crates_io_payload_with_unparseable_version() {
921 let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
922 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
923 assert!(err.to_string().contains("unparseable version"));
924 }
925
926 #[test]
927 fn rejects_crates_io_payload_without_crate_object() {
928 let body = r#"{"errors":[{"detail":"not found"}]}"#;
929 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
930 assert!(
931 err.to_string()
932 .contains("missing the top-level `crate` object")
933 );
934 }
935
936 #[test]
937 fn generates_manifest_from_catalogue() {
938 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
939 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
940 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
941 assert_eq!(manifest.version, "1.0.5");
942 assert_eq!(manifest.channel.as_deref(), Some("latest"));
943 assert!(
944 manifest
945 .packages
946 .iter()
947 .any(|package| package.crate_name == "greentic-bundle"
948 && package.bins == ["greentic-bundle"])
949 );
950 assert!(
951 manifest
952 .packages
953 .iter()
954 .any(|package| package.crate_name == "greentic-runner"
955 && package.bins == ["greentic-runner"])
956 );
957 }
958
959 #[test]
960 fn source_manifest_can_pin_package_versions() {
961 let source = ToolchainManifest {
962 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
963 toolchain: TOOLCHAIN_NAME.to_string(),
964 version: "latest".to_string(),
965 channel: Some("latest".to_string()),
966 created_at: None,
967 packages: vec![ToolchainPackage {
968 crate_name: "greentic-dev".to_string(),
969 bins: vec!["greentic-dev".to_string()],
970 version: "0.5.9".to_string(),
971 }],
972 };
973 let manifest =
974 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
975 let greentic_dev = manifest
976 .packages
977 .iter()
978 .find(|package| package.crate_name == "greentic-dev")
979 .unwrap();
980 assert_eq!(greentic_dev.version, "0.5.9");
981 }
982
983 #[test]
984 fn from_argument_controls_generated_channel_over_source_manifest() {
985 let source = ToolchainManifest {
986 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
987 toolchain: TOOLCHAIN_NAME.to_string(),
988 version: "latest".to_string(),
989 channel: Some("stable".to_string()),
990 created_at: None,
991 packages: Vec::new(),
992 };
993 let manifest =
994 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
995 assert_eq!(manifest.channel.as_deref(), Some("dev"));
996 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
997 }
998
999 #[test]
1000 fn generate_from_dev_uses_dev_crate_and_binary_names() {
1001 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1002 assert!(
1003 manifest
1004 .packages
1005 .iter()
1006 .flat_map(|package| package.bins.iter())
1007 .all(|bin| bin.ends_with("-dev"))
1008 );
1009 assert!(
1010 manifest
1011 .packages
1012 .iter()
1013 .all(|package| package.crate_name.ends_with("-dev")),
1014 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1015 );
1016 assert!(manifest.packages.iter().any(|package| {
1017 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1018 }));
1019 assert!(manifest.packages.iter().any(|package| {
1020 package.crate_name == "greentic-component-dev"
1021 && package.bins == ["greentic-component-dev"]
1022 }));
1023 assert!(manifest.packages.iter().any(|package| {
1024 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1025 }));
1026 }
1027
1028 #[test]
1029 fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1030 let manifest =
1031 snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1032 .unwrap();
1033 assert_eq!(manifest.version, "1.1.5");
1034 assert_eq!(manifest.channel.as_deref(), Some("dev"));
1035 for package in &manifest.packages {
1036 assert!(
1037 package.crate_name.ends_with("-dev"),
1038 "dev snapshot must pin -dev crate names; got {}",
1039 package.crate_name
1040 );
1041 assert!(
1042 package.bins.iter().all(|bin| bin.ends_with("-dev")),
1043 "dev snapshot must pin -dev bin names; got {:?}",
1044 package.bins
1045 );
1046 assert_ne!(
1047 package.version, "latest",
1048 "snapshot must always resolve concrete versions"
1049 );
1050 }
1051 assert!(
1052 manifest
1053 .packages
1054 .iter()
1055 .any(|package| package.crate_name == "greentic-operator-dev")
1056 );
1057 }
1058
1059 #[test]
1060 fn snapshot_manifest_stable_channel_keeps_plain_names() {
1061 let manifest =
1062 snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1063 assert_eq!(manifest.channel.as_deref(), Some("stable"));
1064 let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1068 .iter()
1069 .map(|spec| spec.crate_name)
1070 .collect();
1071 for package in &manifest.packages {
1072 assert!(
1073 catalogue_names.contains(package.crate_name.as_str()),
1074 "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1075 package.crate_name
1076 );
1077 }
1078 }
1079
1080 #[test]
1081 fn snapshot_manifest_resolves_via_resolver() {
1082 let manifest =
1083 snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1084 .unwrap();
1085 for package in &manifest.packages {
1091 assert_eq!(
1092 package.version, "1.2.3",
1093 "resolver must be hit for {}",
1094 package.crate_name
1095 );
1096 }
1097 }
1098
1099 #[test]
1100 fn parses_dev_channel_argument() {
1101 assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1102 assert_eq!(
1103 parse_channel("development").unwrap(),
1104 ToolchainChannel::Development
1105 );
1106 assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1107 assert!(parse_channel("rc").is_err());
1108 }
1109
1110 #[test]
1111 fn detects_concrete_pins_for_publish_deprecation_warning() {
1112 let with_pins = ToolchainManifest {
1113 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1114 toolchain: TOOLCHAIN_NAME.to_string(),
1115 version: "0.0.1".to_string(),
1116 channel: Some("dev".to_string()),
1117 created_at: None,
1118 packages: vec![ToolchainPackage {
1119 crate_name: "greentic-operator-dev".to_string(),
1120 bins: vec!["greentic-operator-dev".to_string()],
1121 version: "0.5.123".to_string(),
1122 }],
1123 };
1124 assert!(source_manifest_has_concrete_pins(&with_pins));
1125
1126 let only_latest = ToolchainManifest {
1127 packages: vec![ToolchainPackage {
1128 crate_name: "greentic-operator".to_string(),
1129 bins: vec!["greentic-operator".to_string()],
1130 version: "latest".to_string(),
1131 }],
1132 ..with_pins
1133 };
1134 assert!(!source_manifest_has_concrete_pins(&only_latest));
1135 }
1136
1137 #[test]
1138 fn bootstrap_source_manifest_uses_source_tag_identity() {
1139 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1140 assert_eq!(manifest.version, "latest");
1141 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1142 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1143 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1144 assert!(
1145 manifest
1146 .packages
1147 .iter()
1148 .all(|package| package.version != "latest")
1149 );
1150 }
1151
1152 #[test]
1153 fn validates_schema_and_toolchain() {
1154 let mut manifest =
1155 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1156 assert!(validate_manifest(&manifest).is_ok());
1157 manifest.schema = "wrong".to_string();
1158 assert!(validate_manifest(&manifest).is_err());
1159 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1160 manifest.toolchain = "other".to_string();
1161 assert!(validate_manifest(&manifest).is_err());
1162 }
1163
1164 #[test]
1165 fn resolves_inline_registry_token() {
1166 assert_eq!(
1167 resolve_registry_token(Some("secret-token"))
1168 .unwrap()
1169 .as_deref(),
1170 Some("secret-token")
1171 );
1172 }
1173
1174 #[test]
1175 fn resolves_registry_token_from_environment_reference() {
1176 let _guard = ENV_LOCK.lock().unwrap();
1177 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1178 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1179
1180 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1181 assert_eq!(resolved.as_deref(), Some("env-secret"));
1182
1183 match previous {
1184 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1185 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1186 }
1187 }
1188
1189 #[test]
1190 fn rejects_empty_registry_token_from_environment_reference() {
1191 let _guard = ENV_LOCK.lock().unwrap();
1192 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1193 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
1194
1195 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1196 assert!(err.to_string().contains("resolved to an empty token"));
1197
1198 match previous {
1199 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1200 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1201 }
1202 }
1203
1204 #[test]
1205 fn registry_auth_uses_environment_fallbacks() {
1206 let _guard = ENV_LOCK.lock().unwrap();
1207 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1208 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1209 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1210 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1211
1212 let auth = registry_auth(None).unwrap();
1213 match auth {
1214 RegistryAuth::Basic(user, token) => {
1215 assert_eq!(user, DEFAULT_OAUTH_USER);
1216 assert_eq!(token, "ghcr-secret");
1217 }
1218 _ => panic!("expected basic auth"),
1219 }
1220
1221 match previous_ghcr {
1222 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1223 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1224 }
1225 match previous_github {
1226 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1227 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1228 }
1229 }
1230
1231 #[test]
1232 fn optional_registry_auth_allows_missing_implicit_token() {
1233 let _guard = ENV_LOCK.lock().unwrap();
1234 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1235 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1236 unsafe { std::env::remove_var("GHCR_TOKEN") };
1237 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1238
1239 let auth = optional_registry_auth(None).unwrap();
1240 assert!(matches!(auth, RegistryAuth::Anonymous));
1241
1242 match previous_ghcr {
1243 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1244 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1245 }
1246 match previous_github {
1247 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1248 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1249 }
1250 }
1251
1252 #[test]
1253 fn release_view_tag_prefers_release_or_tag() {
1254 let args = ReleaseViewArgs {
1255 release: Some("1.0.5".to_string()),
1256 tag: None,
1257 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1258 token: None,
1259 };
1260 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1261
1262 let args = ReleaseViewArgs {
1263 release: None,
1264 tag: Some("stable".to_string()),
1265 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1266 token: None,
1267 };
1268 assert_eq!(release_view_tag(&args).unwrap(), "stable");
1269 }
1270
1271 #[test]
1272 fn release_view_tag_rejects_invalid_argument_combinations() {
1273 let err = release_view_tag(&ReleaseViewArgs {
1274 release: Some("1.0.5".to_string()),
1275 tag: Some("stable".to_string()),
1276 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1277 token: None,
1278 })
1279 .unwrap_err();
1280 assert!(
1281 err.to_string()
1282 .contains("pass exactly one of --release or --tag")
1283 );
1284
1285 let err = release_view_tag(&ReleaseViewArgs {
1286 release: None,
1287 tag: None,
1288 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1289 token: None,
1290 })
1291 .unwrap_err();
1292 assert!(
1293 err.to_string()
1294 .contains("pass exactly one of --release or --tag")
1295 );
1296 }
1297
1298 #[test]
1299 fn publish_manifest_input_uses_local_manifest_version() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let path = dir.path().join("gtc-1.0.12.json");
1302 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1303 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1304 let args = ReleasePublishArgs {
1305 release: None,
1306 from: None,
1307 tag: Some("stable".to_string()),
1308 manifest: Some(path.clone()),
1309 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1310 token: None,
1311 out: dir.path().to_path_buf(),
1312 dry_run: true,
1313 force: true,
1314 };
1315 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1316 assert_eq!(release, "1.0.12");
1317 assert_eq!(loaded, manifest);
1318 assert_eq!(
1319 source_path,
1320 Some(PublishManifestSource::Local(path.clone()))
1321 );
1322 }
1323
1324 #[test]
1325 fn publish_manifest_input_allows_release_override_for_local_manifest() {
1326 let dir = tempfile::tempdir().unwrap();
1327 let path = dir.path().join("gtc-1.0.13.json");
1328 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1329 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1330 let args = ReleasePublishArgs {
1331 release: Some("1.0.13".to_string()),
1332 from: None,
1333 tag: Some("stable".to_string()),
1334 manifest: Some(path.clone()),
1335 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1336 token: None,
1337 out: dir.path().to_path_buf(),
1338 dry_run: true,
1339 force: true,
1340 };
1341 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1342 assert_eq!(release, "1.0.13");
1343 assert_eq!(loaded.version, "1.0.13");
1344 assert_eq!(
1345 source_path,
1346 Some(PublishManifestSource::Local(path.clone()))
1347 );
1348 }
1349
1350 #[test]
1351 fn manifest_file_name_omits_stable_channel() {
1352 let manifest = ToolchainManifest {
1353 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1354 toolchain: TOOLCHAIN_NAME.to_string(),
1355 version: "1.0.12".to_string(),
1356 channel: Some("stable".to_string()),
1357 created_at: None,
1358 packages: Vec::new(),
1359 };
1360 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1361 }
1362
1363 #[test]
1364 fn manifest_file_name_includes_non_stable_channel() {
1365 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1366 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1367
1368 manifest.channel = Some("customer-a".to_string());
1369 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1370 }
1371
1372 #[test]
1373 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
1374 assert_eq!(
1375 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
1376 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
1377 );
1378 assert_eq!(
1379 manifest_bins_for_source("dev", &["greentic-dev"]),
1380 vec!["greentic-dev-dev".to_string()]
1381 );
1382 assert_eq!(
1383 manifest_crate_name_for_source("latest", "greentic-runner"),
1384 "greentic-runner"
1385 );
1386 assert_eq!(
1387 manifest_crate_name_for_source("dev", "greentic-runner"),
1388 "greentic-runner-dev"
1389 );
1390 }
1391
1392 #[test]
1393 fn source_version_map_handles_missing_and_present_sources() {
1394 assert!(source_version_map(None).is_empty());
1395
1396 let source = ToolchainManifest {
1397 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1398 toolchain: TOOLCHAIN_NAME.to_string(),
1399 version: "latest".to_string(),
1400 channel: Some("latest".to_string()),
1401 created_at: None,
1402 packages: vec![ToolchainPackage {
1403 crate_name: "greentic-dev".to_string(),
1404 bins: vec!["greentic-dev".to_string()],
1405 version: "0.6.0".to_string(),
1406 }],
1407 };
1408
1409 let versions = source_version_map(Some(&source));
1410 assert_eq!(
1411 versions.get("greentic-dev").map(String::as_str),
1412 Some("0.6.0")
1413 );
1414 }
1415
1416 #[test]
1417 fn write_manifest_persists_json_to_expected_file_name() {
1418 let dir = tempfile::tempdir().unwrap();
1419 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1420
1421 let path = write_manifest(dir.path(), &manifest).unwrap();
1422 assert_eq!(
1423 path.file_name().and_then(|name| name.to_str()),
1424 Some("gtc-dev-1.0.12.json")
1425 );
1426
1427 let roundtrip = read_manifest_file(&path).unwrap();
1428 assert_eq!(roundtrip, manifest);
1429 }
1430
1431 #[test]
1432 fn latest_manifest_uses_latest_dev_bins() {
1433 let manifest = latest_manifest(None);
1434 assert_eq!(manifest.version, "latest");
1435 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1436 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1437 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1438 assert!(!manifest.packages.is_empty());
1439 assert!(
1440 manifest
1441 .packages
1442 .iter()
1443 .all(|package| package.version == "latest")
1444 );
1445 assert!(
1446 manifest
1447 .packages
1448 .iter()
1449 .flat_map(|package| package.bins.iter())
1450 .all(|bin| bin.ends_with("-dev"))
1451 );
1452 assert!(
1453 manifest
1454 .packages
1455 .iter()
1456 .all(|package| package.crate_name.ends_with("-dev")),
1457 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
1458 );
1459 assert!(
1460 manifest
1461 .packages
1462 .iter()
1463 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
1464 );
1465 assert!(manifest.packages.iter().any(|package| {
1466 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1467 }));
1468 }
1469
1470 #[test]
1471 fn publish_dry_run_with_local_manifest_succeeds() {
1472 let dir = tempfile::tempdir().unwrap();
1473 let path = dir.path().join("gtc-1.0.12.json");
1474 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1475 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1476
1477 publish(ReleasePublishArgs {
1478 release: None,
1479 from: None,
1480 tag: Some("stable".to_string()),
1481 manifest: Some(path),
1482 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1483 token: None,
1484 out: dir.path().to_path_buf(),
1485 dry_run: true,
1486 force: false,
1487 })
1488 .unwrap();
1489 }
1490
1491 #[test]
1492 fn latest_dry_run_succeeds() {
1493 latest(ReleaseLatestArgs {
1494 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1495 token: None,
1496 dry_run: true,
1497 force: false,
1498 })
1499 .unwrap();
1500 }
1501
1502 #[test]
1503 fn promote_dry_run_succeeds() {
1504 promote(ReleasePromoteArgs {
1505 release: "1.0.12".to_string(),
1506 tag: "stable".to_string(),
1507 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1508 token: None,
1509 dry_run: true,
1510 })
1511 .unwrap();
1512 }
1513
1514 #[test]
1515 fn builds_toolchain_ref() {
1516 assert_eq!(
1517 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1518 "ghcr.io/greenticai/greentic-versions/gtc:stable"
1519 );
1520 }
1521}