Skip to main content

greentic_dev/
release_cmd.rs

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
433/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
434/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
435/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
436/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
437/// the stable one. Reuses `delegated_binary_name_for_channel` because the
438/// rule is identical for crates and binaries (`-dev` suffix, with the
439/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
440fn 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}