Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::{BTreeMap, BTreeSet};
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 checker = default_release_checker(args.token.as_deref());
156    publish_with_checker(args, &checker)
157}
158
159fn publish_with_checker(args: ReleasePublishArgs, checker: &dyn ReleaseAssetChecker) -> Result<()> {
160    let (release, manifest, source) = publish_manifest_input(&args)?;
161
162    // Gate before the dry-run return, so `publish --manifest <file> --dry-run`
163    // doubles as the CI check on a pin bump: it answers "is this manifest
164    // publishable?" without pushing anything.
165    verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
166
167    if args.dry_run {
168        println!(
169            "Dry run: would publish {}",
170            toolchain_ref(&args.repo, &release)
171        );
172        if let Some(tag) = &args.tag {
173            println!(
174                "Dry run: would tag {} as {}",
175                toolchain_ref(&args.repo, &release),
176                toolchain_ref(&args.repo, tag)
177            );
178        }
179        return Ok(());
180    }
181
182    let auth = registry_auth(args.token.as_deref())?;
183    block_on_maybe_runtime(async {
184        let client = oci_client();
185        let release_ref = parse_reference(&args.repo, &release)?;
186        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
187            bail!(
188                "release tag `{}` already exists; pass --force to overwrite it",
189                toolchain_ref(&args.repo, &release)
190            );
191        }
192        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
193        if let Some(tag) = &args.tag {
194            let tag_ref = parse_reference(&args.repo, tag)?;
195            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
196        }
197        Ok(())
198    })?;
199
200    if let Some(source) = source {
201        match source {
202            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
203            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
204        }
205    }
206    println!("Published {}", toolchain_ref(&args.repo, &release));
207    if let Some(tag) = &args.tag {
208        println!("Updated {}", toolchain_ref(&args.repo, tag));
209    }
210
211    if !args.no_notify_updater {
212        let channel = manifest.channel.as_deref().unwrap_or("");
213        notify_updater_dispatch(&release, channel, args.token.as_deref());
214    }
215
216    Ok(())
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220enum PublishManifestSource {
221    Generated(PathBuf),
222    Local(PathBuf),
223}
224
225fn publish_manifest_input(
226    args: &ReleasePublishArgs,
227) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
228    if let Some(path) = &args.manifest {
229        let mut manifest = read_manifest_file(path)?;
230        validate_manifest(&manifest)?;
231        let release = if let Some(release) = &args.release {
232            manifest.version = release.clone();
233            release.clone()
234        } else {
235            manifest.version.clone()
236        };
237        return Ok((
238            release,
239            manifest,
240            Some(PublishManifestSource::Local(path.clone())),
241        ));
242    }
243
244    let release = args
245        .release
246        .as_deref()
247        .context("pass --release or --manifest")?;
248    let from = args.from.as_deref().unwrap_or("latest");
249    let resolver = default_resolver();
250    let source = block_on_maybe_runtime(load_source_manifest(
251        &args.repo,
252        from,
253        args.token.as_deref(),
254    ))
255    .with_context(|| {
256        format!(
257            "failed to resolve source manifest `{}`",
258            toolchain_ref(&args.repo, from)
259        )
260    })?;
261    if let Some(source_manifest) = source.as_ref()
262        && source_manifest_has_concrete_pins(source_manifest)
263    {
264        eprintln!(
265            "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
266             of querying crates.io. To refresh a channel from the latest crates.io versions, use \
267             `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
268             re-resolving, use `release promote`. The conflated `--from` semantics will be \
269             removed in a future release.",
270            toolchain_ref(&args.repo, from),
271        );
272    }
273    let manifest = generate_manifest(
274        release,
275        from,
276        source.as_ref(),
277        &resolver,
278        Some(created_at_now()?),
279    )?;
280    let path = if args.dry_run {
281        println!("{}", serde_json::to_string_pretty(&manifest)?);
282        None
283    } else {
284        Some(PublishManifestSource::Generated(write_manifest(
285            &args.out, &manifest,
286        )?))
287    };
288    Ok((release.to_string(), manifest, path))
289}
290
291/// True when the source manifest has at least one package pinned to a concrete
292/// (non-`"latest"`) version. Used to detect the case where `release publish
293/// --from <X>` would silently copy old pins instead of re-resolving — see the
294/// deprecation warning emitted from `publish_manifest_input`.
295fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
296    manifest
297        .packages
298        .iter()
299        .any(|package| package.version != "latest")
300}
301
302fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
303    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
304    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
305}
306
307pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
308    if args.dry_run {
309        println!(
310            "Dry run: would promote {} to {}",
311            toolchain_ref(&args.repo, &args.release),
312            toolchain_ref(&args.repo, &args.tag)
313        );
314        return Ok(());
315    }
316
317    // Promote copies the OCI manifest (a tag alias) and never reads the
318    // toolchain layer, so load it up front: the stable-lane gate needs the pins
319    // to refuse moving `:stable` onto binaries that are not downloadable yet,
320    // and the updater dispatch below needs the channel.
321    let source = block_on_maybe_runtime(load_source_manifest(
322        &args.repo,
323        &args.release,
324        args.token.as_deref(),
325    ))
326    .ok()
327    .flatten();
328    match source.as_ref() {
329        Some(manifest) => {
330            let checker = default_release_checker(args.token.as_deref());
331            verify_manifest_releases(manifest, Some(args.tag.as_str()), &checker)?;
332        }
333        // Fail closed: moving the tag `gtc install` resolves without being able
334        // to read its pins would reintroduce the exact hazard the gate exists
335        // for. Other tags stay non-fatal, as before.
336        None if args.tag == "stable" => bail!(
337            "cannot read the toolchain pins of `{}` — refusing to move `:stable` unverified",
338            toolchain_ref(&args.repo, &args.release)
339        ),
340        None => {}
341    }
342
343    let auth = registry_auth(args.token.as_deref())?;
344    block_on_maybe_runtime(async {
345        let client = oci_client();
346        let source_ref = parse_reference(&args.repo, &args.release)?;
347        let target_ref = parse_reference(&args.repo, &args.tag)?;
348        let (manifest, _) = client
349            .pull_manifest(&source_ref, &auth)
350            .await
351            .with_context(|| {
352                format!(
353                    "failed to resolve source release `{}`",
354                    toolchain_ref(&args.repo, &args.release)
355                )
356            })?;
357        client
358            .push_manifest(&target_ref, &manifest)
359            .await
360            .with_context(|| {
361                format!(
362                    "failed to update tag `{}`",
363                    toolchain_ref(&args.repo, &args.tag)
364                )
365            })?;
366        Ok(())
367    })?;
368    println!(
369        "Promoted {} to {}",
370        toolchain_ref(&args.repo, &args.release),
371        toolchain_ref(&args.repo, &args.tag)
372    );
373
374    if !args.no_notify_updater {
375        // Reuses the manifest loaded before the push. If loading failed, skip
376        // the dispatch — the workflow has its own channel guard as a backstop.
377        let channel = source.and_then(|m| m.channel);
378        match channel.as_deref() {
379            Some(ch) => notify_updater_dispatch(&args.release, ch, args.token.as_deref()),
380            None => {
381                eprintln!(
382                    "Skipping updater dispatch: could not determine channel \
383                     for {} (use workflow_dispatch as fallback)",
384                    toolchain_ref(&args.repo, &args.release)
385                );
386            }
387        }
388    }
389
390    Ok(())
391}
392
393/// Snapshot the current crates.io state into a new toolchain manifest.
394///
395/// Unlike `publish --from <X>`, snapshot **never** reads an existing manifest
396/// and **always** queries the resolver. That makes it safe to call repeatedly
397/// to refresh a channel — `:dev` after each nightly publish, `:stable` after
398/// a weekly release — without the promote-vs-snapshot conflation that bit
399/// callers of `publish --from dev`.
400pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
401    let checker = default_release_checker(args.token.as_deref());
402    snapshot_with_checker(args, &checker)
403}
404
405fn snapshot_with_checker(
406    args: ReleaseSnapshotArgs,
407    checker: &dyn ReleaseAssetChecker,
408) -> Result<()> {
409    let channel = parse_channel(&args.channel)?;
410    let resolver = CratesIoApiVersionResolver::default();
411    let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
412
413    // Snapshot resolves pins from crates.io, which does NOT imply a finished
414    // release build. Most repos gate `publish_crates` on `needs: [release]`, but
415    // greentic-pack's `crates-publish.yml` fires independently on `push: tags:
416    // ["v*"]` — so its crates.io version and its GitHub release race, and a
417    // stable snapshot could pin the winner of that race. Same gate as publish.
418    verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
419
420    if args.dry_run {
421        println!("{}", serde_json::to_string_pretty(&manifest)?);
422        println!(
423            "Dry run: would publish {}",
424            toolchain_ref(&args.repo, &args.release)
425        );
426        if let Some(tag) = &args.tag {
427            println!(
428                "Dry run: would tag {} as {}",
429                toolchain_ref(&args.repo, &args.release),
430                toolchain_ref(&args.repo, tag)
431            );
432        }
433        return Ok(());
434    }
435
436    let path = write_manifest(&args.out, &manifest)?;
437    println!("Wrote {}", path.display());
438
439    let auth = registry_auth(args.token.as_deref())?;
440    block_on_maybe_runtime(async {
441        let client = oci_client();
442        let release_ref = parse_reference(&args.repo, &args.release)?;
443        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
444            bail!(
445                "release tag `{}` already exists; pass --force to overwrite it",
446                toolchain_ref(&args.repo, &args.release)
447            );
448        }
449        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
450        if let Some(tag) = &args.tag {
451            let tag_ref = parse_reference(&args.repo, tag)?;
452            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
453        }
454        Ok(())
455    })?;
456    println!("Published {}", toolchain_ref(&args.repo, &args.release));
457    if let Some(tag) = &args.tag {
458        println!("Updated {}", toolchain_ref(&args.repo, tag));
459    }
460
461    if !args.no_notify_updater {
462        let ch = channel_tag(channel);
463        notify_updater_dispatch(&args.release, ch, args.token.as_deref());
464    }
465
466    Ok(())
467}
468
469fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
470    match channel {
471        "dev" | "development" => Ok(ToolchainChannel::Development),
472        "rnd" | "research" => Ok(ToolchainChannel::Rnd),
473        "stable" => Ok(ToolchainChannel::Stable),
474        other => bail!(
475            "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
476             pass --channel dev for the dev lane, --channel research for the research lane, or \
477             --channel stable for the stable lane"
478        ),
479    }
480}
481
482fn channel_tag(channel: ToolchainChannel) -> &'static str {
483    match channel {
484        ToolchainChannel::Stable => "stable",
485        ToolchainChannel::Development => "dev",
486        ToolchainChannel::Rnd => "rnd",
487    }
488}
489
490/// Resolve the version for a single toolchain manifest entry on `channel`.
491/// Returns `Ok(None)` when the research channel has no `-rnd` build for the
492/// crate (skip it) so manifest assembly does not abort on the ~10 of 13
493/// toolchain crates that ship no research build.
494fn resolve_manifest_version<R: CrateVersionResolver>(
495    resolver: &R,
496    crate_in_manifest: &str,
497    channel: ToolchainChannel,
498) -> Result<Option<String>> {
499    if channel == ToolchainChannel::Rnd {
500        match resolver
501            .resolve_research_version(crate_in_manifest)
502            .with_context(|| {
503                format!("failed to resolve research version for `{crate_in_manifest}`")
504            })? {
505            ResearchVersion::Pinned(version) => Ok(Some(version)),
506            ResearchVersion::Absent => {
507                eprintln!(
508                    "note: `{crate_in_manifest}` has no research build on crates.io; \
509                     omitting it from the research toolchain manifest"
510                );
511                Ok(None)
512            }
513        }
514    } else {
515        resolver
516            .resolve_latest_for_channel(crate_in_manifest, channel)
517            .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
518            .map(Some)
519    }
520}
521
522pub fn snapshot_manifest<R: CrateVersionResolver>(
523    release: &str,
524    channel: ToolchainChannel,
525    resolver: &R,
526    created_at: Option<String>,
527) -> Result<ToolchainManifest> {
528    let from = channel_tag(channel);
529    let mut packages = Vec::new();
530    for package in GREENTIC_TOOLCHAIN_PACKAGES {
531        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
532        let Some(version) = resolve_manifest_version(resolver, &crate_in_manifest, channel)? else {
533            continue;
534        };
535        packages.push(ToolchainPackage {
536            crate_name: crate_in_manifest,
537            bins: manifest_bins_for_source(from, package.bins),
538            version,
539        });
540    }
541    Ok(ToolchainManifest {
542        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
543        toolchain: TOOLCHAIN_NAME.to_string(),
544        version: release.to_string(),
545        channel: Some(from.to_string()),
546        created_at,
547        packages,
548        extension_packs: None,
549        components: None,
550    })
551}
552
553pub fn view(args: ReleaseViewArgs) -> Result<()> {
554    let tag = release_view_tag(&args)?;
555    let manifest = block_on_maybe_runtime(load_source_manifest(
556        &args.repo,
557        &tag,
558        args.token.as_deref(),
559    ))
560    .with_context(|| {
561        format!(
562            "failed to resolve manifest `{}`",
563            toolchain_ref(&args.repo, &tag)
564        )
565    })?
566    .with_context(|| {
567        format!(
568            "manifest `{}` was not found or is not authorized for this token",
569            toolchain_ref(&args.repo, &tag)
570        )
571    })?;
572    println!("{}", serde_json::to_string_pretty(&manifest)?);
573    Ok(())
574}
575
576pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
577    let manifest = latest_manifest(Some(created_at_now()?));
578    if args.dry_run {
579        println!("{}", serde_json::to_string_pretty(&manifest)?);
580        println!(
581            "Dry run: would publish {}",
582            toolchain_ref(&args.repo, "latest")
583        );
584        return Ok(());
585    }
586
587    let auth = registry_auth(args.token.as_deref())?;
588    block_on_maybe_runtime(async {
589        let client = oci_client();
590        let latest_ref = parse_reference(&args.repo, "latest")?;
591        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
592            bail!(
593                "latest tag `{}` already exists; pass --force to overwrite it",
594                toolchain_ref(&args.repo, "latest")
595            );
596        }
597        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
598    })?;
599    println!("Published {}", toolchain_ref(&args.repo, "latest"));
600    Ok(())
601}
602
603fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
604    ToolchainManifest {
605        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
606        toolchain: TOOLCHAIN_NAME.to_string(),
607        version: "latest".to_string(),
608        channel: Some("latest".to_string()),
609        created_at,
610        packages: latest_manifest_packages(),
611        extension_packs: Some(
612            GREENTIC_EXTENSION_PACK_PACKAGES
613                .iter()
614                .map(|package| ExtensionPackRef {
615                    id: package.package.to_string(),
616                    version: "latest".to_string(),
617                })
618                .collect(),
619        ),
620        components: Some(
621            GREENTIC_COMPONENT_PACKAGES
622                .iter()
623                .map(|package| ComponentRef {
624                    id: package.package.to_string(),
625                    version: "latest".to_string(),
626                })
627                .collect(),
628        ),
629    }
630}
631
632fn latest_manifest_packages() -> Vec<ToolchainPackage> {
633    std::iter::once(ToolchainPackage {
634        crate_name: delegated_binary_name_for_channel(
635            TOOLCHAIN_NAME,
636            ToolchainChannel::Development,
637        ),
638        bins: vec![delegated_binary_name_for_channel(
639            TOOLCHAIN_NAME,
640            ToolchainChannel::Development,
641        )],
642        version: "latest".to_string(),
643    })
644    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
645        ToolchainPackage {
646            crate_name: delegated_binary_name_for_channel(
647                package.crate_name,
648                ToolchainChannel::Development,
649            ),
650            bins: package
651                .bins
652                .iter()
653                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
654                .collect(),
655            version: "latest".to_string(),
656        }
657    }))
658    .collect()
659}
660
661fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
662    match (&args.release, &args.tag) {
663        (Some(release), None) => Ok(release.clone()),
664        (None, Some(tag)) => Ok(tag.clone()),
665        _ => bail!("pass exactly one of --release or --tag"),
666    }
667}
668
669pub fn generate_manifest<R: CrateVersionResolver>(
670    release: &str,
671    from: &str,
672    source: Option<&ToolchainManifest>,
673    resolver: &R,
674    created_at: Option<String>,
675) -> Result<ToolchainManifest> {
676    let artifact_resolver = ReleaseArtifactVersionResolver { release };
677    generate_manifest_with_artifact_resolver(
678        release,
679        from,
680        source,
681        resolver,
682        &artifact_resolver,
683        created_at,
684    )
685}
686
687pub fn generate_manifest_with_artifact_resolver<R, A>(
688    release: &str,
689    from: &str,
690    source: Option<&ToolchainManifest>,
691    resolver: &R,
692    artifact_resolver: &A,
693    created_at: Option<String>,
694) -> Result<ToolchainManifest>
695where
696    R: CrateVersionResolver,
697    A: ArtifactVersionResolver,
698{
699    if let Some(source) = source {
700        validate_manifest(source)?;
701    }
702    let source_versions = source_version_map(source);
703    let mut packages = Vec::new();
704    for package in GREENTIC_TOOLCHAIN_PACKAGES {
705        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
706        let source_version = source_versions.get(&crate_in_manifest);
707        let version = match source_version.map(String::as_str) {
708            Some(version) if version != "latest" => Some(version.to_string()),
709            _ => resolve_manifest_version(
710                resolver,
711                &crate_in_manifest,
712                channel_from_source_tag(from),
713            )?,
714        };
715        let Some(version) = version else {
716            continue;
717        };
718        packages.push(ToolchainPackage {
719            crate_name: crate_in_manifest,
720            bins: manifest_bins_for_source(from, package.bins),
721            version,
722        });
723    }
724    Ok(ToolchainManifest {
725        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
726        toolchain: TOOLCHAIN_NAME.to_string(),
727        version: release.to_string(),
728        channel: Some(from.to_string()),
729        created_at,
730        packages,
731        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
732        components: Some(component_refs_for_release(source, artifact_resolver)?),
733    })
734}
735
736/// Map a manifest source-tag (`dev`/`rnd`/`stable`) to its channel.
737fn channel_from_source_tag(from: &str) -> ToolchainChannel {
738    match from {
739        "dev" => ToolchainChannel::Development,
740        "rnd" => ToolchainChannel::Rnd,
741        _ => ToolchainChannel::Stable,
742    }
743}
744
745fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
746    let channel = channel_from_source_tag(from);
747    bins.iter()
748        .map(|bin| delegated_binary_name_for_channel(bin, channel))
749        .collect()
750}
751
752fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
753    source: Option<&ToolchainManifest>,
754    artifact_resolver: &A,
755) -> Result<Vec<ExtensionPackRef>> {
756    let source_versions = source_ref_version_map(source.and_then(|manifest| {
757        manifest
758            .extension_packs
759            .as_ref()
760            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
761    }));
762    GREENTIC_EXTENSION_PACK_PACKAGES
763        .iter()
764        .map(|package| {
765            Ok(ExtensionPackRef {
766                id: package.package.to_string(),
767                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
768            })
769        })
770        .collect()
771}
772
773fn component_refs_for_release<A: ArtifactVersionResolver>(
774    source: Option<&ToolchainManifest>,
775    artifact_resolver: &A,
776) -> Result<Vec<ComponentRef>> {
777    let source_versions = source_ref_version_map(source.and_then(|manifest| {
778        manifest
779            .components
780            .as_ref()
781            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
782    }));
783    GREENTIC_COMPONENT_PACKAGES
784        .iter()
785        .map(|package| {
786            Ok(ComponentRef {
787                id: package.package.to_string(),
788                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
789            })
790        })
791        .collect()
792}
793
794fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
795where
796    I: Iterator<Item = (&'a String, &'a String)>,
797{
798    let mut out = BTreeMap::new();
799    if let Some(refs) = refs {
800        for (id, version) in refs {
801            out.insert(id.clone(), version.clone());
802        }
803    }
804    out
805}
806
807fn ref_version_for_package(
808    package: &OciPackageSpec,
809    source_versions: &BTreeMap<String, String>,
810    artifact_resolver: &impl ArtifactVersionResolver,
811) -> Result<String> {
812    match source_versions.get(package.package).map(String::as_str) {
813        Some(version) if version != "latest" => Ok(version.to_string()),
814        _ => artifact_resolver
815            .resolve_latest(package.package)
816            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
817    }
818}
819
820/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
821/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
822/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
823/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
824/// the stable one. Reuses `delegated_binary_name_for_channel` because the
825/// rule is identical for crates and binaries (`-dev` suffix, with the
826/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
827fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
828    if from == "dev" {
829        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
830    } else {
831        crate_name.to_string()
832    }
833}
834
835pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
836    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
837        bail!(
838            "unsupported toolchain manifest schema `{}`",
839            manifest.schema
840        );
841    }
842    if manifest.toolchain != TOOLCHAIN_NAME {
843        bail!("unsupported toolchain `{}`", manifest.toolchain);
844    }
845    Ok(())
846}
847
848pub fn toolchain_ref(repo: &str, tag: &str) -> String {
849    format!("{repo}:{tag}")
850}
851
852// ---------------------------------------------------------------------------
853// Stable-lane release gate — a published manifest must never pin a version
854// whose binaries are not downloadable yet
855// ---------------------------------------------------------------------------
856
857const GITHUB_API_BASE: &str = "https://api.github.com";
858/// Every toolchain package's crate name doubles as its repo name under this org.
859const TOOLCHAIN_RELEASE_OWNER: &str = "greenticai";
860/// Archive extensions the shared `release-binaries.yml` workflow attaches.
861const RELEASE_ARCHIVE_SUFFIXES: [&str; 2] = [".tgz", ".zip"];
862
863/// Reads the asset names of one package's GitHub release. Injected so the gate
864/// is testable without a network round-trip, mirroring [`CrateVersionResolver`].
865trait ReleaseAssetChecker {
866    /// `Ok(None)` when the release does not exist, `Ok(Some(names))` otherwise.
867    fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>>;
868}
869
870#[derive(Deserialize)]
871struct GithubReleaseAssets {
872    #[serde(default)]
873    assets: Vec<GithubReleaseAsset>,
874}
875
876#[derive(Deserialize)]
877struct GithubReleaseAsset {
878    name: String,
879}
880
881struct GithubReleaseAssetChecker {
882    base_url: String,
883    token: Option<String>,
884    client: reqwest::blocking::Client,
885}
886
887impl GithubReleaseAssetChecker {
888    fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
889        let client = reqwest::blocking::Client::builder()
890            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
891            .build()
892            .expect("failed to build GitHub API client");
893        Self {
894            base_url: base_url.into(),
895            token,
896            client,
897        }
898    }
899}
900
901impl ReleaseAssetChecker for GithubReleaseAssetChecker {
902    fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
903        let url = format!(
904            "{}/repos/{TOOLCHAIN_RELEASE_OWNER}/{repo}/releases/tags/{tag}",
905            self.base_url.trim_end_matches('/')
906        );
907        let mut request = self
908            .client
909            .get(&url)
910            .header(reqwest::header::ACCEPT, "application/vnd.github+json");
911        if let Some(token) = &self.token {
912            request = request.bearer_auth(token);
913        }
914        let response = request
915            .send()
916            .with_context(|| format!("failed to GET {url}"))?;
917        let status = response.status();
918        let body = response
919            .text()
920            .with_context(|| format!("failed to read body of {url}"))?;
921        let Some(body) = classify_release_response(status, &url, body)? else {
922            return Ok(None);
923        };
924        let release: GithubReleaseAssets = serde_json::from_str(&body)
925            .with_context(|| format!("failed to parse release metadata from {url}"))?;
926        Ok(Some(
927            release.assets.into_iter().map(|asset| asset.name).collect(),
928        ))
929    }
930}
931
932/// The gate's production checker: real GitHub API, ambient token when one is
933/// available. Release reads on these public repos work unauthenticated; the
934/// token only lifts the rate limit.
935fn default_release_checker(raw_token: Option<&str>) -> GithubReleaseAssetChecker {
936    GithubReleaseAssetChecker::new(GITHUB_API_BASE, ambient_github_token(raw_token))
937}
938
939/// Classify a GitHub release-by-tag response: `Ok(None)` for a 404 (no such
940/// release), `Ok(Some(body))` on success, `Err` otherwise. Pure so the
941/// 404-vs-error decision is unit-testable without a live HTTP round-trip.
942fn classify_release_response(
943    status: reqwest::StatusCode,
944    url: &str,
945    body: String,
946) -> Result<Option<String>> {
947    if status == reqwest::StatusCode::NOT_FOUND {
948        return Ok(None);
949    }
950    if !status.is_success() {
951        bail!("GitHub API GET {url} returned {status}: {body}");
952    }
953    Ok(Some(body))
954}
955
956/// True when a push affects what `gtc install` resolves: either the manifest
957/// declares the stable channel, or the push moves the `stable` tag itself.
958///
959/// The tag half is not redundant. `generate_manifest` records `channel: <the
960/// --from value>`, and `--from` defaults to `latest`, so `publish --tag stable`
961/// routinely ships a manifest whose channel is `"latest"` while still moving the
962/// tag users install from. Gating on the channel alone would wave it through.
963fn affects_stable_channel(manifest: &ToolchainManifest, target_tag: Option<&str>) -> bool {
964    manifest.channel.as_deref() == Some("stable") || target_tag == Some("stable")
965}
966
967/// Refuse to publish a stable-lane manifest that pins a package version whose
968/// GitHub release is missing or still uploading.
969///
970/// The toolchain manifest is what `gtc install` resolves, so a pin that outruns
971/// its release build leaves `:stable` pointing at binaries nobody can download.
972/// The dev and research lanes publish on their own cadence and are never gated.
973fn verify_manifest_releases(
974    manifest: &ToolchainManifest,
975    target_tag: Option<&str>,
976    checker: &dyn ReleaseAssetChecker,
977) -> Result<()> {
978    if !affects_stable_channel(manifest, target_tag) {
979        return Ok(());
980    }
981    let mut problems = Vec::new();
982    for package in &manifest.packages {
983        let tag = format!("v{}", package.version);
984        match checker.release_assets(&package.crate_name, &tag)? {
985            None => problems.push(format!(
986                "{} {tag}: no GitHub release (build not finished)",
987                package.crate_name
988            )),
989            Some(assets) => {
990                if let Err(reason) = check_release_assets(&assets, &package.version) {
991                    problems.push(format!("{} {tag}: {reason}", package.crate_name));
992                }
993            }
994        }
995    }
996    if !problems.is_empty() {
997        bail!(
998            "refusing to publish toolchain manifest {}: {} pinned package(s) are not \
999             downloadable yet:\n  {}\nWait for the release builds to finish, then retry.",
1000            manifest.version,
1001            problems.len(),
1002            problems.join("\n  ")
1003        );
1004    }
1005    Ok(())
1006}
1007
1008/// A release is usable once it carries at least one versioned archive and every
1009/// versioned archive has its `.sha256` sibling. The `ensure-release` action
1010/// creates the release and *then* uploads the assets, so a half-populated
1011/// release is an observed state rather than a theoretical one.
1012///
1013/// Only assets embedding `-v<version>-` count. `greentic-pack` also attaches
1014/// unversioned `greentic-pack-<target>.tgz` aliases that carry no checksums —
1015/// deliberate `binstall` shims, not a half-finished upload — and demanding a
1016/// `.sha256` for those would reject every pack release ever published.
1017///
1018/// KNOWN LIMITATION: this cannot detect a release whose upload is partway
1019/// through its *first* target, because it has no notion of the expected target
1020/// matrix. Nothing available makes that knowable cheaply — `ensure-release`
1021/// creates the release without `--draft` (so there is no atomic publish
1022/// marker), the target set varies per package (`include-macos-intel`), and the
1023/// manifest's `bins` holds the delegated binary name, not the archive prefix
1024/// (greentic-mcp declares `greentic-mcp` but ships `greentic-mcp-exec-*` and
1025/// `greentic-mcp-generator-*`), so it cannot drive a per-binary check either.
1026/// The residual window is one `gh release upload` invocation — all assets go up
1027/// in a single call — against the 35-45 minutes of build time this does cover.
1028fn check_release_assets(assets: &[String], version: &str) -> Result<(), String> {
1029    let names: BTreeSet<&str> = assets.iter().map(String::as_str).collect();
1030    let version_marker = format!("-v{version}-");
1031    let archives: Vec<&str> = names
1032        .iter()
1033        .copied()
1034        .filter(|name| {
1035            name.contains(&version_marker)
1036                && RELEASE_ARCHIVE_SUFFIXES
1037                    .iter()
1038                    .any(|suffix| name.ends_with(suffix))
1039        })
1040        .collect();
1041    if archives.is_empty() {
1042        return Err(format!(
1043            "release has no v{version} archives yet (upload in progress)"
1044        ));
1045    }
1046    let unchecksummed: Vec<&str> = archives
1047        .iter()
1048        .copied()
1049        .filter(|name| !names.contains(format!("{name}.sha256").as_str()))
1050        .collect();
1051    if !unchecksummed.is_empty() {
1052        return Err(format!(
1053            "archives missing .sha256 (upload in progress): {}",
1054            unchecksummed.join(", ")
1055        ));
1056    }
1057    Ok(())
1058}
1059
1060fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
1061    let mut out = BTreeMap::new();
1062    if let Some(source) = source {
1063        for package in &source.packages {
1064            out.insert(package.crate_name.clone(), package.version.clone());
1065        }
1066    }
1067    out
1068}
1069
1070fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
1071    fs::create_dir_all(out_dir)
1072        .with_context(|| format!("failed to create {}", out_dir.display()))?;
1073    let path = out_dir.join(manifest_file_name(manifest));
1074    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1075    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
1076    Ok(path)
1077}
1078
1079fn manifest_file_name(manifest: &ToolchainManifest) -> String {
1080    match manifest.channel.as_deref() {
1081        Some("stable") | None => format!("gtc-{}.json", manifest.version),
1082        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
1083    }
1084}
1085
1086fn created_at_now() -> Result<String> {
1087    OffsetDateTime::now_utc()
1088        .format(&Rfc3339)
1089        .context("failed to format current time")
1090}
1091
1092/// Outcome of resolving the research (`-rnd`) version of a toolchain crate.
1093///
1094/// Only `start`/`runner`/`setup` carry `-research` builds; the other ~10
1095/// delegated toolchain crates have no `<name>-rnd` published. Resolving those
1096/// must not be a hard error — it is an expected "no research build" signal that
1097/// the caller turns into a skip, so the research channel still assembles.
1098pub enum ResearchVersion {
1099    /// The `<name>-rnd` crate is published; pin this exact version.
1100    Pinned(String),
1101    /// The `<name>-rnd` crate is not published on crates.io (HTTP 404). The
1102    /// tool ships no research build and must be skipped on the research channel
1103    /// rather than aborting the whole install / manifest assembly.
1104    Absent,
1105}
1106
1107pub trait CrateVersionResolver {
1108    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
1109
1110    /// Channel-aware resolution. The research (`rnd`) lane publishes base-name
1111    /// crates at `X.Y.Z-research` PRERELEASES (greentic-runner's
1112    /// research-publish.yml), which `resolve_latest`'s `max_stable_version`
1113    /// preference silently skips — so the dev/stable behaviour returns the old
1114    /// stable (e.g. `0.5.x`) instead of the current `1.2.0-research`. The
1115    /// default delegates to `resolve_latest` (correct for dev/stable).
1116    fn resolve_latest_for_channel(
1117        &self,
1118        crate_name: &str,
1119        _channel: ToolchainChannel,
1120    ) -> Result<String> {
1121        self.resolve_latest(crate_name)
1122    }
1123
1124    /// Resolve the research (`-rnd`) version, distinguishing an unpublished
1125    /// crate (HTTP 404 → [`ResearchVersion::Absent`]) from a genuine resolution
1126    /// error. The default treats every resolvable crate as
1127    /// [`ResearchVersion::Pinned`]; only the crates.io resolver can observe a
1128    /// 404, so it overrides this.
1129    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1130        self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
1131            .map(ResearchVersion::Pinned)
1132    }
1133}
1134
1135/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
1136/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
1137/// this is preferred over shelling out to `cargo search`.
1138fn default_resolver() -> CratesIoApiVersionResolver {
1139    CratesIoApiVersionResolver::default()
1140}
1141
1142pub trait ArtifactVersionResolver {
1143    fn resolve_latest(&self, package: &str) -> Result<String>;
1144}
1145
1146const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
1147const CRATES_IO_USER_AGENT: &str = concat!(
1148    "greentic-dev/",
1149    env!("CARGO_PKG_VERSION"),
1150    " (https://github.com/greenticai/greentic-dev)"
1151);
1152
1153/// Resolve the latest published version of a crate by hitting the crates.io
1154/// HTTP API directly. Returns `max_stable_version` when present, falling back
1155/// to `newest_version` and then `max_version`. Replaces an earlier
1156/// `cargo search`-based resolver that ranked results by relevance and parsed
1157/// stdout heuristically — both brittle for `<name>-dev` aliases that share
1158/// prefixes with their stable parents.
1159pub struct CratesIoApiVersionResolver {
1160    base_url: String,
1161    client: reqwest::blocking::Client,
1162}
1163
1164impl Default for CratesIoApiVersionResolver {
1165    fn default() -> Self {
1166        Self::new(CRATES_IO_API_BASE)
1167    }
1168}
1169
1170impl CratesIoApiVersionResolver {
1171    pub fn new(base_url: impl Into<String>) -> Self {
1172        let client = reqwest::blocking::Client::builder()
1173            .user_agent(CRATES_IO_USER_AGENT)
1174            .build()
1175            .expect("failed to build crates.io API client");
1176        Self {
1177            base_url: base_url.into(),
1178            client,
1179        }
1180    }
1181
1182    /// GET the crates.io page for `crate_name`. `Ok(None)` when the crate is
1183    /// absent (HTTP 404), `Ok(Some(body))` on success, `Err` on any other
1184    /// status or transport failure. Lets callers treat "no such crate" as a
1185    /// skip rather than a hard error.
1186    fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
1187        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1188        let response = self
1189            .client
1190            .get(&url)
1191            .send()
1192            .with_context(|| format!("failed to GET {url}"))?;
1193        let status = response.status();
1194        let body = response
1195            .text()
1196            .with_context(|| format!("failed to read body of {url}"))?;
1197        classify_crate_response(status, &url, body)
1198    }
1199}
1200
1201/// Classify a crates.io crate-page response by HTTP status: `Ok(None)` for a
1202/// 404 (crate absent), `Ok(Some(body))` for success, `Err` otherwise. Pure so
1203/// the 404-vs-error decision is unit-testable without a live HTTP round-trip.
1204fn classify_crate_response(
1205    status: reqwest::StatusCode,
1206    url: &str,
1207    body: String,
1208) -> Result<Option<String>> {
1209    if status == reqwest::StatusCode::NOT_FOUND {
1210        return Ok(None);
1211    }
1212    if !status.is_success() {
1213        bail!("crates.io API GET {url} returned {status}: {body}");
1214    }
1215    Ok(Some(body))
1216}
1217
1218/// Pick the research version from a crates.io body, or fall back to the latest
1219/// published version when no `-research` prerelease exists. Toolchain crates
1220/// with no `-research` publish yet keep their latest build so the snapshot still
1221/// assembles; the multi-provider-critical crates (runner/setup/start) carry a
1222/// `-research` build. The fallback is logged so silent staleness stays visible.
1223fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
1224    match parse_crates_io_research_version(crate_name, body) {
1225        Ok(version) => Ok(version),
1226        Err(_) => {
1227            let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
1228            eprintln!(
1229                "note: `{crate_name}` has no -research publish; the research \
1230                 toolchain falls back to latest `{fallback}`"
1231            );
1232            Ok(fallback)
1233        }
1234    }
1235}
1236
1237struct ReleaseArtifactVersionResolver<'a> {
1238    release: &'a str,
1239}
1240
1241impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
1242    fn resolve_latest(&self, _package: &str) -> Result<String> {
1243        Ok(self.release.to_string())
1244    }
1245}
1246
1247struct GhcrArtifactVersionResolver {
1248    client: reqwest::blocking::Client,
1249    registry: String,
1250    namespace: String,
1251    basic_token: Option<String>,
1252}
1253
1254impl GhcrArtifactVersionResolver {
1255    fn new(raw_token: Option<&str>) -> Result<Self> {
1256        Ok(Self {
1257            client: reqwest::blocking::Client::builder()
1258                .build()
1259                .context("failed to build GHCR HTTP client")?,
1260            registry: "ghcr.io".to_string(),
1261            namespace: "greenticai".to_string(),
1262            basic_token: resolve_registry_token(raw_token)?
1263                .or_else(|| std::env::var("GHCR_TOKEN").ok())
1264                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
1265        })
1266    }
1267
1268    fn bearer_token(&self, repository: &str) -> Result<String> {
1269        let scope = format!("repository:{repository}:pull");
1270        let mut request = self
1271            .client
1272            .get(format!("https://{}/token", self.registry))
1273            .query(&[
1274                ("service", self.registry.as_str()),
1275                ("scope", scope.as_str()),
1276            ]);
1277        if let Some(token) = &self.basic_token {
1278            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
1279        }
1280        let response = request
1281            .send()
1282            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
1283            .error_for_status()
1284            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
1285        let body: GhcrTokenResponse = response
1286            .json()
1287            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1288        Ok(body.token)
1289    }
1290
1291    fn tags(&self, repository: &str) -> Result<Vec<String>> {
1292        let token = self.bearer_token(repository)?;
1293        let response = self
1294            .client
1295            .get(format!(
1296                "https://{}/v2/{repository}/tags/list",
1297                self.registry
1298            ))
1299            .bearer_auth(token)
1300            .send()
1301            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1302            .error_for_status()
1303            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1304        let body: GhcrTagsResponse = response
1305            .json()
1306            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1307        Ok(body.tags)
1308    }
1309}
1310
1311impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1312    fn resolve_latest(&self, package: &str) -> Result<String> {
1313        let repository = format!("{}/{}", self.namespace, package);
1314        let tags = self.tags(&repository)?;
1315        select_latest_artifact_tag(&tags)
1316            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1317    }
1318}
1319
1320#[derive(Deserialize)]
1321struct GhcrTokenResponse {
1322    token: String,
1323}
1324
1325#[derive(Deserialize)]
1326struct GhcrTagsResponse {
1327    #[serde(default)]
1328    tags: Vec<String>,
1329}
1330
1331fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1332    tags.iter()
1333        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1334        .max_by(|(left, _), (right, _)| left.cmp(right))
1335        .map(|(_, tag)| tag.clone())
1336        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1337        .context("no semver or latest tags found")
1338}
1339
1340impl CrateVersionResolver for CratesIoApiVersionResolver {
1341    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1342        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1343        let response = self
1344            .client
1345            .get(&url)
1346            .send()
1347            .with_context(|| format!("failed to GET {url}"))?;
1348        let status = response.status();
1349        let body = response
1350            .text()
1351            .with_context(|| format!("failed to read body of {url}"))?;
1352        if !status.is_success() {
1353            bail!("crates.io API GET {url} returned {status}: {body}");
1354        }
1355        parse_crates_io_version(crate_name, &body)
1356    }
1357
1358    fn resolve_latest_for_channel(
1359        &self,
1360        crate_name: &str,
1361        channel: ToolchainChannel,
1362    ) -> Result<String> {
1363        if channel != ToolchainChannel::Rnd {
1364            return self.resolve_latest(crate_name);
1365        }
1366        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1367        let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1368            anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1369        })?;
1370        research_or_fallback(crate_name, &body)
1371    }
1372
1373    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1374        // A 404 means the `<name>-rnd` crate is simply not published — the tool
1375        // ships no research build. Map it to `Absent` (a skip signal) instead of
1376        // aborting, so `gtc-research install` / manifest assembly survives the
1377        // ~10 of 13 toolchain crates that have no research line.
1378        match self.fetch_crate_body(crate_name)? {
1379            None => Ok(ResearchVersion::Absent),
1380            Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1381        }
1382    }
1383}
1384
1385/// Pick the highest non-yanked version from the crates.io `/crates/<name>`
1386/// response's top-level `versions` array. When `research_only`, restricts to
1387/// `-research` prereleases (the research lane publishes `X.Y.Z-research`, which
1388/// `max_stable_version` skips). Otherwise picks the highest semver of ANY
1389/// channel — the fallback for toolchain crates with no `-research` build, which
1390/// keeps them at their latest dev build instead of regressing to old stable.
1391fn pick_highest_crates_io_version(
1392    crate_name: &str,
1393    body: &str,
1394    research_only: bool,
1395) -> Result<String> {
1396    let payload: serde_json::Value = serde_json::from_str(body)
1397        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1398    let versions = payload
1399        .get("versions")
1400        .and_then(|v| v.as_array())
1401        .ok_or_else(|| {
1402            anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1403        })?;
1404    let mut best: Option<Version> = None;
1405    for entry in versions {
1406        if entry
1407            .get("yanked")
1408            .and_then(serde_json::Value::as_bool)
1409            .unwrap_or(false)
1410        {
1411            continue;
1412        }
1413        let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1414            continue;
1415        };
1416        let Ok(parsed) = Version::parse(num) else {
1417            continue;
1418        };
1419        if research_only && !parsed.pre.as_str().starts_with("research") {
1420            continue;
1421        }
1422        if best.as_ref().is_none_or(|current| parsed > *current) {
1423            best = Some(parsed);
1424        }
1425    }
1426    best.map(|v| v.to_string()).ok_or_else(|| {
1427        let what = if research_only {
1428            "no non-yanked `-research` version"
1429        } else {
1430            "no non-yanked versions"
1431        };
1432        anyhow!("crates.io API for `{crate_name}` exposes {what}")
1433    })
1434}
1435
1436fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1437    pick_highest_crates_io_version(crate_name, body, true)
1438}
1439
1440fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1441    let payload: serde_json::Value = serde_json::from_str(body)
1442        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1443    let crate_obj = payload.get("crate").ok_or_else(|| {
1444        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1445    })?;
1446    let version = crate_obj
1447        .get("max_stable_version")
1448        .and_then(|v| v.as_str())
1449        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1450        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1451        .ok_or_else(|| {
1452            anyhow!(
1453                "crates.io API for `{crate_name}` does not expose max_stable_version, \
1454                 newest_version, or max_version"
1455            )
1456        })?;
1457    Version::parse(version).with_context(|| {
1458        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1459    })?;
1460    Ok(version.to_string())
1461}
1462
1463#[async_trait]
1464trait ToolchainManifestSource {
1465    async fn load_manifest(
1466        &self,
1467        repo: &str,
1468        tag: &str,
1469        token: Option<&str>,
1470    ) -> Result<Option<ToolchainManifest>>;
1471}
1472
1473struct OciToolchainManifestSource;
1474
1475#[async_trait]
1476impl ToolchainManifestSource for OciToolchainManifestSource {
1477    async fn load_manifest(
1478        &self,
1479        repo: &str,
1480        tag: &str,
1481        token: Option<&str>,
1482    ) -> Result<Option<ToolchainManifest>> {
1483        let auth = optional_registry_auth(token)?;
1484        let client = oci_client();
1485        let reference = parse_reference(repo, tag)?;
1486        let image = match client
1487            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1488            .await
1489        {
1490            Ok(image) => image,
1491            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1492                return Ok(None);
1493            }
1494            Err(err) => {
1495                return Err(err)
1496                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1497            }
1498        };
1499        let Some(layer) = image
1500            .layers
1501            .into_iter()
1502            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1503        else {
1504            return Ok(None);
1505        };
1506        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1507            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1508        validate_manifest(&manifest)?;
1509        Ok(Some(manifest))
1510    }
1511}
1512
1513async fn load_source_manifest(
1514    repo: &str,
1515    tag: &str,
1516    token: Option<&str>,
1517) -> Result<Option<ToolchainManifest>> {
1518    OciToolchainManifestSource
1519        .load_manifest(repo, tag, token)
1520        .await
1521}
1522
1523fn oci_client() -> Client {
1524    Client::new(ClientConfig {
1525        protocol: ClientProtocol::Https,
1526        ..Default::default()
1527    })
1528}
1529
1530// ---------------------------------------------------------------------------
1531// Updater dispatch — notify the coordinated update-plan workflow
1532// ---------------------------------------------------------------------------
1533
1534/// Returns `true` when `version` is a plain `X.Y.Z` (no pre-release, no build
1535/// metadata) AND `channel` is `"stable"`. The update server must only ever
1536/// receive stable-lane content; everything else (dev, rnd, run-id versions)
1537/// is silently skipped.
1538fn should_notify_updater(version: &str, channel: &str) -> bool {
1539    if channel != "stable" {
1540        return false;
1541    }
1542    match Version::parse(version) {
1543        Ok(v) => v.pre.is_empty() && v.build.is_empty(),
1544        Err(_) => false,
1545    }
1546}
1547
1548/// Resolve a GitHub token from `--token`, then the ambient CI environment. An
1549/// empty or whitespace-only value counts as absent. Reads of public release
1550/// metadata work without one; only the dispatch strictly needs it.
1551fn ambient_github_token(raw_token: Option<&str>) -> Option<String> {
1552    resolve_registry_token(raw_token)
1553        .ok()
1554        .flatten()
1555        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1556        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1557        .filter(|token| !token.trim().is_empty())
1558}
1559
1560/// Fire a `repository_dispatch` to trigger the coordinated update-plan
1561/// publisher workflow. Failure is a warning, never fatal — the GHCR manifest
1562/// push already succeeded, and the workflow has a manual `workflow_dispatch`
1563/// fallback.
1564fn notify_updater_dispatch(version: &str, channel: &str, raw_token: Option<&str>) {
1565    if !should_notify_updater(version, channel) {
1566        eprintln!(
1567            "Skipping updater dispatch: version `{version}` / channel `{channel}` \
1568             is not a stable-lane release"
1569        );
1570        return;
1571    }
1572
1573    let Some(token) = ambient_github_token(raw_token) else {
1574        eprintln!(
1575            "Warning: skipping updater dispatch — no token available \
1576             (pass --token or set GHCR_TOKEN/GITHUB_TOKEN)"
1577        );
1578        return;
1579    };
1580
1581    let client = match reqwest::blocking::Client::builder()
1582        .user_agent("greentic-dev-cli")
1583        .build()
1584    {
1585        Ok(c) => c,
1586        Err(e) => {
1587            eprintln!("Warning: failed to build HTTP client for updater dispatch: {e}");
1588            return;
1589        }
1590    };
1591
1592    let body = serde_json::json!({
1593        "event_type": "toolchain-release-published",
1594        "client_payload": {
1595            "release": version,
1596            "channel": channel,
1597        }
1598    });
1599
1600    let url = "https://api.github.com/repos/greenticai/greentic-dev/dispatches";
1601    match client
1602        .post(url)
1603        .header("Accept", "application/vnd.github+json")
1604        .bearer_auth(&token)
1605        .json(&body)
1606        .send()
1607    {
1608        Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
1609            eprintln!("Dispatched toolchain-release-published for {version} (channel={channel})");
1610        }
1611        Ok(resp) => {
1612            let status = resp.status();
1613            let text = resp.text().unwrap_or_default();
1614            eprintln!("Warning: updater dispatch returned HTTP {status}: {text}");
1615        }
1616        Err(e) => {
1617            eprintln!("Warning: updater dispatch failed: {e}");
1618        }
1619    }
1620}
1621
1622fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1623    let token = resolve_registry_token(raw_token)?
1624        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1625        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1626        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1627    if token.trim().is_empty() {
1628        bail!("GHCR token is empty");
1629    }
1630    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1631}
1632
1633fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1634    match registry_auth(raw_token) {
1635        Ok(auth) => Ok(auth),
1636        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1637        Err(err) => Err(err),
1638    }
1639}
1640
1641fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1642    let Some(raw_token) = raw_token else {
1643        return Ok(None);
1644    };
1645    if let Some(var) = raw_token.strip_prefix("env:") {
1646        let token =
1647            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1648        if token.trim().is_empty() {
1649            bail!("env var {var} resolved to an empty token");
1650        }
1651        return Ok(Some(token));
1652    }
1653    if raw_token.trim().is_empty() {
1654        bail!("GHCR token is empty");
1655    }
1656    Ok(Some(raw_token.to_string()))
1657}
1658
1659fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1660    Reference::from_str(&toolchain_ref(repo, tag))
1661        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1662}
1663
1664async fn manifest_exists(
1665    client: &Client,
1666    reference: &Reference,
1667    auth: &RegistryAuth,
1668) -> Result<bool> {
1669    match client.pull_manifest(reference, auth).await {
1670        Ok(_) => Ok(true),
1671        Err(err) if is_missing_manifest_error(&err) => Ok(false),
1672        Err(err) => Err(err).context("failed to check whether release tag exists"),
1673    }
1674}
1675
1676fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1677    let msg = err.to_string().to_ascii_lowercase();
1678    msg.contains("manifest unknown")
1679        || msg.contains("name unknown")
1680        || msg.contains("not found")
1681        || msg.contains("404")
1682}
1683
1684fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1685    let msg = err.to_string().to_ascii_lowercase();
1686    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1687}
1688
1689async fn push_manifest_layer(
1690    client: &Client,
1691    reference: &Reference,
1692    auth: &RegistryAuth,
1693    manifest: &ToolchainManifest,
1694) -> Result<()> {
1695    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1696    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1697    let config = Config::new(
1698        br#"{"toolchain":"gtc"}"#.to_vec(),
1699        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1700        None,
1701    );
1702    client
1703        .push(reference, &[layer], config, auth, None)
1704        .await
1705        .context("failed to push toolchain manifest")?;
1706    Ok(())
1707}
1708
1709#[cfg(test)]
1710mod tests {
1711    use super::*;
1712    use once_cell::sync::Lazy;
1713    use std::sync::Mutex;
1714
1715    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1716
1717    struct FixedResolver;
1718
1719    impl CrateVersionResolver for FixedResolver {
1720        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1721            Ok(match crate_name {
1722                "greentic-runner" => "0.5.10",
1723                _ => "1.2.3",
1724            }
1725            .to_string())
1726        }
1727    }
1728
1729    struct FixedArtifactResolver;
1730
1731    impl ArtifactVersionResolver for FixedArtifactResolver {
1732        fn resolve_latest(&self, package: &str) -> Result<String> {
1733            Ok(match package {
1734                "packs/messaging/messaging-webchat-gui" => "0.4.93",
1735                "components/component-adaptive-card" => "0.5.8",
1736                _ => "0.1.0",
1737            }
1738            .to_string())
1739        }
1740    }
1741
1742    #[test]
1743    fn parses_crates_io_max_stable_version() {
1744        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1745        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1746        assert_eq!(version, "0.5.123");
1747    }
1748
1749    #[test]
1750    fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1751        // The research lane must ignore the stable `max_stable_version` (0.5.48)
1752        // and pick the highest non-yanked `-research` prerelease.
1753        let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1754            "versions":[
1755                {"num":"0.5.48","yanked":false},
1756                {"num":"1.2.0-research.0","yanked":false},
1757                {"num":"1.2.0-research.1","yanked":false},
1758                {"num":"1.2.0-research.2","yanked":true}
1759            ]}"#;
1760        let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1761        assert_eq!(version, "1.2.0-research.1");
1762    }
1763
1764    #[test]
1765    fn research_resolver_errors_when_no_research_version() {
1766        let body = r#"{"crate":{"id":"greentic-setup"},
1767            "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1768        let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1769        assert!(err.to_string().contains("no non-yanked `-research`"));
1770    }
1771
1772    /// Mirrors the real fleet: only start/runner/setup ship `-research` crates,
1773    /// so any `operator` crate resolves to `Absent` (a skip), everything else to
1774    /// a pinned research version.
1775    struct ResearchSkipResolver;
1776
1777    impl CrateVersionResolver for ResearchSkipResolver {
1778        fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1779            Ok("1.2.0-research.4".to_string())
1780        }
1781
1782        fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1783            if crate_name.contains("operator") {
1784                Ok(ResearchVersion::Absent)
1785            } else {
1786                Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1787            }
1788        }
1789    }
1790
1791    #[test]
1792    fn classify_crate_response_maps_404_to_absent() {
1793        let outcome =
1794            classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1795                .unwrap();
1796        assert!(outcome.is_none(), "404 must classify as absent (None)");
1797    }
1798
1799    #[test]
1800    fn classify_crate_response_returns_body_on_success() {
1801        let outcome =
1802            classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1803        assert_eq!(outcome.as_deref(), Some("payload"));
1804    }
1805
1806    #[test]
1807    fn classify_crate_response_errors_on_other_status() {
1808        let err = classify_crate_response(
1809            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1810            "url",
1811            "boom".to_string(),
1812        )
1813        .unwrap_err();
1814        assert!(err.to_string().contains("500"));
1815    }
1816
1817    #[test]
1818    fn snapshot_manifest_skips_crates_without_research_build() {
1819        // The absent `operator` crate must be omitted, not abort the whole
1820        // research manifest — the regression behind .github#212's install hang.
1821        let manifest = snapshot_manifest(
1822            "1.2.0-research.4",
1823            ToolchainChannel::Rnd,
1824            &ResearchSkipResolver,
1825            None,
1826        )
1827        .unwrap();
1828        assert!(
1829            !manifest.packages.is_empty(),
1830            "research-built tools must remain in the manifest"
1831        );
1832        assert!(
1833            manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1834            "at least one tool without a research build must be skipped"
1835        );
1836        assert!(
1837            manifest
1838                .packages
1839                .iter()
1840                .all(|package| !package.crate_name.contains("operator")),
1841            "the absent `operator` crate must be omitted"
1842        );
1843        assert!(
1844            manifest
1845                .packages
1846                .iter()
1847                .all(|package| package.version == "1.2.0-research.4"),
1848            "remaining research tools pin their resolved -research version"
1849        );
1850    }
1851
1852    #[test]
1853    fn parses_crates_io_falls_back_to_newest_version() {
1854        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1855        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1856        assert_eq!(version, "0.6.7");
1857    }
1858
1859    #[test]
1860    fn parses_crates_io_falls_back_to_max_version() {
1861        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1862        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1863        assert_eq!(version, "0.4.99");
1864    }
1865
1866    #[test]
1867    fn rejects_crates_io_payload_without_versions() {
1868        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1869        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1870        assert!(
1871            err.to_string()
1872                .contains("does not expose max_stable_version")
1873        );
1874    }
1875
1876    #[test]
1877    fn rejects_crates_io_payload_with_unparseable_version() {
1878        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1879        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1880        assert!(err.to_string().contains("unparseable version"));
1881    }
1882
1883    #[test]
1884    fn rejects_crates_io_payload_without_crate_object() {
1885        let body = r#"{"errors":[{"detail":"not found"}]}"#;
1886        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1887        assert!(
1888            err.to_string()
1889                .contains("missing the top-level `crate` object")
1890        );
1891    }
1892
1893    #[test]
1894    fn selects_latest_semver_tag() {
1895        let tags = vec![
1896            "latest".to_string(),
1897            "0.4.93".to_string(),
1898            "0.4.9".to_string(),
1899            "1.0.0-beta.1".to_string(),
1900            "1.0.0".to_string(),
1901        ];
1902
1903        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1904    }
1905
1906    #[test]
1907    fn selects_latest_tag_when_no_semver_tags_exist() {
1908        let tags = vec!["latest".to_string()];
1909
1910        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1911    }
1912
1913    #[test]
1914    fn generates_manifest_from_catalogue() {
1915        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1916        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1917        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1918        assert_eq!(manifest.version, "1.0.5");
1919        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1920        assert!(
1921            manifest
1922                .packages
1923                .iter()
1924                .any(|package| package.crate_name == "greentic-bundle"
1925                    && package.bins == ["greentic-bundle"])
1926        );
1927        assert!(
1928            manifest
1929                .packages
1930                .iter()
1931                .any(|package| package.crate_name == "greentic-runner"
1932                    && package.bins == ["greentic-runner"])
1933        );
1934        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1935        assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1936        assert!(
1937            manifest
1938                .extension_packs
1939                .as_ref()
1940                .unwrap()
1941                .iter()
1942                .all(|item| item.version == "1.0.5")
1943        );
1944        assert!(
1945            manifest
1946                .components
1947                .as_ref()
1948                .unwrap()
1949                .iter()
1950                .all(|item| item.version == "1.0.5")
1951        );
1952    }
1953
1954    #[test]
1955    fn generated_manifest_can_use_artifact_resolver_versions() {
1956        let manifest = generate_manifest_with_artifact_resolver(
1957            "1.0.17",
1958            "stable",
1959            None,
1960            &FixedResolver,
1961            &FixedArtifactResolver,
1962            None,
1963        )
1964        .unwrap();
1965
1966        assert!(
1967            manifest
1968                .extension_packs
1969                .as_ref()
1970                .unwrap()
1971                .iter()
1972                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1973                    && item.version == "0.4.93")
1974        );
1975        assert!(
1976            manifest
1977                .components
1978                .as_ref()
1979                .unwrap()
1980                .iter()
1981                .any(|item| item.id == "components/component-adaptive-card"
1982                    && item.version == "0.5.8")
1983        );
1984    }
1985
1986    #[test]
1987    fn source_manifest_can_pin_package_versions() {
1988        let source = ToolchainManifest {
1989            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1990            toolchain: TOOLCHAIN_NAME.to_string(),
1991            version: "latest".to_string(),
1992            channel: Some("latest".to_string()),
1993            created_at: None,
1994            packages: vec![ToolchainPackage {
1995                crate_name: "greentic-dev".to_string(),
1996                bins: vec!["greentic-dev".to_string()],
1997                version: "0.5.9".to_string(),
1998            }],
1999            extension_packs: None,
2000            components: None,
2001        };
2002        let manifest =
2003            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
2004        let greentic_dev = manifest
2005            .packages
2006            .iter()
2007            .find(|package| package.crate_name == "greentic-dev")
2008            .unwrap();
2009        assert_eq!(greentic_dev.version, "0.5.9");
2010    }
2011
2012    #[test]
2013    fn from_argument_controls_generated_channel_over_source_manifest() {
2014        let source = ToolchainManifest {
2015            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2016            toolchain: TOOLCHAIN_NAME.to_string(),
2017            version: "latest".to_string(),
2018            channel: Some("stable".to_string()),
2019            created_at: None,
2020            packages: Vec::new(),
2021            extension_packs: None,
2022            components: None,
2023        };
2024        let manifest =
2025            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
2026        assert_eq!(manifest.channel.as_deref(), Some("dev"));
2027        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
2028    }
2029
2030    #[test]
2031    fn generate_from_dev_uses_dev_crate_and_binary_names() {
2032        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
2033        assert!(
2034            manifest
2035                .packages
2036                .iter()
2037                .flat_map(|package| package.bins.iter())
2038                .all(|bin| bin.ends_with("-dev"))
2039        );
2040        assert!(
2041            manifest
2042                .packages
2043                .iter()
2044                .all(|package| package.crate_name.ends_with("-dev")),
2045            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
2046        );
2047        assert!(manifest.packages.iter().any(|package| {
2048            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
2049        }));
2050        assert!(manifest.packages.iter().any(|package| {
2051            package.crate_name == "greentic-component-dev"
2052                && package.bins == ["greentic-component-dev"]
2053        }));
2054        assert!(manifest.packages.iter().any(|package| {
2055            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2056        }));
2057    }
2058
2059    #[test]
2060    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
2061        let manifest =
2062            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
2063                .unwrap();
2064        assert_eq!(manifest.version, "1.1.5");
2065        assert_eq!(manifest.channel.as_deref(), Some("dev"));
2066        for package in &manifest.packages {
2067            assert!(
2068                package.crate_name.ends_with("-dev"),
2069                "dev snapshot must pin -dev crate names; got {}",
2070                package.crate_name
2071            );
2072            assert!(
2073                package.bins.iter().all(|bin| bin.ends_with("-dev")),
2074                "dev snapshot must pin -dev bin names; got {:?}",
2075                package.bins
2076            );
2077            assert_ne!(
2078                package.version, "latest",
2079                "snapshot must always resolve concrete versions"
2080            );
2081        }
2082        assert!(
2083            manifest
2084                .packages
2085                .iter()
2086                .any(|package| package.crate_name == "greentic-operator-dev")
2087        );
2088    }
2089
2090    #[test]
2091    fn snapshot_manifest_stable_channel_keeps_plain_names() {
2092        let manifest =
2093            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
2094        assert_eq!(manifest.channel.as_deref(), Some("stable"));
2095        // The stable channel must NOT apply the `-dev` suffix transform.
2096        // Cross-check against the catalogue: every stable package must match
2097        // a catalogue entry by exact name (no transform applied).
2098        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
2099            .iter()
2100            .map(|spec| spec.crate_name)
2101            .collect();
2102        for package in &manifest.packages {
2103            assert!(
2104                catalogue_names.contains(package.crate_name.as_str()),
2105                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
2106                package.crate_name
2107            );
2108        }
2109    }
2110
2111    #[test]
2112    fn snapshot_manifest_resolves_via_resolver() {
2113        let manifest =
2114            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
2115                .unwrap();
2116        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
2117        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
2118        // so the special case in FixedResolver does not apply and every
2119        // package should land on the default 1.2.3 — proving the resolver was
2120        // hit (rather than versions copied from somewhere).
2121        for package in &manifest.packages {
2122            assert_eq!(
2123                package.version, "1.2.3",
2124                "resolver must be hit for {}",
2125                package.crate_name
2126            );
2127        }
2128    }
2129
2130    #[test]
2131    fn parses_dev_channel_argument() {
2132        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
2133        assert_eq!(
2134            parse_channel("development").unwrap(),
2135            ToolchainChannel::Development
2136        );
2137        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
2138        assert!(parse_channel("rc").is_err());
2139    }
2140
2141    #[test]
2142    fn detects_concrete_pins_for_publish_deprecation_warning() {
2143        let with_pins = ToolchainManifest {
2144            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2145            toolchain: TOOLCHAIN_NAME.to_string(),
2146            version: "0.0.1".to_string(),
2147            channel: Some("dev".to_string()),
2148            created_at: None,
2149            packages: vec![ToolchainPackage {
2150                crate_name: "greentic-operator-dev".to_string(),
2151                bins: vec!["greentic-operator-dev".to_string()],
2152                version: "0.5.123".to_string(),
2153            }],
2154            extension_packs: None,
2155            components: None,
2156        };
2157        assert!(source_manifest_has_concrete_pins(&with_pins));
2158
2159        let only_latest = ToolchainManifest {
2160            packages: vec![ToolchainPackage {
2161                crate_name: "greentic-operator".to_string(),
2162                bins: vec!["greentic-operator".to_string()],
2163                version: "latest".to_string(),
2164            }],
2165            ..with_pins
2166        };
2167        assert!(!source_manifest_has_concrete_pins(&only_latest));
2168    }
2169
2170    #[test]
2171    fn generate_from_rnd_uses_rnd_binary_names() {
2172        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
2173        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
2174        assert!(
2175            manifest
2176                .packages
2177                .iter()
2178                .flat_map(|package| package.bins.iter())
2179                .all(|bin| bin.ends_with("-rnd"))
2180        );
2181        assert!(manifest.packages.iter().any(|package| {
2182            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
2183        }));
2184    }
2185
2186    #[test]
2187    fn bootstrap_source_manifest_uses_source_tag_identity() {
2188        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
2189        assert_eq!(manifest.version, "latest");
2190        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2191        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2192        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2193        assert!(
2194            manifest
2195                .packages
2196                .iter()
2197                .all(|package| package.version != "latest")
2198        );
2199    }
2200
2201    #[test]
2202    fn validates_schema_and_toolchain() {
2203        let mut manifest =
2204            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2205        assert!(validate_manifest(&manifest).is_ok());
2206        manifest.schema = "wrong".to_string();
2207        assert!(validate_manifest(&manifest).is_err());
2208        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
2209        manifest.toolchain = "other".to_string();
2210        assert!(validate_manifest(&manifest).is_err());
2211    }
2212
2213    #[test]
2214    fn resolves_inline_registry_token() {
2215        assert_eq!(
2216            resolve_registry_token(Some("secret-token"))
2217                .unwrap()
2218                .as_deref(),
2219            Some("secret-token")
2220        );
2221    }
2222
2223    #[test]
2224    fn resolves_registry_token_from_environment_reference() {
2225        let _guard = ENV_LOCK.lock().unwrap();
2226        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2227        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
2228
2229        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
2230        assert_eq!(resolved.as_deref(), Some("env-secret"));
2231
2232        match previous {
2233            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2234            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2235        }
2236    }
2237
2238    #[test]
2239    fn rejects_empty_registry_token_from_environment_reference() {
2240        let _guard = ENV_LOCK.lock().unwrap();
2241        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2242        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
2243
2244        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
2245        assert!(err.to_string().contains("resolved to an empty token"));
2246
2247        match previous {
2248            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2249            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2250        }
2251    }
2252
2253    #[test]
2254    fn registry_auth_uses_environment_fallbacks() {
2255        let _guard = ENV_LOCK.lock().unwrap();
2256        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2257        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2258        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
2259        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2260
2261        let auth = registry_auth(None).unwrap();
2262        match auth {
2263            RegistryAuth::Basic(user, token) => {
2264                assert_eq!(user, DEFAULT_OAUTH_USER);
2265                assert_eq!(token, "ghcr-secret");
2266            }
2267            _ => panic!("expected basic auth"),
2268        }
2269
2270        match previous_ghcr {
2271            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2272            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2273        }
2274        match previous_github {
2275            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2276            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2277        }
2278    }
2279
2280    #[test]
2281    fn optional_registry_auth_allows_missing_implicit_token() {
2282        let _guard = ENV_LOCK.lock().unwrap();
2283        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2284        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2285        unsafe { std::env::remove_var("GHCR_TOKEN") };
2286        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2287
2288        let auth = optional_registry_auth(None).unwrap();
2289        assert!(matches!(auth, RegistryAuth::Anonymous));
2290
2291        match previous_ghcr {
2292            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2293            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2294        }
2295        match previous_github {
2296            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2297            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2298        }
2299    }
2300
2301    #[test]
2302    fn release_view_tag_prefers_release_or_tag() {
2303        let args = ReleaseViewArgs {
2304            release: Some("1.0.5".to_string()),
2305            tag: None,
2306            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2307            token: None,
2308        };
2309        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
2310
2311        let args = ReleaseViewArgs {
2312            release: None,
2313            tag: Some("stable".to_string()),
2314            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2315            token: None,
2316        };
2317        assert_eq!(release_view_tag(&args).unwrap(), "stable");
2318    }
2319
2320    #[test]
2321    fn release_view_tag_rejects_invalid_argument_combinations() {
2322        let err = release_view_tag(&ReleaseViewArgs {
2323            release: Some("1.0.5".to_string()),
2324            tag: Some("stable".to_string()),
2325            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2326            token: None,
2327        })
2328        .unwrap_err();
2329        assert!(
2330            err.to_string()
2331                .contains("pass exactly one of --release or --tag")
2332        );
2333
2334        let err = release_view_tag(&ReleaseViewArgs {
2335            release: None,
2336            tag: None,
2337            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2338            token: None,
2339        })
2340        .unwrap_err();
2341        assert!(
2342            err.to_string()
2343                .contains("pass exactly one of --release or --tag")
2344        );
2345    }
2346
2347    #[test]
2348    fn publish_manifest_input_uses_local_manifest_version() {
2349        let dir = tempfile::tempdir().unwrap();
2350        let path = dir.path().join("gtc-1.0.12.json");
2351        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2352        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2353        let args = ReleasePublishArgs {
2354            release: None,
2355            from: None,
2356            tag: Some("stable".to_string()),
2357            manifest: Some(path.clone()),
2358            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2359            token: None,
2360            out: dir.path().to_path_buf(),
2361            dry_run: true,
2362            force: true,
2363            no_notify_updater: true,
2364        };
2365        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2366        assert_eq!(release, "1.0.12");
2367        assert_eq!(loaded, manifest);
2368        assert_eq!(
2369            source_path,
2370            Some(PublishManifestSource::Local(path.clone()))
2371        );
2372    }
2373
2374    #[test]
2375    fn publish_manifest_input_allows_release_override_for_local_manifest() {
2376        let dir = tempfile::tempdir().unwrap();
2377        let path = dir.path().join("gtc-1.0.13.json");
2378        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2379        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2380        let args = ReleasePublishArgs {
2381            release: Some("1.0.13".to_string()),
2382            from: None,
2383            tag: Some("stable".to_string()),
2384            manifest: Some(path.clone()),
2385            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2386            token: None,
2387            out: dir.path().to_path_buf(),
2388            dry_run: true,
2389            force: true,
2390            no_notify_updater: true,
2391        };
2392        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2393        assert_eq!(release, "1.0.13");
2394        assert_eq!(loaded.version, "1.0.13");
2395        assert_eq!(
2396            source_path,
2397            Some(PublishManifestSource::Local(path.clone()))
2398        );
2399    }
2400
2401    #[test]
2402    fn manifest_file_name_omits_stable_channel() {
2403        let manifest = ToolchainManifest {
2404            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2405            toolchain: TOOLCHAIN_NAME.to_string(),
2406            version: "1.0.12".to_string(),
2407            channel: Some("stable".to_string()),
2408            created_at: None,
2409            packages: Vec::new(),
2410            extension_packs: None,
2411            components: None,
2412        };
2413        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2414    }
2415
2416    #[test]
2417    fn parses_manifest_without_extension_sections() {
2418        let manifest: ToolchainManifest = serde_json::from_str(
2419            r#"{
2420              "schema": "greentic.toolchain-manifest.v1",
2421              "toolchain": "gtc",
2422              "version": "1.0.16",
2423              "channel": "stable",
2424              "packages": []
2425            }"#,
2426        )
2427        .unwrap();
2428
2429        assert_eq!(manifest.extension_packs, None);
2430        assert_eq!(manifest.components, None);
2431    }
2432
2433    #[test]
2434    fn generated_manifest_includes_catalogue_extension_sections() {
2435        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2436        let json = serde_json::to_value(&manifest).unwrap();
2437
2438        assert!(json.get("extension_packs").is_some());
2439        assert!(json.get("components").is_some());
2440        assert!(
2441            manifest
2442                .extension_packs
2443                .as_ref()
2444                .unwrap()
2445                .iter()
2446                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2447                    && item.version == "1.0.16")
2448        );
2449        assert!(
2450            manifest
2451                .extension_packs
2452                .as_ref()
2453                .unwrap()
2454                .iter()
2455                .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2456                    && item.version == "1.0.16")
2457        );
2458        assert!(
2459            manifest
2460                .components
2461                .as_ref()
2462                .unwrap()
2463                .iter()
2464                .any(|item| item.id == "component/component-llm-openai"
2465                    && item.version == "1.0.16")
2466        );
2467    }
2468
2469    #[test]
2470    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2471        let source = ToolchainManifest {
2472            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2473            toolchain: TOOLCHAIN_NAME.to_string(),
2474            version: "dev".to_string(),
2475            channel: Some("dev".to_string()),
2476            created_at: None,
2477            packages: Vec::new(),
2478            extension_packs: Some(vec![ExtensionPackRef {
2479                id: "packs/messaging/messaging-webchat-gui".to_string(),
2480                version: "0.5.4".to_string(),
2481            }]),
2482            components: Some(vec![ComponentRef {
2483                id: "components/component-adaptive-card".to_string(),
2484                version: "0.5.8".to_string(),
2485            }]),
2486        };
2487
2488        let manifest =
2489            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2490
2491        assert!(
2492            manifest
2493                .extension_packs
2494                .as_ref()
2495                .unwrap()
2496                .iter()
2497                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2498                    && item.version == "0.5.4")
2499        );
2500        assert!(
2501            manifest
2502                .components
2503                .as_ref()
2504                .unwrap()
2505                .iter()
2506                .any(|item| item.id == "components/component-adaptive-card"
2507                    && item.version == "0.5.8")
2508        );
2509    }
2510
2511    #[test]
2512    fn manifest_file_name_includes_non_stable_channel() {
2513        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2514        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2515
2516        manifest.channel = Some("customer-a".to_string());
2517        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2518    }
2519
2520    #[test]
2521    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2522        assert_eq!(
2523            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2524            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2525        );
2526        assert_eq!(
2527            manifest_bins_for_source("dev", &["greentic-dev"]),
2528            vec!["greentic-dev-dev".to_string()]
2529        );
2530        assert_eq!(
2531            manifest_crate_name_for_source("latest", "greentic-runner"),
2532            "greentic-runner"
2533        );
2534        assert_eq!(
2535            manifest_crate_name_for_source("dev", "greentic-runner"),
2536            "greentic-runner-dev"
2537        );
2538    }
2539
2540    #[test]
2541    fn source_version_map_handles_missing_and_present_sources() {
2542        assert!(source_version_map(None).is_empty());
2543
2544        let source = ToolchainManifest {
2545            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2546            toolchain: TOOLCHAIN_NAME.to_string(),
2547            version: "latest".to_string(),
2548            channel: Some("latest".to_string()),
2549            created_at: None,
2550            packages: vec![ToolchainPackage {
2551                crate_name: "greentic-dev".to_string(),
2552                bins: vec!["greentic-dev".to_string()],
2553                version: "0.6.0".to_string(),
2554            }],
2555            extension_packs: None,
2556            components: None,
2557        };
2558
2559        let versions = source_version_map(Some(&source));
2560        assert_eq!(
2561            versions.get("greentic-dev").map(String::as_str),
2562            Some("0.6.0")
2563        );
2564    }
2565
2566    #[test]
2567    fn write_manifest_persists_json_to_expected_file_name() {
2568        let dir = tempfile::tempdir().unwrap();
2569        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2570
2571        let path = write_manifest(dir.path(), &manifest).unwrap();
2572        assert_eq!(
2573            path.file_name().and_then(|name| name.to_str()),
2574            Some("gtc-dev-1.0.12.json")
2575        );
2576
2577        let roundtrip = read_manifest_file(&path).unwrap();
2578        assert_eq!(roundtrip, manifest);
2579    }
2580
2581    #[test]
2582    fn latest_manifest_uses_latest_dev_bins() {
2583        let manifest = latest_manifest(None);
2584        assert_eq!(manifest.version, "latest");
2585        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2586        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2587        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2588        assert!(!manifest.packages.is_empty());
2589        assert!(
2590            manifest
2591                .packages
2592                .iter()
2593                .all(|package| package.version == "latest")
2594        );
2595        assert!(
2596            manifest
2597                .packages
2598                .iter()
2599                .flat_map(|package| package.bins.iter())
2600                .all(|bin| bin.ends_with("-dev"))
2601        );
2602        assert!(
2603            manifest
2604                .packages
2605                .iter()
2606                .all(|package| package.crate_name.ends_with("-dev")),
2607            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2608        );
2609        assert!(
2610            manifest
2611                .packages
2612                .iter()
2613                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2614        );
2615        assert!(manifest.packages.iter().any(|package| {
2616            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2617        }));
2618        assert!(
2619            manifest
2620                .extension_packs
2621                .as_ref()
2622                .unwrap()
2623                .iter()
2624                .all(|item| item.version == "latest")
2625        );
2626        assert!(
2627            manifest
2628                .components
2629                .as_ref()
2630                .unwrap()
2631                .iter()
2632                .all(|item| item.version == "latest")
2633        );
2634    }
2635
2636    #[test]
2637    fn publish_dry_run_with_local_manifest_succeeds() {
2638        let dir = tempfile::tempdir().unwrap();
2639        let path = dir.path().join("gtc-1.0.12.json");
2640        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2641        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2642
2643        // `--tag stable` puts this dry run inside the stable gate, so it needs a
2644        // checker; a live one would reach for github.com from a unit test.
2645        let checker = StubReleaseChecker::complete_for(&manifest);
2646        publish_with_checker(
2647            ReleasePublishArgs {
2648                release: None,
2649                from: None,
2650                tag: Some("stable".to_string()),
2651                manifest: Some(path),
2652                repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2653                token: None,
2654                out: dir.path().to_path_buf(),
2655                dry_run: true,
2656                force: false,
2657                no_notify_updater: true,
2658            },
2659            &checker,
2660        )
2661        .unwrap();
2662    }
2663
2664    // -----------------------------------------------------------------------
2665    // verify_manifest_releases — stable-lane release gate
2666    // -----------------------------------------------------------------------
2667
2668    /// Maps `"<crate>@<tag>"` to that release's asset names. An absent key means
2669    /// the release does not exist.
2670    struct StubReleaseChecker(BTreeMap<String, Vec<String>>);
2671
2672    impl ReleaseAssetChecker for StubReleaseChecker {
2673        fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
2674            Ok(self.0.get(&format!("{repo}@{tag}")).cloned())
2675        }
2676    }
2677
2678    impl StubReleaseChecker {
2679        /// Every package in `manifest` present with a complete asset set.
2680        fn complete_for(manifest: &ToolchainManifest) -> Self {
2681            Self(
2682                manifest
2683                    .packages
2684                    .iter()
2685                    .map(|package| {
2686                        (
2687                            format!("{}@v{}", package.crate_name, package.version),
2688                            complete_assets(&package.crate_name, &package.version),
2689                        )
2690                    })
2691                    .collect(),
2692            )
2693        }
2694
2695        fn with(entries: &[(&str, Vec<String>)]) -> Self {
2696            Self(
2697                entries
2698                    .iter()
2699                    .map(|(key, assets)| ((*key).to_string(), assets.clone()))
2700                    .collect(),
2701            )
2702        }
2703    }
2704
2705    /// One archive plus its checksum — the shape `ensure-release` ends up with.
2706    fn complete_assets(crate_name: &str, version: &str) -> Vec<String> {
2707        let archive = format!("{crate_name}-v{version}-x86_64-unknown-linux-gnu.tgz");
2708        vec![format!("{archive}.sha256"), archive]
2709    }
2710
2711    fn manifest_with_channel(
2712        channel: Option<&str>,
2713        packages: &[(&str, &str)],
2714    ) -> ToolchainManifest {
2715        ToolchainManifest {
2716            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2717            toolchain: TOOLCHAIN_NAME.to_string(),
2718            version: "1.1.13".to_string(),
2719            channel: channel.map(str::to_string),
2720            created_at: None,
2721            packages: packages
2722                .iter()
2723                .map(|(crate_name, version)| ToolchainPackage {
2724                    crate_name: (*crate_name).to_string(),
2725                    bins: vec![(*crate_name).to_string()],
2726                    version: (*version).to_string(),
2727                })
2728                .collect(),
2729            extension_packs: None,
2730            components: None,
2731        }
2732    }
2733
2734    #[test]
2735    fn release_gate_skips_dev_channel() {
2736        let manifest = manifest_with_channel(Some("dev"), &[("greentic-setup", "1.2.30516109579")]);
2737        // Empty checker: every release is "missing", so a firing gate would fail.
2738        verify_manifest_releases(&manifest, Some("dev"), &StubReleaseChecker::with(&[])).unwrap();
2739    }
2740
2741    #[test]
2742    fn release_gate_skips_manifest_without_channel_or_stable_tag() {
2743        let manifest = manifest_with_channel(None, &[("greentic-setup", "1.1.31")]);
2744        verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[])).unwrap();
2745    }
2746
2747    #[test]
2748    fn release_gate_accepts_complete_stable_releases() {
2749        let manifest = manifest_with_channel(
2750            Some("stable"),
2751            &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2752        );
2753        let checker = StubReleaseChecker::complete_for(&manifest);
2754        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2755    }
2756
2757    /// The regression this gate exists for: greentic-setup 1.1.31 was pinned
2758    /// while its release build was still running, so `:stable` moved onto a
2759    /// binary nobody could download.
2760    #[test]
2761    fn release_gate_rejects_pin_whose_release_does_not_exist() {
2762        let manifest = manifest_with_channel(
2763            Some("stable"),
2764            &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2765        );
2766        let checker = StubReleaseChecker::with(&[(
2767            "greentic-start@v1.1.38",
2768            complete_assets("greentic-start", "1.1.38"),
2769        )]);
2770        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2771            .unwrap_err()
2772            .to_string();
2773        assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2774        assert!(error.contains("no GitHub release"), "{error}");
2775        assert!(!error.contains("greentic-start"), "{error}");
2776    }
2777
2778    /// A `--from latest` manifest records `channel: "latest"`, yet `--tag stable`
2779    /// still moves the tag `gtc install` resolves. It must be gated.
2780    #[test]
2781    fn release_gate_fires_on_stable_tag_despite_latest_channel() {
2782        let manifest = manifest_with_channel(Some("latest"), &[("greentic-setup", "1.1.31")]);
2783        let error =
2784            verify_manifest_releases(&manifest, Some("stable"), &StubReleaseChecker::with(&[]))
2785                .unwrap_err()
2786                .to_string();
2787        assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2788    }
2789
2790    #[test]
2791    fn release_gate_rejects_release_with_no_archives_yet() {
2792        let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2793        let checker = StubReleaseChecker::with(&[("greentic-setup@v1.1.31", Vec::new())]);
2794        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2795            .unwrap_err()
2796            .to_string();
2797        assert!(error.contains("no v1.1.31 archives yet"), "{error}");
2798    }
2799
2800    #[test]
2801    fn release_gate_rejects_archive_missing_its_checksum() {
2802        let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2803        let checker = StubReleaseChecker::with(&[(
2804            "greentic-setup@v1.1.31",
2805            vec![
2806                "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz".to_string(),
2807                "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2808                "greentic-setup-v1.1.31-aarch64-apple-darwin.tgz".to_string(),
2809            ],
2810        )]);
2811        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2812            .unwrap_err()
2813            .to_string();
2814        assert!(error.contains("missing .sha256"), "{error}");
2815        assert!(error.contains("aarch64-apple-darwin"), "{error}");
2816    }
2817
2818    /// Multi-binary repos (greentic-mcp ships two) attach several archives per
2819    /// target; the rule is per-archive, not a fixed asset count.
2820    #[test]
2821    fn release_gate_accepts_multi_binary_release() {
2822        let manifest = manifest_with_channel(Some("stable"), &[("greentic-mcp", "1.1.1")]);
2823        let checker = StubReleaseChecker::with(&[(
2824            "greentic-mcp@v1.1.1",
2825            vec![
2826                "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz".to_string(),
2827                "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2828                "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip".to_string(),
2829                "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip.sha256".to_string(),
2830            ],
2831        )]);
2832        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2833    }
2834
2835    /// `snapshot --channel stable` passes no `--tag`, so the channel half of the
2836    /// predicate is what gates it. crates.io presence does not imply a finished
2837    /// release: greentic-pack publishes crates on its own `push: tags` trigger,
2838    /// independent of the release job.
2839    #[test]
2840    fn release_gate_fires_on_stable_channel_without_a_target_tag() {
2841        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2842        let error = verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[]))
2843            .unwrap_err()
2844            .to_string();
2845        assert!(error.contains("greentic-pack v1.1.5"), "{error}");
2846        assert!(error.contains("no GitHub release"), "{error}");
2847    }
2848
2849    /// greentic-pack attaches unversioned `greentic-pack-<target>.tgz` binstall
2850    /// aliases with no checksums next to the canonical versioned set. Requiring
2851    /// a `.sha256` for every archive rejected every real pack release.
2852    #[test]
2853    fn release_gate_ignores_unversioned_binstall_aliases() {
2854        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2855        let checker = StubReleaseChecker::with(&[(
2856            "greentic-pack@v1.1.5",
2857            vec![
2858                "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz".to_string(),
2859                "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2860                // Alias, no checksum — must not be read as an unfinished upload.
2861                "greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string(),
2862            ],
2863        )]);
2864        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2865    }
2866
2867    /// ...but aliases alone are not a usable release: binstall resolves the
2868    /// versioned names.
2869    #[test]
2870    fn release_gate_rejects_release_with_only_unversioned_aliases() {
2871        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2872        let checker = StubReleaseChecker::with(&[(
2873            "greentic-pack@v1.1.5",
2874            vec!["greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string()],
2875        )]);
2876        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2877            .unwrap_err()
2878            .to_string();
2879        assert!(error.contains("no v1.1.5 archives yet"), "{error}");
2880    }
2881
2882    #[test]
2883    fn release_response_404_means_absent() {
2884        assert_eq!(
2885            classify_release_response(reqwest::StatusCode::NOT_FOUND, "url", "{}".to_string())
2886                .unwrap(),
2887            None
2888        );
2889    }
2890
2891    #[test]
2892    fn release_response_success_returns_body() {
2893        assert_eq!(
2894            classify_release_response(reqwest::StatusCode::OK, "url", "{}".to_string()).unwrap(),
2895            Some("{}".to_string())
2896        );
2897    }
2898
2899    #[test]
2900    fn release_response_server_error_is_fatal() {
2901        // A 5xx must never be mistaken for "release absent" — that would let a
2902        // GitHub outage wave a bad manifest straight through the gate.
2903        assert!(
2904            classify_release_response(
2905                reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2906                "url",
2907                "boom".to_string()
2908            )
2909            .is_err()
2910        );
2911    }
2912
2913    #[test]
2914    fn latest_dry_run_succeeds() {
2915        latest(ReleaseLatestArgs {
2916            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2917            token: None,
2918            dry_run: true,
2919            force: false,
2920        })
2921        .unwrap();
2922    }
2923
2924    #[test]
2925    fn promote_dry_run_succeeds() {
2926        promote(ReleasePromoteArgs {
2927            release: "1.0.12".to_string(),
2928            tag: "stable".to_string(),
2929            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2930            token: None,
2931            dry_run: true,
2932            no_notify_updater: true,
2933        })
2934        .unwrap();
2935    }
2936
2937    #[test]
2938    fn builds_toolchain_ref() {
2939        assert_eq!(
2940            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
2941            "ghcr.io/greenticai/greentic-versions/gtc:stable"
2942        );
2943    }
2944
2945    // -----------------------------------------------------------------------
2946    // should_notify_updater — stable-lane gate tests
2947    // -----------------------------------------------------------------------
2948
2949    #[test]
2950    fn notify_gate_accepts_stable_plain_version() {
2951        assert!(should_notify_updater("1.1.2", "stable"));
2952    }
2953
2954    #[test]
2955    fn notify_gate_accepts_stable_zero_version() {
2956        assert!(should_notify_updater("0.1.0", "stable"));
2957    }
2958
2959    #[test]
2960    fn notify_gate_rejects_dev_channel() {
2961        assert!(!should_notify_updater("1.1.2", "dev"));
2962    }
2963
2964    #[test]
2965    fn notify_gate_rejects_rnd_channel() {
2966        assert!(!should_notify_updater("1.1.2", "rnd"));
2967    }
2968
2969    #[test]
2970    fn notify_gate_rejects_prerelease_version() {
2971        assert!(!should_notify_updater("1.2.0-dev.3", "stable"));
2972    }
2973
2974    #[test]
2975    fn notify_gate_rejects_run_id_version_on_dev_channel() {
2976        // Run-id versions (e.g. 1.1.14995680637) are dev-lane artifacts and
2977        // always carry channel "dev". The channel check catches them.
2978        assert!(!should_notify_updater("1.1.14995680637", "dev"));
2979    }
2980
2981    #[test]
2982    fn notify_gate_rejects_research_prerelease() {
2983        assert!(!should_notify_updater("1.3.0-research.1", "stable"));
2984    }
2985
2986    #[test]
2987    fn notify_gate_rejects_build_metadata() {
2988        assert!(!should_notify_updater("1.1.2+build.42", "stable"));
2989    }
2990
2991    #[test]
2992    fn notify_gate_rejects_empty_channel() {
2993        assert!(!should_notify_updater("1.1.2", ""));
2994    }
2995
2996    #[test]
2997    fn notify_gate_rejects_unparseable_version() {
2998        assert!(!should_notify_updater("not-a-version", "stable"));
2999    }
3000}