Skip to main content

greentic_dev/
release_cmd.rs

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::{
23    GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
24    OciPackageSpec,
25};
26
27const DEFAULT_OAUTH_USER: &str = "oauth2";
28pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
29pub const TOOLCHAIN_NAME: &str = "gtc";
30pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
31const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ToolchainManifest {
35    pub schema: String,
36    pub toolchain: String,
37    pub version: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub channel: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub created_at: Option<String>,
42    pub packages: Vec<ToolchainPackage>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub extension_packs: Option<Vec<ExtensionPackRef>>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub components: Option<Vec<ComponentRef>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ToolchainPackage {
51    #[serde(rename = "crate")]
52    pub crate_name: String,
53    pub bins: Vec<String>,
54    pub version: String,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ExtensionPackRef {
59    pub id: String,
60    pub version: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ComponentRef {
65    pub id: String,
66    pub version: String,
67}
68
69pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
70    let resolver = default_resolver();
71    let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
72    let source = block_on_maybe_runtime(load_source_manifest(
73        &args.repo,
74        &args.from,
75        args.token.as_deref(),
76    ))
77    .with_context(|| {
78        format!(
79            "failed to resolve source manifest `{}`",
80            toolchain_ref(&args.repo, &args.from)
81        )
82    })?;
83    let source = match source {
84        Some(source) => Some(source),
85        None => bootstrap_source_manifest_if_needed(
86            &args.repo,
87            &args.from,
88            args.token.as_deref(),
89            args.dry_run,
90            &resolver,
91        )?,
92    };
93    let manifest = generate_manifest_with_artifact_resolver(
94        &args.release,
95        &args.from,
96        source.as_ref(),
97        &resolver,
98        &artifact_resolver,
99        Some(created_at_now()?),
100    )?;
101    if args.dry_run {
102        println!("{}", serde_json::to_string_pretty(&manifest)?);
103        return Ok(());
104    }
105    let path = write_manifest(&args.out, &manifest)?;
106    println!("Wrote {}", path.display());
107    Ok(())
108}
109
110fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
111    repo: &str,
112    tag: &str,
113    token: Option<&str>,
114    dry_run: bool,
115    resolver: &R,
116) -> Result<Option<ToolchainManifest>> {
117    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
118    if dry_run {
119        eprintln!(
120            "Dry run: would bootstrap missing source manifest {}",
121            toolchain_ref(repo, tag)
122        );
123        return Ok(Some(manifest));
124    }
125
126    let auth = match optional_registry_auth(token)? {
127        RegistryAuth::Anonymous => {
128            eprintln!(
129                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
130                toolchain_ref(repo, tag)
131            );
132            return Ok(Some(manifest));
133        }
134        auth => auth,
135    };
136    block_on_maybe_runtime(async {
137        let client = oci_client();
138        let source_ref = parse_reference(repo, tag)?;
139        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
140    })
141    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
142    println!("Bootstrapped {}", toolchain_ref(repo, tag));
143    Ok(Some(manifest))
144}
145
146fn bootstrap_source_manifest<R: CrateVersionResolver>(
147    tag: &str,
148    resolver: &R,
149    created_at: Option<String>,
150) -> Result<ToolchainManifest> {
151    generate_manifest(tag, tag, None, resolver, created_at)
152}
153
154pub fn publish(args: ReleasePublishArgs) -> Result<()> {
155    let (release, manifest, source) = publish_manifest_input(&args)?;
156
157    if args.dry_run {
158        println!(
159            "Dry run: would publish {}",
160            toolchain_ref(&args.repo, &release)
161        );
162        if let Some(tag) = &args.tag {
163            println!(
164                "Dry run: would tag {} as {}",
165                toolchain_ref(&args.repo, &release),
166                toolchain_ref(&args.repo, tag)
167            );
168        }
169        return Ok(());
170    }
171
172    let auth = registry_auth(args.token.as_deref())?;
173    block_on_maybe_runtime(async {
174        let client = oci_client();
175        let release_ref = parse_reference(&args.repo, &release)?;
176        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
177            bail!(
178                "release tag `{}` already exists; pass --force to overwrite it",
179                toolchain_ref(&args.repo, &release)
180            );
181        }
182        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
183        if let Some(tag) = &args.tag {
184            let tag_ref = parse_reference(&args.repo, tag)?;
185            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
186        }
187        Ok(())
188    })?;
189
190    if let Some(source) = source {
191        match source {
192            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
193            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
194        }
195    }
196    println!("Published {}", toolchain_ref(&args.repo, &release));
197    if let Some(tag) = &args.tag {
198        println!("Updated {}", toolchain_ref(&args.repo, tag));
199    }
200
201    if !args.no_notify_updater {
202        let channel = manifest.channel.as_deref().unwrap_or("");
203        notify_updater_dispatch(&release, channel, args.token.as_deref());
204    }
205
206    Ok(())
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210enum PublishManifestSource {
211    Generated(PathBuf),
212    Local(PathBuf),
213}
214
215fn publish_manifest_input(
216    args: &ReleasePublishArgs,
217) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
218    if let Some(path) = &args.manifest {
219        let mut manifest = read_manifest_file(path)?;
220        validate_manifest(&manifest)?;
221        let release = if let Some(release) = &args.release {
222            manifest.version = release.clone();
223            release.clone()
224        } else {
225            manifest.version.clone()
226        };
227        return Ok((
228            release,
229            manifest,
230            Some(PublishManifestSource::Local(path.clone())),
231        ));
232    }
233
234    let release = args
235        .release
236        .as_deref()
237        .context("pass --release or --manifest")?;
238    let from = args.from.as_deref().unwrap_or("latest");
239    let resolver = default_resolver();
240    let source = block_on_maybe_runtime(load_source_manifest(
241        &args.repo,
242        from,
243        args.token.as_deref(),
244    ))
245    .with_context(|| {
246        format!(
247            "failed to resolve source manifest `{}`",
248            toolchain_ref(&args.repo, from)
249        )
250    })?;
251    if let Some(source_manifest) = source.as_ref()
252        && source_manifest_has_concrete_pins(source_manifest)
253    {
254        eprintln!(
255            "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
256             of querying crates.io. To refresh a channel from the latest crates.io versions, use \
257             `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
258             re-resolving, use `release promote`. The conflated `--from` semantics will be \
259             removed in a future release.",
260            toolchain_ref(&args.repo, from),
261        );
262    }
263    let manifest = generate_manifest(
264        release,
265        from,
266        source.as_ref(),
267        &resolver,
268        Some(created_at_now()?),
269    )?;
270    let path = if args.dry_run {
271        println!("{}", serde_json::to_string_pretty(&manifest)?);
272        None
273    } else {
274        Some(PublishManifestSource::Generated(write_manifest(
275            &args.out, &manifest,
276        )?))
277    };
278    Ok((release.to_string(), manifest, path))
279}
280
281/// True when the source manifest has at least one package pinned to a concrete
282/// (non-`"latest"`) version. Used to detect the case where `release publish
283/// --from <X>` would silently copy old pins instead of re-resolving — see the
284/// deprecation warning emitted from `publish_manifest_input`.
285fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
286    manifest
287        .packages
288        .iter()
289        .any(|package| package.version != "latest")
290}
291
292fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
293    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
294    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
295}
296
297pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
298    if args.dry_run {
299        println!(
300            "Dry run: would promote {} to {}",
301            toolchain_ref(&args.repo, &args.release),
302            toolchain_ref(&args.repo, &args.tag)
303        );
304        return Ok(());
305    }
306
307    let auth = registry_auth(args.token.as_deref())?;
308    block_on_maybe_runtime(async {
309        let client = oci_client();
310        let source_ref = parse_reference(&args.repo, &args.release)?;
311        let target_ref = parse_reference(&args.repo, &args.tag)?;
312        let (manifest, _) = client
313            .pull_manifest(&source_ref, &auth)
314            .await
315            .with_context(|| {
316                format!(
317                    "failed to resolve source release `{}`",
318                    toolchain_ref(&args.repo, &args.release)
319                )
320            })?;
321        client
322            .push_manifest(&target_ref, &manifest)
323            .await
324            .with_context(|| {
325                format!(
326                    "failed to update tag `{}`",
327                    toolchain_ref(&args.repo, &args.tag)
328                )
329            })?;
330        Ok(())
331    })?;
332    println!(
333        "Promoted {} to {}",
334        toolchain_ref(&args.repo, &args.release),
335        toolchain_ref(&args.repo, &args.tag)
336    );
337
338    if !args.no_notify_updater {
339        // Promote copies the OCI manifest (tag alias) but does not load the
340        // toolchain layer. Load it now to read the channel for the stable-lane
341        // gate. If loading fails, skip the dispatch — the workflow has its own
342        // channel guard as a backstop.
343        let channel = block_on_maybe_runtime(load_source_manifest(
344            &args.repo,
345            &args.release,
346            args.token.as_deref(),
347        ))
348        .ok()
349        .flatten()
350        .and_then(|m| m.channel);
351        match channel.as_deref() {
352            Some(ch) => notify_updater_dispatch(&args.release, ch, args.token.as_deref()),
353            None => {
354                eprintln!(
355                    "Skipping updater dispatch: could not determine channel \
356                     for {} (use workflow_dispatch as fallback)",
357                    toolchain_ref(&args.repo, &args.release)
358                );
359            }
360        }
361    }
362
363    Ok(())
364}
365
366/// Snapshot the current crates.io state into a new toolchain manifest.
367///
368/// Unlike `publish --from <X>`, snapshot **never** reads an existing manifest
369/// and **always** queries the resolver. That makes it safe to call repeatedly
370/// to refresh a channel — `:dev` after each nightly publish, `:stable` after
371/// a weekly release — without the promote-vs-snapshot conflation that bit
372/// callers of `publish --from dev`.
373pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
374    let channel = parse_channel(&args.channel)?;
375    let resolver = CratesIoApiVersionResolver::default();
376    let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
377
378    if args.dry_run {
379        println!("{}", serde_json::to_string_pretty(&manifest)?);
380        println!(
381            "Dry run: would publish {}",
382            toolchain_ref(&args.repo, &args.release)
383        );
384        if let Some(tag) = &args.tag {
385            println!(
386                "Dry run: would tag {} as {}",
387                toolchain_ref(&args.repo, &args.release),
388                toolchain_ref(&args.repo, tag)
389            );
390        }
391        return Ok(());
392    }
393
394    let path = write_manifest(&args.out, &manifest)?;
395    println!("Wrote {}", path.display());
396
397    let auth = registry_auth(args.token.as_deref())?;
398    block_on_maybe_runtime(async {
399        let client = oci_client();
400        let release_ref = parse_reference(&args.repo, &args.release)?;
401        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
402            bail!(
403                "release tag `{}` already exists; pass --force to overwrite it",
404                toolchain_ref(&args.repo, &args.release)
405            );
406        }
407        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
408        if let Some(tag) = &args.tag {
409            let tag_ref = parse_reference(&args.repo, tag)?;
410            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
411        }
412        Ok(())
413    })?;
414    println!("Published {}", toolchain_ref(&args.repo, &args.release));
415    if let Some(tag) = &args.tag {
416        println!("Updated {}", toolchain_ref(&args.repo, tag));
417    }
418
419    if !args.no_notify_updater {
420        let ch = channel_tag(channel);
421        notify_updater_dispatch(&args.release, ch, args.token.as_deref());
422    }
423
424    Ok(())
425}
426
427fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
428    match channel {
429        "dev" | "development" => Ok(ToolchainChannel::Development),
430        "rnd" | "research" => Ok(ToolchainChannel::Rnd),
431        "stable" => Ok(ToolchainChannel::Stable),
432        other => bail!(
433            "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
434             pass --channel dev for the dev lane, --channel research for the research lane, or \
435             --channel stable for the stable lane"
436        ),
437    }
438}
439
440fn channel_tag(channel: ToolchainChannel) -> &'static str {
441    match channel {
442        ToolchainChannel::Stable => "stable",
443        ToolchainChannel::Development => "dev",
444        ToolchainChannel::Rnd => "rnd",
445    }
446}
447
448/// Resolve the version for a single toolchain manifest entry on `channel`.
449/// Returns `Ok(None)` when the research channel has no `-rnd` build for the
450/// crate (skip it) so manifest assembly does not abort on the ~10 of 13
451/// toolchain crates that ship no research build.
452fn resolve_manifest_version<R: CrateVersionResolver>(
453    resolver: &R,
454    crate_in_manifest: &str,
455    channel: ToolchainChannel,
456) -> Result<Option<String>> {
457    if channel == ToolchainChannel::Rnd {
458        match resolver
459            .resolve_research_version(crate_in_manifest)
460            .with_context(|| {
461                format!("failed to resolve research version for `{crate_in_manifest}`")
462            })? {
463            ResearchVersion::Pinned(version) => Ok(Some(version)),
464            ResearchVersion::Absent => {
465                eprintln!(
466                    "note: `{crate_in_manifest}` has no research build on crates.io; \
467                     omitting it from the research toolchain manifest"
468                );
469                Ok(None)
470            }
471        }
472    } else {
473        resolver
474            .resolve_latest_for_channel(crate_in_manifest, channel)
475            .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
476            .map(Some)
477    }
478}
479
480pub fn snapshot_manifest<R: CrateVersionResolver>(
481    release: &str,
482    channel: ToolchainChannel,
483    resolver: &R,
484    created_at: Option<String>,
485) -> Result<ToolchainManifest> {
486    let from = channel_tag(channel);
487    let mut packages = Vec::new();
488    for package in GREENTIC_TOOLCHAIN_PACKAGES {
489        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
490        let Some(version) = resolve_manifest_version(resolver, &crate_in_manifest, channel)? else {
491            continue;
492        };
493        packages.push(ToolchainPackage {
494            crate_name: crate_in_manifest,
495            bins: manifest_bins_for_source(from, package.bins),
496            version,
497        });
498    }
499    Ok(ToolchainManifest {
500        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
501        toolchain: TOOLCHAIN_NAME.to_string(),
502        version: release.to_string(),
503        channel: Some(from.to_string()),
504        created_at,
505        packages,
506        extension_packs: None,
507        components: None,
508    })
509}
510
511pub fn view(args: ReleaseViewArgs) -> Result<()> {
512    let tag = release_view_tag(&args)?;
513    let manifest = block_on_maybe_runtime(load_source_manifest(
514        &args.repo,
515        &tag,
516        args.token.as_deref(),
517    ))
518    .with_context(|| {
519        format!(
520            "failed to resolve manifest `{}`",
521            toolchain_ref(&args.repo, &tag)
522        )
523    })?
524    .with_context(|| {
525        format!(
526            "manifest `{}` was not found or is not authorized for this token",
527            toolchain_ref(&args.repo, &tag)
528        )
529    })?;
530    println!("{}", serde_json::to_string_pretty(&manifest)?);
531    Ok(())
532}
533
534pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
535    let manifest = latest_manifest(Some(created_at_now()?));
536    if args.dry_run {
537        println!("{}", serde_json::to_string_pretty(&manifest)?);
538        println!(
539            "Dry run: would publish {}",
540            toolchain_ref(&args.repo, "latest")
541        );
542        return Ok(());
543    }
544
545    let auth = registry_auth(args.token.as_deref())?;
546    block_on_maybe_runtime(async {
547        let client = oci_client();
548        let latest_ref = parse_reference(&args.repo, "latest")?;
549        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
550            bail!(
551                "latest tag `{}` already exists; pass --force to overwrite it",
552                toolchain_ref(&args.repo, "latest")
553            );
554        }
555        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
556    })?;
557    println!("Published {}", toolchain_ref(&args.repo, "latest"));
558    Ok(())
559}
560
561fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
562    ToolchainManifest {
563        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
564        toolchain: TOOLCHAIN_NAME.to_string(),
565        version: "latest".to_string(),
566        channel: Some("latest".to_string()),
567        created_at,
568        packages: latest_manifest_packages(),
569        extension_packs: Some(
570            GREENTIC_EXTENSION_PACK_PACKAGES
571                .iter()
572                .map(|package| ExtensionPackRef {
573                    id: package.package.to_string(),
574                    version: "latest".to_string(),
575                })
576                .collect(),
577        ),
578        components: Some(
579            GREENTIC_COMPONENT_PACKAGES
580                .iter()
581                .map(|package| ComponentRef {
582                    id: package.package.to_string(),
583                    version: "latest".to_string(),
584                })
585                .collect(),
586        ),
587    }
588}
589
590fn latest_manifest_packages() -> Vec<ToolchainPackage> {
591    std::iter::once(ToolchainPackage {
592        crate_name: delegated_binary_name_for_channel(
593            TOOLCHAIN_NAME,
594            ToolchainChannel::Development,
595        ),
596        bins: vec![delegated_binary_name_for_channel(
597            TOOLCHAIN_NAME,
598            ToolchainChannel::Development,
599        )],
600        version: "latest".to_string(),
601    })
602    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
603        ToolchainPackage {
604            crate_name: delegated_binary_name_for_channel(
605                package.crate_name,
606                ToolchainChannel::Development,
607            ),
608            bins: package
609                .bins
610                .iter()
611                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
612                .collect(),
613            version: "latest".to_string(),
614        }
615    }))
616    .collect()
617}
618
619fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
620    match (&args.release, &args.tag) {
621        (Some(release), None) => Ok(release.clone()),
622        (None, Some(tag)) => Ok(tag.clone()),
623        _ => bail!("pass exactly one of --release or --tag"),
624    }
625}
626
627pub fn generate_manifest<R: CrateVersionResolver>(
628    release: &str,
629    from: &str,
630    source: Option<&ToolchainManifest>,
631    resolver: &R,
632    created_at: Option<String>,
633) -> Result<ToolchainManifest> {
634    let artifact_resolver = ReleaseArtifactVersionResolver { release };
635    generate_manifest_with_artifact_resolver(
636        release,
637        from,
638        source,
639        resolver,
640        &artifact_resolver,
641        created_at,
642    )
643}
644
645pub fn generate_manifest_with_artifact_resolver<R, A>(
646    release: &str,
647    from: &str,
648    source: Option<&ToolchainManifest>,
649    resolver: &R,
650    artifact_resolver: &A,
651    created_at: Option<String>,
652) -> Result<ToolchainManifest>
653where
654    R: CrateVersionResolver,
655    A: ArtifactVersionResolver,
656{
657    if let Some(source) = source {
658        validate_manifest(source)?;
659    }
660    let source_versions = source_version_map(source);
661    let mut packages = Vec::new();
662    for package in GREENTIC_TOOLCHAIN_PACKAGES {
663        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
664        let source_version = source_versions.get(&crate_in_manifest);
665        let version = match source_version.map(String::as_str) {
666            Some(version) if version != "latest" => Some(version.to_string()),
667            _ => resolve_manifest_version(
668                resolver,
669                &crate_in_manifest,
670                channel_from_source_tag(from),
671            )?,
672        };
673        let Some(version) = version else {
674            continue;
675        };
676        packages.push(ToolchainPackage {
677            crate_name: crate_in_manifest,
678            bins: manifest_bins_for_source(from, package.bins),
679            version,
680        });
681    }
682    Ok(ToolchainManifest {
683        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
684        toolchain: TOOLCHAIN_NAME.to_string(),
685        version: release.to_string(),
686        channel: Some(from.to_string()),
687        created_at,
688        packages,
689        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
690        components: Some(component_refs_for_release(source, artifact_resolver)?),
691    })
692}
693
694/// Map a manifest source-tag (`dev`/`rnd`/`stable`) to its channel.
695fn channel_from_source_tag(from: &str) -> ToolchainChannel {
696    match from {
697        "dev" => ToolchainChannel::Development,
698        "rnd" => ToolchainChannel::Rnd,
699        _ => ToolchainChannel::Stable,
700    }
701}
702
703fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
704    let channel = channel_from_source_tag(from);
705    bins.iter()
706        .map(|bin| delegated_binary_name_for_channel(bin, channel))
707        .collect()
708}
709
710fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
711    source: Option<&ToolchainManifest>,
712    artifact_resolver: &A,
713) -> Result<Vec<ExtensionPackRef>> {
714    let source_versions = source_ref_version_map(source.and_then(|manifest| {
715        manifest
716            .extension_packs
717            .as_ref()
718            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
719    }));
720    GREENTIC_EXTENSION_PACK_PACKAGES
721        .iter()
722        .map(|package| {
723            Ok(ExtensionPackRef {
724                id: package.package.to_string(),
725                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
726            })
727        })
728        .collect()
729}
730
731fn component_refs_for_release<A: ArtifactVersionResolver>(
732    source: Option<&ToolchainManifest>,
733    artifact_resolver: &A,
734) -> Result<Vec<ComponentRef>> {
735    let source_versions = source_ref_version_map(source.and_then(|manifest| {
736        manifest
737            .components
738            .as_ref()
739            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
740    }));
741    GREENTIC_COMPONENT_PACKAGES
742        .iter()
743        .map(|package| {
744            Ok(ComponentRef {
745                id: package.package.to_string(),
746                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
747            })
748        })
749        .collect()
750}
751
752fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
753where
754    I: Iterator<Item = (&'a String, &'a String)>,
755{
756    let mut out = BTreeMap::new();
757    if let Some(refs) = refs {
758        for (id, version) in refs {
759            out.insert(id.clone(), version.clone());
760        }
761    }
762    out
763}
764
765fn ref_version_for_package(
766    package: &OciPackageSpec,
767    source_versions: &BTreeMap<String, String>,
768    artifact_resolver: &impl ArtifactVersionResolver,
769) -> Result<String> {
770    match source_versions.get(package.package).map(String::as_str) {
771        Some(version) if version != "latest" => Ok(version.to_string()),
772        _ => artifact_resolver
773            .resolve_latest(package.package)
774            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
775    }
776}
777
778/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
779/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
780/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
781/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
782/// the stable one. Reuses `delegated_binary_name_for_channel` because the
783/// rule is identical for crates and binaries (`-dev` suffix, with the
784/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
785fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
786    if from == "dev" {
787        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
788    } else {
789        crate_name.to_string()
790    }
791}
792
793pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
794    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
795        bail!(
796            "unsupported toolchain manifest schema `{}`",
797            manifest.schema
798        );
799    }
800    if manifest.toolchain != TOOLCHAIN_NAME {
801        bail!("unsupported toolchain `{}`", manifest.toolchain);
802    }
803    Ok(())
804}
805
806pub fn toolchain_ref(repo: &str, tag: &str) -> String {
807    format!("{repo}:{tag}")
808}
809
810fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
811    let mut out = BTreeMap::new();
812    if let Some(source) = source {
813        for package in &source.packages {
814            out.insert(package.crate_name.clone(), package.version.clone());
815        }
816    }
817    out
818}
819
820fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
821    fs::create_dir_all(out_dir)
822        .with_context(|| format!("failed to create {}", out_dir.display()))?;
823    let path = out_dir.join(manifest_file_name(manifest));
824    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
825    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
826    Ok(path)
827}
828
829fn manifest_file_name(manifest: &ToolchainManifest) -> String {
830    match manifest.channel.as_deref() {
831        Some("stable") | None => format!("gtc-{}.json", manifest.version),
832        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
833    }
834}
835
836fn created_at_now() -> Result<String> {
837    OffsetDateTime::now_utc()
838        .format(&Rfc3339)
839        .context("failed to format current time")
840}
841
842/// Outcome of resolving the research (`-rnd`) version of a toolchain crate.
843///
844/// Only `start`/`runner`/`setup` carry `-research` builds; the other ~10
845/// delegated toolchain crates have no `<name>-rnd` published. Resolving those
846/// must not be a hard error — it is an expected "no research build" signal that
847/// the caller turns into a skip, so the research channel still assembles.
848pub enum ResearchVersion {
849    /// The `<name>-rnd` crate is published; pin this exact version.
850    Pinned(String),
851    /// The `<name>-rnd` crate is not published on crates.io (HTTP 404). The
852    /// tool ships no research build and must be skipped on the research channel
853    /// rather than aborting the whole install / manifest assembly.
854    Absent,
855}
856
857pub trait CrateVersionResolver {
858    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
859
860    /// Channel-aware resolution. The research (`rnd`) lane publishes base-name
861    /// crates at `X.Y.Z-research` PRERELEASES (greentic-runner's
862    /// research-publish.yml), which `resolve_latest`'s `max_stable_version`
863    /// preference silently skips — so the dev/stable behaviour returns the old
864    /// stable (e.g. `0.5.x`) instead of the current `1.2.0-research`. The
865    /// default delegates to `resolve_latest` (correct for dev/stable).
866    fn resolve_latest_for_channel(
867        &self,
868        crate_name: &str,
869        _channel: ToolchainChannel,
870    ) -> Result<String> {
871        self.resolve_latest(crate_name)
872    }
873
874    /// Resolve the research (`-rnd`) version, distinguishing an unpublished
875    /// crate (HTTP 404 → [`ResearchVersion::Absent`]) from a genuine resolution
876    /// error. The default treats every resolvable crate as
877    /// [`ResearchVersion::Pinned`]; only the crates.io resolver can observe a
878    /// 404, so it overrides this.
879    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
880        self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
881            .map(ResearchVersion::Pinned)
882    }
883}
884
885/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
886/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
887/// this is preferred over shelling out to `cargo search`.
888fn default_resolver() -> CratesIoApiVersionResolver {
889    CratesIoApiVersionResolver::default()
890}
891
892pub trait ArtifactVersionResolver {
893    fn resolve_latest(&self, package: &str) -> Result<String>;
894}
895
896const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
897const CRATES_IO_USER_AGENT: &str = concat!(
898    "greentic-dev/",
899    env!("CARGO_PKG_VERSION"),
900    " (https://github.com/greenticai/greentic-dev)"
901);
902
903/// Resolve the latest published version of a crate by hitting the crates.io
904/// HTTP API directly. Returns `max_stable_version` when present, falling back
905/// to `newest_version` and then `max_version`. Replaces an earlier
906/// `cargo search`-based resolver that ranked results by relevance and parsed
907/// stdout heuristically — both brittle for `<name>-dev` aliases that share
908/// prefixes with their stable parents.
909pub struct CratesIoApiVersionResolver {
910    base_url: String,
911    client: reqwest::blocking::Client,
912}
913
914impl Default for CratesIoApiVersionResolver {
915    fn default() -> Self {
916        Self::new(CRATES_IO_API_BASE)
917    }
918}
919
920impl CratesIoApiVersionResolver {
921    pub fn new(base_url: impl Into<String>) -> Self {
922        let client = reqwest::blocking::Client::builder()
923            .user_agent(CRATES_IO_USER_AGENT)
924            .build()
925            .expect("failed to build crates.io API client");
926        Self {
927            base_url: base_url.into(),
928            client,
929        }
930    }
931
932    /// GET the crates.io page for `crate_name`. `Ok(None)` when the crate is
933    /// absent (HTTP 404), `Ok(Some(body))` on success, `Err` on any other
934    /// status or transport failure. Lets callers treat "no such crate" as a
935    /// skip rather than a hard error.
936    fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
937        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
938        let response = self
939            .client
940            .get(&url)
941            .send()
942            .with_context(|| format!("failed to GET {url}"))?;
943        let status = response.status();
944        let body = response
945            .text()
946            .with_context(|| format!("failed to read body of {url}"))?;
947        classify_crate_response(status, &url, body)
948    }
949}
950
951/// Classify a crates.io crate-page response by HTTP status: `Ok(None)` for a
952/// 404 (crate absent), `Ok(Some(body))` for success, `Err` otherwise. Pure so
953/// the 404-vs-error decision is unit-testable without a live HTTP round-trip.
954fn classify_crate_response(
955    status: reqwest::StatusCode,
956    url: &str,
957    body: String,
958) -> Result<Option<String>> {
959    if status == reqwest::StatusCode::NOT_FOUND {
960        return Ok(None);
961    }
962    if !status.is_success() {
963        bail!("crates.io API GET {url} returned {status}: {body}");
964    }
965    Ok(Some(body))
966}
967
968/// Pick the research version from a crates.io body, or fall back to the latest
969/// published version when no `-research` prerelease exists. Toolchain crates
970/// with no `-research` publish yet keep their latest build so the snapshot still
971/// assembles; the multi-provider-critical crates (runner/setup/start) carry a
972/// `-research` build. The fallback is logged so silent staleness stays visible.
973fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
974    match parse_crates_io_research_version(crate_name, body) {
975        Ok(version) => Ok(version),
976        Err(_) => {
977            let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
978            eprintln!(
979                "note: `{crate_name}` has no -research publish; the research \
980                 toolchain falls back to latest `{fallback}`"
981            );
982            Ok(fallback)
983        }
984    }
985}
986
987struct ReleaseArtifactVersionResolver<'a> {
988    release: &'a str,
989}
990
991impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
992    fn resolve_latest(&self, _package: &str) -> Result<String> {
993        Ok(self.release.to_string())
994    }
995}
996
997struct GhcrArtifactVersionResolver {
998    client: reqwest::blocking::Client,
999    registry: String,
1000    namespace: String,
1001    basic_token: Option<String>,
1002}
1003
1004impl GhcrArtifactVersionResolver {
1005    fn new(raw_token: Option<&str>) -> Result<Self> {
1006        Ok(Self {
1007            client: reqwest::blocking::Client::builder()
1008                .build()
1009                .context("failed to build GHCR HTTP client")?,
1010            registry: "ghcr.io".to_string(),
1011            namespace: "greenticai".to_string(),
1012            basic_token: resolve_registry_token(raw_token)?
1013                .or_else(|| std::env::var("GHCR_TOKEN").ok())
1014                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
1015        })
1016    }
1017
1018    fn bearer_token(&self, repository: &str) -> Result<String> {
1019        let scope = format!("repository:{repository}:pull");
1020        let mut request = self
1021            .client
1022            .get(format!("https://{}/token", self.registry))
1023            .query(&[
1024                ("service", self.registry.as_str()),
1025                ("scope", scope.as_str()),
1026            ]);
1027        if let Some(token) = &self.basic_token {
1028            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
1029        }
1030        let response = request
1031            .send()
1032            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
1033            .error_for_status()
1034            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
1035        let body: GhcrTokenResponse = response
1036            .json()
1037            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1038        Ok(body.token)
1039    }
1040
1041    fn tags(&self, repository: &str) -> Result<Vec<String>> {
1042        let token = self.bearer_token(repository)?;
1043        let response = self
1044            .client
1045            .get(format!(
1046                "https://{}/v2/{repository}/tags/list",
1047                self.registry
1048            ))
1049            .bearer_auth(token)
1050            .send()
1051            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1052            .error_for_status()
1053            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1054        let body: GhcrTagsResponse = response
1055            .json()
1056            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1057        Ok(body.tags)
1058    }
1059}
1060
1061impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1062    fn resolve_latest(&self, package: &str) -> Result<String> {
1063        let repository = format!("{}/{}", self.namespace, package);
1064        let tags = self.tags(&repository)?;
1065        select_latest_artifact_tag(&tags)
1066            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1067    }
1068}
1069
1070#[derive(Deserialize)]
1071struct GhcrTokenResponse {
1072    token: String,
1073}
1074
1075#[derive(Deserialize)]
1076struct GhcrTagsResponse {
1077    #[serde(default)]
1078    tags: Vec<String>,
1079}
1080
1081fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1082    tags.iter()
1083        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1084        .max_by(|(left, _), (right, _)| left.cmp(right))
1085        .map(|(_, tag)| tag.clone())
1086        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1087        .context("no semver or latest tags found")
1088}
1089
1090impl CrateVersionResolver for CratesIoApiVersionResolver {
1091    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1092        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1093        let response = self
1094            .client
1095            .get(&url)
1096            .send()
1097            .with_context(|| format!("failed to GET {url}"))?;
1098        let status = response.status();
1099        let body = response
1100            .text()
1101            .with_context(|| format!("failed to read body of {url}"))?;
1102        if !status.is_success() {
1103            bail!("crates.io API GET {url} returned {status}: {body}");
1104        }
1105        parse_crates_io_version(crate_name, &body)
1106    }
1107
1108    fn resolve_latest_for_channel(
1109        &self,
1110        crate_name: &str,
1111        channel: ToolchainChannel,
1112    ) -> Result<String> {
1113        if channel != ToolchainChannel::Rnd {
1114            return self.resolve_latest(crate_name);
1115        }
1116        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1117        let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1118            anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1119        })?;
1120        research_or_fallback(crate_name, &body)
1121    }
1122
1123    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1124        // A 404 means the `<name>-rnd` crate is simply not published — the tool
1125        // ships no research build. Map it to `Absent` (a skip signal) instead of
1126        // aborting, so `gtc-research install` / manifest assembly survives the
1127        // ~10 of 13 toolchain crates that have no research line.
1128        match self.fetch_crate_body(crate_name)? {
1129            None => Ok(ResearchVersion::Absent),
1130            Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1131        }
1132    }
1133}
1134
1135/// Pick the highest non-yanked version from the crates.io `/crates/<name>`
1136/// response's top-level `versions` array. When `research_only`, restricts to
1137/// `-research` prereleases (the research lane publishes `X.Y.Z-research`, which
1138/// `max_stable_version` skips). Otherwise picks the highest semver of ANY
1139/// channel — the fallback for toolchain crates with no `-research` build, which
1140/// keeps them at their latest dev build instead of regressing to old stable.
1141fn pick_highest_crates_io_version(
1142    crate_name: &str,
1143    body: &str,
1144    research_only: bool,
1145) -> Result<String> {
1146    let payload: serde_json::Value = serde_json::from_str(body)
1147        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1148    let versions = payload
1149        .get("versions")
1150        .and_then(|v| v.as_array())
1151        .ok_or_else(|| {
1152            anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1153        })?;
1154    let mut best: Option<Version> = None;
1155    for entry in versions {
1156        if entry
1157            .get("yanked")
1158            .and_then(serde_json::Value::as_bool)
1159            .unwrap_or(false)
1160        {
1161            continue;
1162        }
1163        let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1164            continue;
1165        };
1166        let Ok(parsed) = Version::parse(num) else {
1167            continue;
1168        };
1169        if research_only && !parsed.pre.as_str().starts_with("research") {
1170            continue;
1171        }
1172        if best.as_ref().is_none_or(|current| parsed > *current) {
1173            best = Some(parsed);
1174        }
1175    }
1176    best.map(|v| v.to_string()).ok_or_else(|| {
1177        let what = if research_only {
1178            "no non-yanked `-research` version"
1179        } else {
1180            "no non-yanked versions"
1181        };
1182        anyhow!("crates.io API for `{crate_name}` exposes {what}")
1183    })
1184}
1185
1186fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1187    pick_highest_crates_io_version(crate_name, body, true)
1188}
1189
1190fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1191    let payload: serde_json::Value = serde_json::from_str(body)
1192        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1193    let crate_obj = payload.get("crate").ok_or_else(|| {
1194        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1195    })?;
1196    let version = crate_obj
1197        .get("max_stable_version")
1198        .and_then(|v| v.as_str())
1199        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1200        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1201        .ok_or_else(|| {
1202            anyhow!(
1203                "crates.io API for `{crate_name}` does not expose max_stable_version, \
1204                 newest_version, or max_version"
1205            )
1206        })?;
1207    Version::parse(version).with_context(|| {
1208        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1209    })?;
1210    Ok(version.to_string())
1211}
1212
1213#[async_trait]
1214trait ToolchainManifestSource {
1215    async fn load_manifest(
1216        &self,
1217        repo: &str,
1218        tag: &str,
1219        token: Option<&str>,
1220    ) -> Result<Option<ToolchainManifest>>;
1221}
1222
1223struct OciToolchainManifestSource;
1224
1225#[async_trait]
1226impl ToolchainManifestSource for OciToolchainManifestSource {
1227    async fn load_manifest(
1228        &self,
1229        repo: &str,
1230        tag: &str,
1231        token: Option<&str>,
1232    ) -> Result<Option<ToolchainManifest>> {
1233        let auth = optional_registry_auth(token)?;
1234        let client = oci_client();
1235        let reference = parse_reference(repo, tag)?;
1236        let image = match client
1237            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1238            .await
1239        {
1240            Ok(image) => image,
1241            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1242                return Ok(None);
1243            }
1244            Err(err) => {
1245                return Err(err)
1246                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1247            }
1248        };
1249        let Some(layer) = image
1250            .layers
1251            .into_iter()
1252            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1253        else {
1254            return Ok(None);
1255        };
1256        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1257            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1258        validate_manifest(&manifest)?;
1259        Ok(Some(manifest))
1260    }
1261}
1262
1263async fn load_source_manifest(
1264    repo: &str,
1265    tag: &str,
1266    token: Option<&str>,
1267) -> Result<Option<ToolchainManifest>> {
1268    OciToolchainManifestSource
1269        .load_manifest(repo, tag, token)
1270        .await
1271}
1272
1273fn oci_client() -> Client {
1274    Client::new(ClientConfig {
1275        protocol: ClientProtocol::Https,
1276        ..Default::default()
1277    })
1278}
1279
1280// ---------------------------------------------------------------------------
1281// Updater dispatch — notify the coordinated update-plan workflow
1282// ---------------------------------------------------------------------------
1283
1284/// Returns `true` when `version` is a plain `X.Y.Z` (no pre-release, no build
1285/// metadata) AND `channel` is `"stable"`. The update server must only ever
1286/// receive stable-lane content; everything else (dev, rnd, run-id versions)
1287/// is silently skipped.
1288fn should_notify_updater(version: &str, channel: &str) -> bool {
1289    if channel != "stable" {
1290        return false;
1291    }
1292    match Version::parse(version) {
1293        Ok(v) => v.pre.is_empty() && v.build.is_empty(),
1294        Err(_) => false,
1295    }
1296}
1297
1298/// Fire a `repository_dispatch` to trigger the coordinated update-plan
1299/// publisher workflow. Failure is a warning, never fatal — the GHCR manifest
1300/// push already succeeded, and the workflow has a manual `workflow_dispatch`
1301/// fallback.
1302fn notify_updater_dispatch(version: &str, channel: &str, raw_token: Option<&str>) {
1303    if !should_notify_updater(version, channel) {
1304        eprintln!(
1305            "Skipping updater dispatch: version `{version}` / channel `{channel}` \
1306             is not a stable-lane release"
1307        );
1308        return;
1309    }
1310
1311    let token = match resolve_registry_token(raw_token)
1312        .ok()
1313        .flatten()
1314        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1315        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1316    {
1317        Some(t) if !t.trim().is_empty() => t,
1318        _ => {
1319            eprintln!(
1320                "Warning: skipping updater dispatch — no token available \
1321                 (pass --token or set GHCR_TOKEN/GITHUB_TOKEN)"
1322            );
1323            return;
1324        }
1325    };
1326
1327    let client = match reqwest::blocking::Client::builder()
1328        .user_agent("greentic-dev-cli")
1329        .build()
1330    {
1331        Ok(c) => c,
1332        Err(e) => {
1333            eprintln!("Warning: failed to build HTTP client for updater dispatch: {e}");
1334            return;
1335        }
1336    };
1337
1338    let body = serde_json::json!({
1339        "event_type": "toolchain-release-published",
1340        "client_payload": {
1341            "release": version,
1342            "channel": channel,
1343        }
1344    });
1345
1346    let url = "https://api.github.com/repos/greenticai/greentic-dev/dispatches";
1347    match client
1348        .post(url)
1349        .header("Accept", "application/vnd.github+json")
1350        .bearer_auth(&token)
1351        .json(&body)
1352        .send()
1353    {
1354        Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
1355            eprintln!("Dispatched toolchain-release-published for {version} (channel={channel})");
1356        }
1357        Ok(resp) => {
1358            let status = resp.status();
1359            let text = resp.text().unwrap_or_default();
1360            eprintln!("Warning: updater dispatch returned HTTP {status}: {text}");
1361        }
1362        Err(e) => {
1363            eprintln!("Warning: updater dispatch failed: {e}");
1364        }
1365    }
1366}
1367
1368fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1369    let token = resolve_registry_token(raw_token)?
1370        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1371        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1372        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1373    if token.trim().is_empty() {
1374        bail!("GHCR token is empty");
1375    }
1376    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1377}
1378
1379fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1380    match registry_auth(raw_token) {
1381        Ok(auth) => Ok(auth),
1382        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1383        Err(err) => Err(err),
1384    }
1385}
1386
1387fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1388    let Some(raw_token) = raw_token else {
1389        return Ok(None);
1390    };
1391    if let Some(var) = raw_token.strip_prefix("env:") {
1392        let token =
1393            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1394        if token.trim().is_empty() {
1395            bail!("env var {var} resolved to an empty token");
1396        }
1397        return Ok(Some(token));
1398    }
1399    if raw_token.trim().is_empty() {
1400        bail!("GHCR token is empty");
1401    }
1402    Ok(Some(raw_token.to_string()))
1403}
1404
1405fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1406    Reference::from_str(&toolchain_ref(repo, tag))
1407        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1408}
1409
1410async fn manifest_exists(
1411    client: &Client,
1412    reference: &Reference,
1413    auth: &RegistryAuth,
1414) -> Result<bool> {
1415    match client.pull_manifest(reference, auth).await {
1416        Ok(_) => Ok(true),
1417        Err(err) if is_missing_manifest_error(&err) => Ok(false),
1418        Err(err) => Err(err).context("failed to check whether release tag exists"),
1419    }
1420}
1421
1422fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1423    let msg = err.to_string().to_ascii_lowercase();
1424    msg.contains("manifest unknown")
1425        || msg.contains("name unknown")
1426        || msg.contains("not found")
1427        || msg.contains("404")
1428}
1429
1430fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1431    let msg = err.to_string().to_ascii_lowercase();
1432    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1433}
1434
1435async fn push_manifest_layer(
1436    client: &Client,
1437    reference: &Reference,
1438    auth: &RegistryAuth,
1439    manifest: &ToolchainManifest,
1440) -> Result<()> {
1441    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1442    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1443    let config = Config::new(
1444        br#"{"toolchain":"gtc"}"#.to_vec(),
1445        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1446        None,
1447    );
1448    client
1449        .push(reference, &[layer], config, auth, None)
1450        .await
1451        .context("failed to push toolchain manifest")?;
1452    Ok(())
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use super::*;
1458    use once_cell::sync::Lazy;
1459    use std::sync::Mutex;
1460
1461    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1462
1463    struct FixedResolver;
1464
1465    impl CrateVersionResolver for FixedResolver {
1466        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1467            Ok(match crate_name {
1468                "greentic-runner" => "0.5.10",
1469                _ => "1.2.3",
1470            }
1471            .to_string())
1472        }
1473    }
1474
1475    struct FixedArtifactResolver;
1476
1477    impl ArtifactVersionResolver for FixedArtifactResolver {
1478        fn resolve_latest(&self, package: &str) -> Result<String> {
1479            Ok(match package {
1480                "packs/messaging/messaging-webchat-gui" => "0.4.93",
1481                "components/component-adaptive-card" => "0.5.8",
1482                _ => "0.1.0",
1483            }
1484            .to_string())
1485        }
1486    }
1487
1488    #[test]
1489    fn parses_crates_io_max_stable_version() {
1490        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1491        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1492        assert_eq!(version, "0.5.123");
1493    }
1494
1495    #[test]
1496    fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1497        // The research lane must ignore the stable `max_stable_version` (0.5.48)
1498        // and pick the highest non-yanked `-research` prerelease.
1499        let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1500            "versions":[
1501                {"num":"0.5.48","yanked":false},
1502                {"num":"1.2.0-research.0","yanked":false},
1503                {"num":"1.2.0-research.1","yanked":false},
1504                {"num":"1.2.0-research.2","yanked":true}
1505            ]}"#;
1506        let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1507        assert_eq!(version, "1.2.0-research.1");
1508    }
1509
1510    #[test]
1511    fn research_resolver_errors_when_no_research_version() {
1512        let body = r#"{"crate":{"id":"greentic-setup"},
1513            "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1514        let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1515        assert!(err.to_string().contains("no non-yanked `-research`"));
1516    }
1517
1518    /// Mirrors the real fleet: only start/runner/setup ship `-research` crates,
1519    /// so any `operator` crate resolves to `Absent` (a skip), everything else to
1520    /// a pinned research version.
1521    struct ResearchSkipResolver;
1522
1523    impl CrateVersionResolver for ResearchSkipResolver {
1524        fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1525            Ok("1.2.0-research.4".to_string())
1526        }
1527
1528        fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1529            if crate_name.contains("operator") {
1530                Ok(ResearchVersion::Absent)
1531            } else {
1532                Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1533            }
1534        }
1535    }
1536
1537    #[test]
1538    fn classify_crate_response_maps_404_to_absent() {
1539        let outcome =
1540            classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1541                .unwrap();
1542        assert!(outcome.is_none(), "404 must classify as absent (None)");
1543    }
1544
1545    #[test]
1546    fn classify_crate_response_returns_body_on_success() {
1547        let outcome =
1548            classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1549        assert_eq!(outcome.as_deref(), Some("payload"));
1550    }
1551
1552    #[test]
1553    fn classify_crate_response_errors_on_other_status() {
1554        let err = classify_crate_response(
1555            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1556            "url",
1557            "boom".to_string(),
1558        )
1559        .unwrap_err();
1560        assert!(err.to_string().contains("500"));
1561    }
1562
1563    #[test]
1564    fn snapshot_manifest_skips_crates_without_research_build() {
1565        // The absent `operator` crate must be omitted, not abort the whole
1566        // research manifest — the regression behind .github#212's install hang.
1567        let manifest = snapshot_manifest(
1568            "1.2.0-research.4",
1569            ToolchainChannel::Rnd,
1570            &ResearchSkipResolver,
1571            None,
1572        )
1573        .unwrap();
1574        assert!(
1575            !manifest.packages.is_empty(),
1576            "research-built tools must remain in the manifest"
1577        );
1578        assert!(
1579            manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1580            "at least one tool without a research build must be skipped"
1581        );
1582        assert!(
1583            manifest
1584                .packages
1585                .iter()
1586                .all(|package| !package.crate_name.contains("operator")),
1587            "the absent `operator` crate must be omitted"
1588        );
1589        assert!(
1590            manifest
1591                .packages
1592                .iter()
1593                .all(|package| package.version == "1.2.0-research.4"),
1594            "remaining research tools pin their resolved -research version"
1595        );
1596    }
1597
1598    #[test]
1599    fn parses_crates_io_falls_back_to_newest_version() {
1600        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1601        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1602        assert_eq!(version, "0.6.7");
1603    }
1604
1605    #[test]
1606    fn parses_crates_io_falls_back_to_max_version() {
1607        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1608        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1609        assert_eq!(version, "0.4.99");
1610    }
1611
1612    #[test]
1613    fn rejects_crates_io_payload_without_versions() {
1614        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1615        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1616        assert!(
1617            err.to_string()
1618                .contains("does not expose max_stable_version")
1619        );
1620    }
1621
1622    #[test]
1623    fn rejects_crates_io_payload_with_unparseable_version() {
1624        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1625        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1626        assert!(err.to_string().contains("unparseable version"));
1627    }
1628
1629    #[test]
1630    fn rejects_crates_io_payload_without_crate_object() {
1631        let body = r#"{"errors":[{"detail":"not found"}]}"#;
1632        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1633        assert!(
1634            err.to_string()
1635                .contains("missing the top-level `crate` object")
1636        );
1637    }
1638
1639    #[test]
1640    fn selects_latest_semver_tag() {
1641        let tags = vec![
1642            "latest".to_string(),
1643            "0.4.93".to_string(),
1644            "0.4.9".to_string(),
1645            "1.0.0-beta.1".to_string(),
1646            "1.0.0".to_string(),
1647        ];
1648
1649        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1650    }
1651
1652    #[test]
1653    fn selects_latest_tag_when_no_semver_tags_exist() {
1654        let tags = vec!["latest".to_string()];
1655
1656        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1657    }
1658
1659    #[test]
1660    fn generates_manifest_from_catalogue() {
1661        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1662        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1663        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1664        assert_eq!(manifest.version, "1.0.5");
1665        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1666        assert!(
1667            manifest
1668                .packages
1669                .iter()
1670                .any(|package| package.crate_name == "greentic-bundle"
1671                    && package.bins == ["greentic-bundle"])
1672        );
1673        assert!(
1674            manifest
1675                .packages
1676                .iter()
1677                .any(|package| package.crate_name == "greentic-runner"
1678                    && package.bins == ["greentic-runner"])
1679        );
1680        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1681        assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1682        assert!(
1683            manifest
1684                .extension_packs
1685                .as_ref()
1686                .unwrap()
1687                .iter()
1688                .all(|item| item.version == "1.0.5")
1689        );
1690        assert!(
1691            manifest
1692                .components
1693                .as_ref()
1694                .unwrap()
1695                .iter()
1696                .all(|item| item.version == "1.0.5")
1697        );
1698    }
1699
1700    #[test]
1701    fn generated_manifest_can_use_artifact_resolver_versions() {
1702        let manifest = generate_manifest_with_artifact_resolver(
1703            "1.0.17",
1704            "stable",
1705            None,
1706            &FixedResolver,
1707            &FixedArtifactResolver,
1708            None,
1709        )
1710        .unwrap();
1711
1712        assert!(
1713            manifest
1714                .extension_packs
1715                .as_ref()
1716                .unwrap()
1717                .iter()
1718                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1719                    && item.version == "0.4.93")
1720        );
1721        assert!(
1722            manifest
1723                .components
1724                .as_ref()
1725                .unwrap()
1726                .iter()
1727                .any(|item| item.id == "components/component-adaptive-card"
1728                    && item.version == "0.5.8")
1729        );
1730    }
1731
1732    #[test]
1733    fn source_manifest_can_pin_package_versions() {
1734        let source = ToolchainManifest {
1735            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1736            toolchain: TOOLCHAIN_NAME.to_string(),
1737            version: "latest".to_string(),
1738            channel: Some("latest".to_string()),
1739            created_at: None,
1740            packages: vec![ToolchainPackage {
1741                crate_name: "greentic-dev".to_string(),
1742                bins: vec!["greentic-dev".to_string()],
1743                version: "0.5.9".to_string(),
1744            }],
1745            extension_packs: None,
1746            components: None,
1747        };
1748        let manifest =
1749            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1750        let greentic_dev = manifest
1751            .packages
1752            .iter()
1753            .find(|package| package.crate_name == "greentic-dev")
1754            .unwrap();
1755        assert_eq!(greentic_dev.version, "0.5.9");
1756    }
1757
1758    #[test]
1759    fn from_argument_controls_generated_channel_over_source_manifest() {
1760        let source = ToolchainManifest {
1761            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1762            toolchain: TOOLCHAIN_NAME.to_string(),
1763            version: "latest".to_string(),
1764            channel: Some("stable".to_string()),
1765            created_at: None,
1766            packages: Vec::new(),
1767            extension_packs: None,
1768            components: None,
1769        };
1770        let manifest =
1771            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1772        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1773        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1774    }
1775
1776    #[test]
1777    fn generate_from_dev_uses_dev_crate_and_binary_names() {
1778        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1779        assert!(
1780            manifest
1781                .packages
1782                .iter()
1783                .flat_map(|package| package.bins.iter())
1784                .all(|bin| bin.ends_with("-dev"))
1785        );
1786        assert!(
1787            manifest
1788                .packages
1789                .iter()
1790                .all(|package| package.crate_name.ends_with("-dev")),
1791            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1792        );
1793        assert!(manifest.packages.iter().any(|package| {
1794            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1795        }));
1796        assert!(manifest.packages.iter().any(|package| {
1797            package.crate_name == "greentic-component-dev"
1798                && package.bins == ["greentic-component-dev"]
1799        }));
1800        assert!(manifest.packages.iter().any(|package| {
1801            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1802        }));
1803    }
1804
1805    #[test]
1806    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1807        let manifest =
1808            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1809                .unwrap();
1810        assert_eq!(manifest.version, "1.1.5");
1811        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1812        for package in &manifest.packages {
1813            assert!(
1814                package.crate_name.ends_with("-dev"),
1815                "dev snapshot must pin -dev crate names; got {}",
1816                package.crate_name
1817            );
1818            assert!(
1819                package.bins.iter().all(|bin| bin.ends_with("-dev")),
1820                "dev snapshot must pin -dev bin names; got {:?}",
1821                package.bins
1822            );
1823            assert_ne!(
1824                package.version, "latest",
1825                "snapshot must always resolve concrete versions"
1826            );
1827        }
1828        assert!(
1829            manifest
1830                .packages
1831                .iter()
1832                .any(|package| package.crate_name == "greentic-operator-dev")
1833        );
1834    }
1835
1836    #[test]
1837    fn snapshot_manifest_stable_channel_keeps_plain_names() {
1838        let manifest =
1839            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1840        assert_eq!(manifest.channel.as_deref(), Some("stable"));
1841        // The stable channel must NOT apply the `-dev` suffix transform.
1842        // Cross-check against the catalogue: every stable package must match
1843        // a catalogue entry by exact name (no transform applied).
1844        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1845            .iter()
1846            .map(|spec| spec.crate_name)
1847            .collect();
1848        for package in &manifest.packages {
1849            assert!(
1850                catalogue_names.contains(package.crate_name.as_str()),
1851                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1852                package.crate_name
1853            );
1854        }
1855    }
1856
1857    #[test]
1858    fn snapshot_manifest_resolves_via_resolver() {
1859        let manifest =
1860            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1861                .unwrap();
1862        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
1863        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
1864        // so the special case in FixedResolver does not apply and every
1865        // package should land on the default 1.2.3 — proving the resolver was
1866        // hit (rather than versions copied from somewhere).
1867        for package in &manifest.packages {
1868            assert_eq!(
1869                package.version, "1.2.3",
1870                "resolver must be hit for {}",
1871                package.crate_name
1872            );
1873        }
1874    }
1875
1876    #[test]
1877    fn parses_dev_channel_argument() {
1878        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1879        assert_eq!(
1880            parse_channel("development").unwrap(),
1881            ToolchainChannel::Development
1882        );
1883        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1884        assert!(parse_channel("rc").is_err());
1885    }
1886
1887    #[test]
1888    fn detects_concrete_pins_for_publish_deprecation_warning() {
1889        let with_pins = ToolchainManifest {
1890            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1891            toolchain: TOOLCHAIN_NAME.to_string(),
1892            version: "0.0.1".to_string(),
1893            channel: Some("dev".to_string()),
1894            created_at: None,
1895            packages: vec![ToolchainPackage {
1896                crate_name: "greentic-operator-dev".to_string(),
1897                bins: vec!["greentic-operator-dev".to_string()],
1898                version: "0.5.123".to_string(),
1899            }],
1900            extension_packs: None,
1901            components: None,
1902        };
1903        assert!(source_manifest_has_concrete_pins(&with_pins));
1904
1905        let only_latest = ToolchainManifest {
1906            packages: vec![ToolchainPackage {
1907                crate_name: "greentic-operator".to_string(),
1908                bins: vec!["greentic-operator".to_string()],
1909                version: "latest".to_string(),
1910            }],
1911            ..with_pins
1912        };
1913        assert!(!source_manifest_has_concrete_pins(&only_latest));
1914    }
1915
1916    #[test]
1917    fn generate_from_rnd_uses_rnd_binary_names() {
1918        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1919        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1920        assert!(
1921            manifest
1922                .packages
1923                .iter()
1924                .flat_map(|package| package.bins.iter())
1925                .all(|bin| bin.ends_with("-rnd"))
1926        );
1927        assert!(manifest.packages.iter().any(|package| {
1928            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1929        }));
1930    }
1931
1932    #[test]
1933    fn bootstrap_source_manifest_uses_source_tag_identity() {
1934        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1935        assert_eq!(manifest.version, "latest");
1936        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1937        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1938        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1939        assert!(
1940            manifest
1941                .packages
1942                .iter()
1943                .all(|package| package.version != "latest")
1944        );
1945    }
1946
1947    #[test]
1948    fn validates_schema_and_toolchain() {
1949        let mut manifest =
1950            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1951        assert!(validate_manifest(&manifest).is_ok());
1952        manifest.schema = "wrong".to_string();
1953        assert!(validate_manifest(&manifest).is_err());
1954        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1955        manifest.toolchain = "other".to_string();
1956        assert!(validate_manifest(&manifest).is_err());
1957    }
1958
1959    #[test]
1960    fn resolves_inline_registry_token() {
1961        assert_eq!(
1962            resolve_registry_token(Some("secret-token"))
1963                .unwrap()
1964                .as_deref(),
1965            Some("secret-token")
1966        );
1967    }
1968
1969    #[test]
1970    fn resolves_registry_token_from_environment_reference() {
1971        let _guard = ENV_LOCK.lock().unwrap();
1972        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1973        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1974
1975        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1976        assert_eq!(resolved.as_deref(), Some("env-secret"));
1977
1978        match previous {
1979            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1980            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1981        }
1982    }
1983
1984    #[test]
1985    fn rejects_empty_registry_token_from_environment_reference() {
1986        let _guard = ENV_LOCK.lock().unwrap();
1987        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1988        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
1989
1990        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1991        assert!(err.to_string().contains("resolved to an empty token"));
1992
1993        match previous {
1994            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1995            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1996        }
1997    }
1998
1999    #[test]
2000    fn registry_auth_uses_environment_fallbacks() {
2001        let _guard = ENV_LOCK.lock().unwrap();
2002        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2003        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2004        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
2005        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2006
2007        let auth = registry_auth(None).unwrap();
2008        match auth {
2009            RegistryAuth::Basic(user, token) => {
2010                assert_eq!(user, DEFAULT_OAUTH_USER);
2011                assert_eq!(token, "ghcr-secret");
2012            }
2013            _ => panic!("expected basic auth"),
2014        }
2015
2016        match previous_ghcr {
2017            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2018            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2019        }
2020        match previous_github {
2021            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2022            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2023        }
2024    }
2025
2026    #[test]
2027    fn optional_registry_auth_allows_missing_implicit_token() {
2028        let _guard = ENV_LOCK.lock().unwrap();
2029        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2030        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2031        unsafe { std::env::remove_var("GHCR_TOKEN") };
2032        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2033
2034        let auth = optional_registry_auth(None).unwrap();
2035        assert!(matches!(auth, RegistryAuth::Anonymous));
2036
2037        match previous_ghcr {
2038            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2039            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2040        }
2041        match previous_github {
2042            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2043            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2044        }
2045    }
2046
2047    #[test]
2048    fn release_view_tag_prefers_release_or_tag() {
2049        let args = ReleaseViewArgs {
2050            release: Some("1.0.5".to_string()),
2051            tag: None,
2052            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2053            token: None,
2054        };
2055        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
2056
2057        let args = ReleaseViewArgs {
2058            release: None,
2059            tag: Some("stable".to_string()),
2060            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2061            token: None,
2062        };
2063        assert_eq!(release_view_tag(&args).unwrap(), "stable");
2064    }
2065
2066    #[test]
2067    fn release_view_tag_rejects_invalid_argument_combinations() {
2068        let err = release_view_tag(&ReleaseViewArgs {
2069            release: Some("1.0.5".to_string()),
2070            tag: Some("stable".to_string()),
2071            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2072            token: None,
2073        })
2074        .unwrap_err();
2075        assert!(
2076            err.to_string()
2077                .contains("pass exactly one of --release or --tag")
2078        );
2079
2080        let err = release_view_tag(&ReleaseViewArgs {
2081            release: None,
2082            tag: None,
2083            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2084            token: None,
2085        })
2086        .unwrap_err();
2087        assert!(
2088            err.to_string()
2089                .contains("pass exactly one of --release or --tag")
2090        );
2091    }
2092
2093    #[test]
2094    fn publish_manifest_input_uses_local_manifest_version() {
2095        let dir = tempfile::tempdir().unwrap();
2096        let path = dir.path().join("gtc-1.0.12.json");
2097        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2098        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2099        let args = ReleasePublishArgs {
2100            release: None,
2101            from: None,
2102            tag: Some("stable".to_string()),
2103            manifest: Some(path.clone()),
2104            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2105            token: None,
2106            out: dir.path().to_path_buf(),
2107            dry_run: true,
2108            force: true,
2109            no_notify_updater: true,
2110        };
2111        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2112        assert_eq!(release, "1.0.12");
2113        assert_eq!(loaded, manifest);
2114        assert_eq!(
2115            source_path,
2116            Some(PublishManifestSource::Local(path.clone()))
2117        );
2118    }
2119
2120    #[test]
2121    fn publish_manifest_input_allows_release_override_for_local_manifest() {
2122        let dir = tempfile::tempdir().unwrap();
2123        let path = dir.path().join("gtc-1.0.13.json");
2124        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2125        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2126        let args = ReleasePublishArgs {
2127            release: Some("1.0.13".to_string()),
2128            from: None,
2129            tag: Some("stable".to_string()),
2130            manifest: Some(path.clone()),
2131            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2132            token: None,
2133            out: dir.path().to_path_buf(),
2134            dry_run: true,
2135            force: true,
2136            no_notify_updater: true,
2137        };
2138        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2139        assert_eq!(release, "1.0.13");
2140        assert_eq!(loaded.version, "1.0.13");
2141        assert_eq!(
2142            source_path,
2143            Some(PublishManifestSource::Local(path.clone()))
2144        );
2145    }
2146
2147    #[test]
2148    fn manifest_file_name_omits_stable_channel() {
2149        let manifest = ToolchainManifest {
2150            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2151            toolchain: TOOLCHAIN_NAME.to_string(),
2152            version: "1.0.12".to_string(),
2153            channel: Some("stable".to_string()),
2154            created_at: None,
2155            packages: Vec::new(),
2156            extension_packs: None,
2157            components: None,
2158        };
2159        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2160    }
2161
2162    #[test]
2163    fn parses_manifest_without_extension_sections() {
2164        let manifest: ToolchainManifest = serde_json::from_str(
2165            r#"{
2166              "schema": "greentic.toolchain-manifest.v1",
2167              "toolchain": "gtc",
2168              "version": "1.0.16",
2169              "channel": "stable",
2170              "packages": []
2171            }"#,
2172        )
2173        .unwrap();
2174
2175        assert_eq!(manifest.extension_packs, None);
2176        assert_eq!(manifest.components, None);
2177    }
2178
2179    #[test]
2180    fn generated_manifest_includes_catalogue_extension_sections() {
2181        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2182        let json = serde_json::to_value(&manifest).unwrap();
2183
2184        assert!(json.get("extension_packs").is_some());
2185        assert!(json.get("components").is_some());
2186        assert!(
2187            manifest
2188                .extension_packs
2189                .as_ref()
2190                .unwrap()
2191                .iter()
2192                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2193                    && item.version == "1.0.16")
2194        );
2195        assert!(
2196            manifest
2197                .extension_packs
2198                .as_ref()
2199                .unwrap()
2200                .iter()
2201                .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2202                    && item.version == "1.0.16")
2203        );
2204        assert!(
2205            manifest
2206                .components
2207                .as_ref()
2208                .unwrap()
2209                .iter()
2210                .any(|item| item.id == "component/component-llm-openai"
2211                    && item.version == "1.0.16")
2212        );
2213    }
2214
2215    #[test]
2216    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2217        let source = ToolchainManifest {
2218            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2219            toolchain: TOOLCHAIN_NAME.to_string(),
2220            version: "dev".to_string(),
2221            channel: Some("dev".to_string()),
2222            created_at: None,
2223            packages: Vec::new(),
2224            extension_packs: Some(vec![ExtensionPackRef {
2225                id: "packs/messaging/messaging-webchat-gui".to_string(),
2226                version: "0.5.4".to_string(),
2227            }]),
2228            components: Some(vec![ComponentRef {
2229                id: "components/component-adaptive-card".to_string(),
2230                version: "0.5.8".to_string(),
2231            }]),
2232        };
2233
2234        let manifest =
2235            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2236
2237        assert!(
2238            manifest
2239                .extension_packs
2240                .as_ref()
2241                .unwrap()
2242                .iter()
2243                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2244                    && item.version == "0.5.4")
2245        );
2246        assert!(
2247            manifest
2248                .components
2249                .as_ref()
2250                .unwrap()
2251                .iter()
2252                .any(|item| item.id == "components/component-adaptive-card"
2253                    && item.version == "0.5.8")
2254        );
2255    }
2256
2257    #[test]
2258    fn manifest_file_name_includes_non_stable_channel() {
2259        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2260        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2261
2262        manifest.channel = Some("customer-a".to_string());
2263        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2264    }
2265
2266    #[test]
2267    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2268        assert_eq!(
2269            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2270            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2271        );
2272        assert_eq!(
2273            manifest_bins_for_source("dev", &["greentic-dev"]),
2274            vec!["greentic-dev-dev".to_string()]
2275        );
2276        assert_eq!(
2277            manifest_crate_name_for_source("latest", "greentic-runner"),
2278            "greentic-runner"
2279        );
2280        assert_eq!(
2281            manifest_crate_name_for_source("dev", "greentic-runner"),
2282            "greentic-runner-dev"
2283        );
2284    }
2285
2286    #[test]
2287    fn source_version_map_handles_missing_and_present_sources() {
2288        assert!(source_version_map(None).is_empty());
2289
2290        let source = ToolchainManifest {
2291            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2292            toolchain: TOOLCHAIN_NAME.to_string(),
2293            version: "latest".to_string(),
2294            channel: Some("latest".to_string()),
2295            created_at: None,
2296            packages: vec![ToolchainPackage {
2297                crate_name: "greentic-dev".to_string(),
2298                bins: vec!["greentic-dev".to_string()],
2299                version: "0.6.0".to_string(),
2300            }],
2301            extension_packs: None,
2302            components: None,
2303        };
2304
2305        let versions = source_version_map(Some(&source));
2306        assert_eq!(
2307            versions.get("greentic-dev").map(String::as_str),
2308            Some("0.6.0")
2309        );
2310    }
2311
2312    #[test]
2313    fn write_manifest_persists_json_to_expected_file_name() {
2314        let dir = tempfile::tempdir().unwrap();
2315        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2316
2317        let path = write_manifest(dir.path(), &manifest).unwrap();
2318        assert_eq!(
2319            path.file_name().and_then(|name| name.to_str()),
2320            Some("gtc-dev-1.0.12.json")
2321        );
2322
2323        let roundtrip = read_manifest_file(&path).unwrap();
2324        assert_eq!(roundtrip, manifest);
2325    }
2326
2327    #[test]
2328    fn latest_manifest_uses_latest_dev_bins() {
2329        let manifest = latest_manifest(None);
2330        assert_eq!(manifest.version, "latest");
2331        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2332        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2333        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2334        assert!(!manifest.packages.is_empty());
2335        assert!(
2336            manifest
2337                .packages
2338                .iter()
2339                .all(|package| package.version == "latest")
2340        );
2341        assert!(
2342            manifest
2343                .packages
2344                .iter()
2345                .flat_map(|package| package.bins.iter())
2346                .all(|bin| bin.ends_with("-dev"))
2347        );
2348        assert!(
2349            manifest
2350                .packages
2351                .iter()
2352                .all(|package| package.crate_name.ends_with("-dev")),
2353            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2354        );
2355        assert!(
2356            manifest
2357                .packages
2358                .iter()
2359                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2360        );
2361        assert!(manifest.packages.iter().any(|package| {
2362            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2363        }));
2364        assert!(
2365            manifest
2366                .extension_packs
2367                .as_ref()
2368                .unwrap()
2369                .iter()
2370                .all(|item| item.version == "latest")
2371        );
2372        assert!(
2373            manifest
2374                .components
2375                .as_ref()
2376                .unwrap()
2377                .iter()
2378                .all(|item| item.version == "latest")
2379        );
2380    }
2381
2382    #[test]
2383    fn publish_dry_run_with_local_manifest_succeeds() {
2384        let dir = tempfile::tempdir().unwrap();
2385        let path = dir.path().join("gtc-1.0.12.json");
2386        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2387        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2388
2389        publish(ReleasePublishArgs {
2390            release: None,
2391            from: None,
2392            tag: Some("stable".to_string()),
2393            manifest: Some(path),
2394            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2395            token: None,
2396            out: dir.path().to_path_buf(),
2397            dry_run: true,
2398            force: false,
2399            no_notify_updater: true,
2400        })
2401        .unwrap();
2402    }
2403
2404    #[test]
2405    fn latest_dry_run_succeeds() {
2406        latest(ReleaseLatestArgs {
2407            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2408            token: None,
2409            dry_run: true,
2410            force: false,
2411        })
2412        .unwrap();
2413    }
2414
2415    #[test]
2416    fn promote_dry_run_succeeds() {
2417        promote(ReleasePromoteArgs {
2418            release: "1.0.12".to_string(),
2419            tag: "stable".to_string(),
2420            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2421            token: None,
2422            dry_run: true,
2423            no_notify_updater: true,
2424        })
2425        .unwrap();
2426    }
2427
2428    #[test]
2429    fn builds_toolchain_ref() {
2430        assert_eq!(
2431            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
2432            "ghcr.io/greenticai/greentic-versions/gtc:stable"
2433        );
2434    }
2435
2436    // -----------------------------------------------------------------------
2437    // should_notify_updater — stable-lane gate tests
2438    // -----------------------------------------------------------------------
2439
2440    #[test]
2441    fn notify_gate_accepts_stable_plain_version() {
2442        assert!(should_notify_updater("1.1.2", "stable"));
2443    }
2444
2445    #[test]
2446    fn notify_gate_accepts_stable_zero_version() {
2447        assert!(should_notify_updater("0.1.0", "stable"));
2448    }
2449
2450    #[test]
2451    fn notify_gate_rejects_dev_channel() {
2452        assert!(!should_notify_updater("1.1.2", "dev"));
2453    }
2454
2455    #[test]
2456    fn notify_gate_rejects_rnd_channel() {
2457        assert!(!should_notify_updater("1.1.2", "rnd"));
2458    }
2459
2460    #[test]
2461    fn notify_gate_rejects_prerelease_version() {
2462        assert!(!should_notify_updater("1.2.0-dev.3", "stable"));
2463    }
2464
2465    #[test]
2466    fn notify_gate_rejects_run_id_version_on_dev_channel() {
2467        // Run-id versions (e.g. 1.1.14995680637) are dev-lane artifacts and
2468        // always carry channel "dev". The channel check catches them.
2469        assert!(!should_notify_updater("1.1.14995680637", "dev"));
2470    }
2471
2472    #[test]
2473    fn notify_gate_rejects_research_prerelease() {
2474        assert!(!should_notify_updater("1.3.0-research.1", "stable"));
2475    }
2476
2477    #[test]
2478    fn notify_gate_rejects_build_metadata() {
2479        assert!(!should_notify_updater("1.1.2+build.42", "stable"));
2480    }
2481
2482    #[test]
2483    fn notify_gate_rejects_empty_channel() {
2484        assert!(!should_notify_updater("1.1.2", ""));
2485    }
2486
2487    #[test]
2488    fn notify_gate_rejects_unparseable_version() {
2489        assert!(!should_notify_updater("not-a-version", "stable"));
2490    }
2491}